added multi filter for record view
This commit is contained in:
parent
fa87647641
commit
6d503e4763
@ -144,21 +144,29 @@ export class ZinoClient {
|
|||||||
// --- Views ---
|
// --- Views ---
|
||||||
|
|
||||||
recordView(rvUid: string, params: RecordViewParams = {}): Promise<RecordViewResponse> {
|
recordView(rvUid: string, params: RecordViewParams = {}): Promise<RecordViewResponse> {
|
||||||
|
const alias = params.preset_alias ?? params.presetAlias;
|
||||||
return this.request<RecordViewResponse>('POST', `/app/${APP_ID}/view/recordview`, {
|
return this.request<RecordViewResponse>('POST', `/app/${APP_ID}/view/recordview`, {
|
||||||
rv_template_uid: rvUid,
|
rv_template_uid: rvUid,
|
||||||
preset_alias: params.preset_alias,
|
...(alias ? { preset_alias: alias } : {}),
|
||||||
search_query: {
|
search_query: {
|
||||||
page: params.page ?? 1,
|
page: params.page ?? 1,
|
||||||
limit: params.limit ?? 50,
|
limit: params.limit ?? 50,
|
||||||
sort_by: params.sortBy ?? '',
|
sort_by: params.sortBy ?? '',
|
||||||
sort_dir: params.sortDir ?? 'desc',
|
sort_dir: params.sortDir ?? 'desc',
|
||||||
search: params.search ?? '',
|
search: params.search ?? '',
|
||||||
filters: (params.filters ?? []).map((f) => ({
|
filters: (params.filters ?? []).map((f) => {
|
||||||
field_key: f.field_key,
|
const item: Record<string, unknown> = {
|
||||||
value: f.value,
|
field_key: f.field_key,
|
||||||
value2: '',
|
data_type: f.data_type ?? 'character varying',
|
||||||
data_type: f.data_type ?? 'string',
|
};
|
||||||
})),
|
if (f.values && f.values.length > 0) {
|
||||||
|
item.values = f.values;
|
||||||
|
} else {
|
||||||
|
item.value = f.value ?? '';
|
||||||
|
item.value2 = '';
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -66,8 +66,9 @@ export interface RecordViewParams {
|
|||||||
sortBy?: string;
|
sortBy?: string;
|
||||||
sortDir?: 'asc' | 'desc';
|
sortDir?: 'asc' | 'desc';
|
||||||
search?: string;
|
search?: string;
|
||||||
filters?: Array<{ field_key: string; value: string; data_type?: string }>;
|
filters?: Array<{ field_key: string; value?: string; values?: string[]; data_type?: string }>;
|
||||||
preset_alias?: string;
|
preset_alias?: string;
|
||||||
|
presetAlias?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Form schema (POST /app/{appId}/view/form-screens) ---
|
// --- Form schema (POST /app/{appId}/view/form-screens) ---
|
||||||
|
|||||||
@ -216,3 +216,247 @@ export function Select({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MultiSelectProps {
|
||||||
|
label?: string;
|
||||||
|
hint?: string;
|
||||||
|
error?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
/** Either strings or {value,label} objects. */
|
||||||
|
options?: Array<string | SelectOption>;
|
||||||
|
value?: string[];
|
||||||
|
onChange?: (values: string[]) => void;
|
||||||
|
required?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
/** Disable search header filter if set to false */
|
||||||
|
searchable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Custom searchable multiselect component using React Portal. */
|
||||||
|
export function MultiSelect({
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
error,
|
||||||
|
options = [],
|
||||||
|
value = [],
|
||||||
|
onChange,
|
||||||
|
required,
|
||||||
|
className,
|
||||||
|
disabled,
|
||||||
|
placeholder,
|
||||||
|
searchable = true,
|
||||||
|
}: MultiSelectProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
|
||||||
|
const [draftValues, setDraftValues] = useState<string[]>(value ?? []);
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const normalizedOptions: SelectOption[] = options.map((o) =>
|
||||||
|
typeof o === 'string' ? { value: o, label: o } : { value: String(o.value ?? ''), label: o.label }
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentValues = Array.isArray(value) ? value : [];
|
||||||
|
|
||||||
|
const handleOpenToggle = () => {
|
||||||
|
if (!disabled) {
|
||||||
|
if (!isOpen) {
|
||||||
|
setDraftValues(currentValues);
|
||||||
|
setSearchQuery('');
|
||||||
|
}
|
||||||
|
setIsOpen(!isOpen);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredOptions = normalizedOptions.filter((o) =>
|
||||||
|
o.label.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatePosition = useCallback(() => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
const rect = containerRef.current.getBoundingClientRect();
|
||||||
|
const dropdownHeight = 320;
|
||||||
|
const spaceBelow = window.innerHeight - rect.bottom;
|
||||||
|
const openUpwards = spaceBelow < dropdownHeight && rect.top > dropdownHeight;
|
||||||
|
|
||||||
|
setDropdownStyle({
|
||||||
|
position: 'fixed',
|
||||||
|
left: `${rect.left}px`,
|
||||||
|
width: `${rect.width}px`,
|
||||||
|
zIndex: 999999,
|
||||||
|
...(openUpwards
|
||||||
|
? { bottom: `${window.innerHeight - rect.top + 4}px` }
|
||||||
|
: { top: `${rect.bottom + 4}px` }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
updatePosition();
|
||||||
|
const handleScrollOrResize = () => updatePosition();
|
||||||
|
window.addEventListener('resize', handleScrollOrResize);
|
||||||
|
window.addEventListener('scroll', handleScrollOrResize, true);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', handleScrollOrResize);
|
||||||
|
window.removeEventListener('scroll', handleScrollOrResize, true);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [isOpen, updatePosition]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
const target = e.target as Node;
|
||||||
|
const isOutsideContainer = containerRef.current && !containerRef.current.contains(target);
|
||||||
|
const isOutsideDropdown = dropdownRef.current && !dropdownRef.current.contains(target);
|
||||||
|
|
||||||
|
if (isOutsideContainer && isOutsideDropdown) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleOption = (val: string) => {
|
||||||
|
setDraftValues((prev) =>
|
||||||
|
prev.includes(val) ? prev.filter((v) => v !== val) : [...prev, val]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApply = () => {
|
||||||
|
onChange?.(draftValues);
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
setDraftValues([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedLabels = normalizedOptions
|
||||||
|
.filter((o) => currentValues.includes(o.value))
|
||||||
|
.map((o) => o.label);
|
||||||
|
|
||||||
|
const triggerText =
|
||||||
|
selectedLabels.length === 0
|
||||||
|
? placeholder || 'Select...'
|
||||||
|
: selectedLabels.length === 1
|
||||||
|
? selectedLabels[0]
|
||||||
|
: `${selectedLabels.length} selected`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className={cn('flex flex-col gap-1.5 font-sans relative w-full', className)}>
|
||||||
|
{label && (
|
||||||
|
<label className="text-xs font-bold text-slate-700">
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-ruby-600"> *</span>}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Trigger Box */}
|
||||||
|
<div
|
||||||
|
onClick={handleOpenToggle}
|
||||||
|
className={cn(
|
||||||
|
"relative bg-card rounded-md h-[40px] border px-3 flex items-center justify-between cursor-pointer transition-all duration-150 select-none",
|
||||||
|
disabled && "opacity-60 cursor-not-allowed bg-slate-50",
|
||||||
|
isOpen ? "border-navy-600 ring-2 ring-navy-600/20" : error ? "border-ruby-600" : "border-border-default"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn("text-xs truncate pr-2", currentValues.length > 0 ? "text-strong font-semibold" : "text-faint")}>
|
||||||
|
{triggerText}
|
||||||
|
</span>
|
||||||
|
<ChevronDown size={15} className={cn("transition-transform duration-150 text-faint shrink-0 ml-1", isOpen && "rotate-180")} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Portaled Dropdown Menu Overlay */}
|
||||||
|
{isOpen && !disabled && createPortal(
|
||||||
|
<div
|
||||||
|
ref={dropdownRef}
|
||||||
|
style={dropdownStyle}
|
||||||
|
className="bg-card border border-border-default rounded-md shadow-2xl overflow-hidden flex flex-col max-h-72 animate-in fade-in-50 duration-100 z-[999999]"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{searchable && (
|
||||||
|
<div className="p-2 border-b border-border-subtle bg-slate-50 flex items-center gap-2 shrink-0">
|
||||||
|
<Search size={14} className="text-muted shrink-0 ml-1" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Search options..."
|
||||||
|
className="w-full text-xs bg-transparent border-none outline-none text-strong placeholder:text-muted"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSearchQuery('')}
|
||||||
|
className="text-muted hover:text-strong p-0.5 rounded cursor-pointer"
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="overflow-y-auto flex-1 py-1 min-h-[100px]">
|
||||||
|
{filteredOptions.length > 0 ? (
|
||||||
|
filteredOptions.map((opt) => {
|
||||||
|
const isSelected = draftValues.includes(opt.value);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => toggleOption(opt.value)}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-2 text-xs flex items-center gap-2.5 cursor-pointer transition-colors select-none",
|
||||||
|
isSelected ? "bg-navy-50/40 text-navy-900 font-semibold" : "hover:bg-black/5 text-strong"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => {}}
|
||||||
|
className="rounded border-slate-300 text-navy-600 focus:ring-navy-500 pointer-events-none h-3.5 w-3.5"
|
||||||
|
/>
|
||||||
|
<span className="truncate flex-1">{opt.label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="px-3 py-3 text-xs text-muted text-center italic">
|
||||||
|
No matching options
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Footer */}
|
||||||
|
<div className="p-2 border-t border-border-subtle bg-slate-50 flex justify-between items-center text-xs shrink-0 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClear}
|
||||||
|
disabled={draftValues.length === 0}
|
||||||
|
className="text-slate-500 hover:text-ruby-600 disabled:opacity-40 font-medium text-[11px] px-2 py-1 rounded cursor-pointer"
|
||||||
|
>
|
||||||
|
Clear ({draftValues.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleApply}
|
||||||
|
className="h-8 px-3.5 bg-primary text-white rounded font-bold text-xs hover:opacity-90 transition-colors shadow-sm cursor-pointer"
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(hint || error) && (
|
||||||
|
<span className={cn('text-xs', error ? 'text-ruby-600' : 'text-faint')}>{error || hint}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import type { WiredRecordViewProps } from './OrdersView';
|
|||||||
import { CallCard } from '../cards/CallCard';
|
import { CallCard } from '../cards/CallCard';
|
||||||
|
|
||||||
/** Calls record view (Calls/Visits workflow). */
|
/** Calls record view (Calls/Visits workflow). */
|
||||||
export function CallsView({ onRowClick, pageSize, headerActions, rowActions, refreshKey }: WiredRecordViewProps) {
|
export function CallsView({ onRowClick, pageSize, headerActions, rowActions, refreshKey, initialFilters }: WiredRecordViewProps) {
|
||||||
return (
|
return (
|
||||||
<RecordView
|
<RecordView
|
||||||
client={orderBookingClient}
|
client={orderBookingClient}
|
||||||
@ -16,6 +16,7 @@ export function CallsView({ onRowClick, pageSize, headerActions, rowActions, ref
|
|||||||
headerActions={headerActions}
|
headerActions={headerActions}
|
||||||
rowActions={rowActions}
|
rowActions={rowActions}
|
||||||
refreshKey={refreshKey}
|
refreshKey={refreshKey}
|
||||||
|
initialFilters={initialFilters}
|
||||||
sortBy="instance_id"
|
sortBy="instance_id"
|
||||||
sortDir="desc"
|
sortDir="desc"
|
||||||
hideChart={true}
|
hideChart={true}
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import type { WiredRecordViewProps } from './OrdersView';
|
|||||||
import { DailyLogCard } from '../cards/DailyLogCard';
|
import { DailyLogCard } from '../cards/DailyLogCard';
|
||||||
|
|
||||||
/** Daily Logs record view (Daily Reports workflow). */
|
/** Daily Logs record view (Daily Reports workflow). */
|
||||||
export function DailyLogsView({ onRowClick, onPunchOutRow, pageSize, headerActions, refreshKey, preset_alias = "my_logs", title = "Daily Logs", hideTiles }: WiredRecordViewProps & { onPunchOutRow?: (row: any) => void, preset_alias?: string, title?: string, hideTiles?: boolean }) {
|
export function DailyLogsView({ onRowClick, onPunchOutRow, pageSize, headerActions, refreshKey, preset_alias = "my_logs", title = "Daily Logs", hideTiles, initialFilters }: WiredRecordViewProps & { onPunchOutRow?: (row: any) => void, preset_alias?: string, title?: string, hideTiles?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<RecordView
|
<RecordView
|
||||||
client={dailyReportsClient}
|
client={dailyReportsClient}
|
||||||
@ -16,6 +16,7 @@ export function DailyLogsView({ onRowClick, onPunchOutRow, pageSize, headerActio
|
|||||||
headerActions={headerActions}
|
headerActions={headerActions}
|
||||||
refreshKey={refreshKey}
|
refreshKey={refreshKey}
|
||||||
preset_alias={preset_alias}
|
preset_alias={preset_alias}
|
||||||
|
initialFilters={initialFilters}
|
||||||
sortBy="instance_id"
|
sortBy="instance_id"
|
||||||
sortDir="desc"
|
sortDir="desc"
|
||||||
hideChart
|
hideChart
|
||||||
|
|||||||
@ -9,10 +9,11 @@ export interface WiredRecordViewProps {
|
|||||||
headerActions?: React.ReactNode;
|
headerActions?: React.ReactNode;
|
||||||
rowActions?: (row: Record<string, unknown>) => React.ReactNode;
|
rowActions?: (row: Record<string, unknown>) => React.ReactNode;
|
||||||
refreshKey?: number;
|
refreshKey?: number;
|
||||||
|
initialFilters?: Record<string, string | string[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Orders record view (Order Booking workflow). */
|
/** Orders record view (Order Booking workflow). */
|
||||||
export function OrdersView({ onRowClick, pageSize, headerActions, rowActions, refreshKey }: WiredRecordViewProps) {
|
export function OrdersView({ onRowClick, pageSize, headerActions, rowActions, refreshKey, initialFilters }: WiredRecordViewProps) {
|
||||||
return (
|
return (
|
||||||
<RecordView
|
<RecordView
|
||||||
client={orderBookingClient}
|
client={orderBookingClient}
|
||||||
@ -23,6 +24,7 @@ export function OrdersView({ onRowClick, pageSize, headerActions, rowActions, re
|
|||||||
headerActions={headerActions}
|
headerActions={headerActions}
|
||||||
rowActions={rowActions}
|
rowActions={rowActions}
|
||||||
refreshKey={refreshKey}
|
refreshKey={refreshKey}
|
||||||
|
initialFilters={initialFilters}
|
||||||
sortBy="instance_id"
|
sortBy="instance_id"
|
||||||
sortDir="desc"
|
sortDir="desc"
|
||||||
renderItem={(row, fields) => <OrderCard row={row} fields={fields} />}
|
renderItem={(row, fields) => <OrderCard row={row} fields={fields} />}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Search, SlidersHorizontal } from 'lucide-react';
|
import { Search, SlidersHorizontal, X } from 'lucide-react';
|
||||||
import { cn } from '../../lib/cn';
|
import { cn } from '../../lib/cn';
|
||||||
import { formatValue } from '../../lib/format';
|
import { formatValue } from '../../lib/format';
|
||||||
import type { ZinoClient } from '../../api/client';
|
import type { ZinoClient } from '../../api/client';
|
||||||
@ -9,7 +9,7 @@ import { Spinner } from '../reusable/Spinner';
|
|||||||
import { EmptyState } from '../reusable/EmptyState';
|
import { EmptyState } from '../reusable/EmptyState';
|
||||||
import { StatsTiles } from '../reusable/StatsTiles';
|
import { StatsTiles } from '../reusable/StatsTiles';
|
||||||
import { AnalyticsChart } from '../reusable/AnalyticsChart';
|
import { AnalyticsChart } from '../reusable/AnalyticsChart';
|
||||||
import { Select } from '../reusable/Select';
|
import { MultiSelect } from '../reusable/Select';
|
||||||
import { Input } from '../reusable/Input';
|
import { Input } from '../reusable/Input';
|
||||||
import { Modal } from '../reusable/Modal';
|
import { Modal } from '../reusable/Modal';
|
||||||
|
|
||||||
@ -22,7 +22,9 @@ export interface RecordViewProps {
|
|||||||
title?: string;
|
title?: string;
|
||||||
/** Restrict/order visible columns by field_key. Defaults to all fields. */
|
/** Restrict/order visible columns by field_key. Defaults to all fields. */
|
||||||
columns?: string[];
|
columns?: string[];
|
||||||
/** Rows per page. @default 25 */
|
/** Columns to hide. */
|
||||||
|
omitColumns?: string[];
|
||||||
|
/** Rows per page. @default 20 */
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
/** Click handler — receives the raw row + index. */
|
/** Click handler — receives the raw row + index. */
|
||||||
onRowClick?: (row: Record<string, unknown>, index: number) => void;
|
onRowClick?: (row: Record<string, unknown>, index: number) => void;
|
||||||
@ -46,18 +48,20 @@ export interface RecordViewProps {
|
|||||||
hideTiles?: boolean;
|
hideTiles?: boolean;
|
||||||
/** Pass a preset alias to apply server-side presets */
|
/** Pass a preset alias to apply server-side presets */
|
||||||
preset_alias?: string;
|
preset_alias?: string;
|
||||||
|
/** Default filters to apply initially. */
|
||||||
|
initialFilters?: Record<string, string | string[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generic Zino record-view table. Fetches `POST /app/{id}/view/recordview`
|
* Generic Zino record-view table for mobile. Fetches `POST /app/{id}/view/recordview`
|
||||||
* (server-side paginated + searchable) and renders config.fields as columns.
|
* (server-side paginated + searchable) and renders cards or table items.
|
||||||
* Wire it to a specific view via the thin wrappers in this folder.
|
|
||||||
*/
|
*/
|
||||||
export function RecordView({
|
export function RecordView({
|
||||||
client,
|
client,
|
||||||
rvUid,
|
rvUid,
|
||||||
title = 'Records',
|
title = 'Records',
|
||||||
columns,
|
columns,
|
||||||
|
omitColumns,
|
||||||
pageSize = 20,
|
pageSize = 20,
|
||||||
onRowClick,
|
onRowClick,
|
||||||
rowKey,
|
rowKey,
|
||||||
@ -70,13 +74,29 @@ export function RecordView({
|
|||||||
hideChart = false,
|
hideChart = false,
|
||||||
hideTiles = false,
|
hideTiles = false,
|
||||||
preset_alias,
|
preset_alias,
|
||||||
|
initialFilters = {},
|
||||||
}: RecordViewProps) {
|
}: RecordViewProps) {
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [activePageSize, setActivePageSize] = useState(pageSize);
|
const [activePageSize, setActivePageSize] = useState(pageSize);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [debounced, setDebounced] = useState('');
|
const [debounced, setDebounced] = useState('');
|
||||||
const [activeFilters, setActiveFilters] = useState<Record<string, string>>({});
|
|
||||||
const [pendingFilters, setPendingFilters] = useState<Record<string, string>>({});
|
const initialFiltersNormalized = useMemo(() => {
|
||||||
|
const res: Record<string, string[]> = {};
|
||||||
|
if (initialFilters) {
|
||||||
|
Object.entries(initialFilters).forEach(([k, v]) => {
|
||||||
|
if (Array.isArray(v)) {
|
||||||
|
res[k] = v;
|
||||||
|
} else if (v) {
|
||||||
|
res[k] = [v];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}, [initialFilters]);
|
||||||
|
|
||||||
|
const [activeFilters, setActiveFilters] = useState<Record<string, string[]>>(initialFiltersNormalized);
|
||||||
|
const [pendingFilters, setPendingFilters] = useState<Record<string, string[]>>(initialFiltersNormalized);
|
||||||
const [showFilters, setShowFilters] = useState(false);
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
const [resp, setResp] = useState<RecordViewResponse | null>(null);
|
const [resp, setResp] = useState<RecordViewResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@ -95,15 +115,18 @@ export function RecordView({
|
|||||||
};
|
};
|
||||||
}, [search]);
|
}, [search]);
|
||||||
|
|
||||||
|
const activeFiltersStr = JSON.stringify(activeFilters);
|
||||||
|
|
||||||
const filtersParam = useMemo(() => {
|
const filtersParam = useMemo(() => {
|
||||||
return Object.entries(activeFilters)
|
const parsed = JSON.parse(activeFiltersStr) as Record<string, string[]>;
|
||||||
.filter(([, val]) => val)
|
return Object.entries(parsed)
|
||||||
.map(([key, val]) => ({
|
.filter(([_, vals]) => vals && vals.length > 0)
|
||||||
|
.map(([key, vals]) => ({
|
||||||
field_key: key,
|
field_key: key,
|
||||||
value: val,
|
values: vals,
|
||||||
data_type: 'string',
|
data_type: 'character varying',
|
||||||
}));
|
}));
|
||||||
}, [activeFilters]);
|
}, [activeFiltersStr]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let live = true;
|
let live = true;
|
||||||
@ -127,26 +150,44 @@ export function RecordView({
|
|||||||
|
|
||||||
const fields: RecordViewField[] = useMemo(() => {
|
const fields: RecordViewField[] = useMemo(() => {
|
||||||
const all = resp?.config.fields ?? [];
|
const all = resp?.config.fields ?? [];
|
||||||
if (!columns) return all;
|
let result = all;
|
||||||
const byKey = new Map(all.map((f) => [f.field_key, f]));
|
if (columns) {
|
||||||
return columns
|
const byKey = new Map(all.map((f) => [f.field_key, f]));
|
||||||
.map((k) => byKey.get(k) ?? ({ field_key: k, output_label: k, data_type: 'string', is_filter: false, is_search: false } as RecordViewField))
|
result = columns
|
||||||
.filter(Boolean);
|
.map((k) => byKey.get(k) ?? ({ field_key: k, output_label: k, data_type: 'string', is_filter: false, is_search: false } as RecordViewField))
|
||||||
}, [resp, columns]);
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
if (omitColumns) {
|
||||||
|
const omitSet = new Set(omitColumns);
|
||||||
|
result = result.filter((f) => !omitSet.has(f.field_key));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [resp, columns, omitColumns]);
|
||||||
|
|
||||||
const rows = resp?.data ?? [];
|
const rows = resp?.data ?? [];
|
||||||
const total = resp?.pagination?.total_count ?? rows.length;
|
const total = resp?.pagination?.total_count ?? rows.length;
|
||||||
|
|
||||||
const tileValues = resp?.tile_values;
|
const tileValues = resp?.tile_values;
|
||||||
|
|
||||||
const chartData = resp?.chart_data;
|
const chartData = resp?.chart_data;
|
||||||
|
|
||||||
const filterEntries = resp?.config?.filter_options
|
const filterEntries = useMemo(() => {
|
||||||
? Object.entries(resp.config.filter_options).filter(([, o]) => o && o.length > 0)
|
if (!resp?.config) return [];
|
||||||
: [];
|
const keys = new Set<string>();
|
||||||
|
resp.config.fields.forEach((f) => {
|
||||||
|
if (f.is_filter) keys.add(f.field_key);
|
||||||
|
});
|
||||||
|
if (resp.config.filter_options) {
|
||||||
|
Object.keys(resp.config.filter_options).forEach((k) => keys.add(k));
|
||||||
|
}
|
||||||
|
return Array.from(keys);
|
||||||
|
}, [resp]);
|
||||||
|
|
||||||
|
const activeFilterCount = useMemo(() => {
|
||||||
|
return Object.values(activeFilters).reduce((acc, vals) => acc + (vals ? vals.length : 0), 0);
|
||||||
|
}, [activeFilters]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5 w-full">
|
<div className="flex flex-col gap-5 w-full max-w-full overflow-x-hidden">
|
||||||
{!hideTiles && <StatsTiles tiles={tileValues} />}
|
{!hideTiles && <StatsTiles tiles={tileValues} />}
|
||||||
|
|
||||||
{!hideChart && <AnalyticsChart data={chartData} />}
|
{!hideChart && <AnalyticsChart data={chartData} />}
|
||||||
@ -156,9 +197,9 @@ export function RecordView({
|
|||||||
{headerActions}
|
{headerActions}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2.5">
|
<div className="flex flex-col gap-2.5 w-full max-w-full">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 w-full pr-1.5">
|
||||||
<div className="relative flex-1 focus-ring rounded-pill border border-border-subtle bg-card shadow-sm">
|
<div className="relative flex-1 focus-ring rounded-pill border border-border-subtle bg-card shadow-sm min-w-0">
|
||||||
<Search size={16} className="absolute left-4 top-1/2 -translate-y-1/2 text-faint pointer-events-none" />
|
<Search size={16} className="absolute left-4 top-1/2 -translate-y-1/2 text-faint pointer-events-none" />
|
||||||
<input
|
<input
|
||||||
value={search}
|
value={search}
|
||||||
@ -174,19 +215,68 @@ export function RecordView({
|
|||||||
setShowFilters(true);
|
setShowFilters(true);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex items-center justify-center w-11 h-11 rounded-lg transition-colors shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-primary",
|
"relative flex items-center justify-center w-11 h-11 rounded-lg transition-colors shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-primary cursor-pointer shrink-0",
|
||||||
"bg-primary text-white border border-primary hover:opacity-90"
|
"bg-primary text-white border border-primary hover:opacity-90"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<SlidersHorizontal size={16} className="text-white" />
|
<SlidersHorizontal size={16} className="text-white" />
|
||||||
{Object.values(activeFilters).filter(Boolean).length > 0 && (
|
{activeFilterCount > 0 && (
|
||||||
<span className="absolute -top-1 -right-1 bg-white text-primary text-[10px] w-4 h-4 flex items-center justify-center rounded-full font-bold shadow-sm border border-primary">
|
<span className="absolute top-1.5 right-1.5 w-2.5 h-2.5 bg-amber-400 rounded-full ring-2 ring-primary shadow-xs" />
|
||||||
{Object.values(activeFilters).filter(Boolean).length}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Active Filter Chips & Clear All */}
|
||||||
|
{Object.entries(activeFilters).some(([_, vals]) => vals && vals.length > 0) && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 px-3 py-2 bg-slate-100/80 rounded-lg border border-border-subtle max-w-full overflow-hidden">
|
||||||
|
<span className="text-xs font-semibold text-slate-600 mr-0.5 shrink-0">Filters:</span>
|
||||||
|
{Object.entries(activeFilters).flatMap(([key, vals]) => {
|
||||||
|
const fieldDef = resp?.config?.fields?.find((f) => f.field_key === key);
|
||||||
|
const label = fieldDef?.output_label || key.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
|
return (vals || []).map((val) => (
|
||||||
|
<span
|
||||||
|
key={`${key}-${val}`}
|
||||||
|
className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-navy-50 text-navy-900 border border-navy-200/80 shadow-xs max-w-full truncate"
|
||||||
|
>
|
||||||
|
<span className="font-bold text-navy-950 truncate max-w-[110px]">{label}:</span>
|
||||||
|
<span className="truncate max-w-[130px]">{val}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setActiveFilters((prev) => {
|
||||||
|
const nextVals = (prev[key] || []).filter((v) => v !== val);
|
||||||
|
const next = { ...prev };
|
||||||
|
if (nextVals.length === 0) {
|
||||||
|
delete next[key];
|
||||||
|
} else {
|
||||||
|
next[key] = nextVals;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className="hover:bg-navy-200/60 rounded-full p-0.5 text-navy-700 hover:text-navy-950 cursor-pointer shrink-0"
|
||||||
|
title={`Remove ${val}`}
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
));
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setActiveFilters({});
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className="ml-auto text-xs font-bold text-ruby-600 hover:text-ruby-700 bg-ruby-50 hover:bg-ruby-100 border border-ruby-200 px-2.5 py-0.5 rounded-md transition-colors cursor-pointer shrink-0"
|
||||||
|
>
|
||||||
|
Clear All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{filterEntries.length > 0 && (
|
{filterEntries.length > 0 && (
|
||||||
<Modal
|
<Modal
|
||||||
open={showFilters}
|
open={showFilters}
|
||||||
@ -194,7 +284,7 @@ export function RecordView({
|
|||||||
title="Filters"
|
title="Filters"
|
||||||
width="sm"
|
width="sm"
|
||||||
actions={
|
actions={
|
||||||
Object.values(pendingFilters).some(Boolean) && (
|
Object.values(pendingFilters).some((vals) => vals && vals.length > 0) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setPendingFilters({});
|
setPendingFilters({});
|
||||||
@ -202,7 +292,7 @@ export function RecordView({
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
setShowFilters(false);
|
setShowFilters(false);
|
||||||
}}
|
}}
|
||||||
className="text-xs font-semibold text-ruby-600 hover:text-ruby-700 bg-ruby-50 px-3 py-1.5 rounded-md"
|
className="text-xs font-semibold text-ruby-600 hover:text-ruby-700 bg-ruby-50 px-3 py-1.5 rounded-md cursor-pointer"
|
||||||
>
|
>
|
||||||
Clear All
|
Clear All
|
||||||
</button>
|
</button>
|
||||||
@ -210,39 +300,50 @@ export function RecordView({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
{filterEntries.map(([key, options]) => {
|
{filterEntries.map((key) => {
|
||||||
const fieldDef = resp?.config.fields.find(f => f.field_key === key);
|
const fieldDef = resp?.config.fields.find((f) => f.field_key === key);
|
||||||
const label = fieldDef?.output_label || key;
|
const label = fieldDef?.output_label || key.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
const isDateField = fieldDef?.data_type === 'date' || key.toLowerCase().includes('date');
|
const isDateField =
|
||||||
|
fieldDef?.data_type === 'date' ||
|
||||||
|
fieldDef?.data_type === 'datetime' ||
|
||||||
|
key.toLowerCase().includes('date') ||
|
||||||
|
key.toLowerCase() === 'created_at' ||
|
||||||
|
key.toLowerCase() === 'updated_at';
|
||||||
|
|
||||||
if (isDateField) {
|
if (isDateField) {
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
key={key}
|
key={key}
|
||||||
type="date"
|
type="date"
|
||||||
label={label.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase())}
|
label={label}
|
||||||
value={pendingFilters[key] || ''}
|
value={pendingFilters[key]?.[0] || ''}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setPendingFilters(prev => ({ ...prev, [key]: e.target.value }));
|
const val = e.target.value;
|
||||||
|
setPendingFilters((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[key]: val ? [val] : [],
|
||||||
|
}));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const opts = options!.map(o => ({
|
const options = resp?.config.filter_options?.[key] || [];
|
||||||
|
const opts = options.map((o) => ({
|
||||||
value: o,
|
value: o,
|
||||||
label: o.replace(/[_\-\s]*\(?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\)?/i, '').trim() || o
|
label: o.replace(/[_\-\s]*\(?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\)?/i, '').trim() || o,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<MultiSelect
|
||||||
key={key}
|
key={key}
|
||||||
label={label.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase())}
|
label={label}
|
||||||
value={pendingFilters[key] || ''}
|
placeholder={`Select ${label}...`}
|
||||||
onChange={(e) => {
|
value={pendingFilters[key] || []}
|
||||||
setPendingFilters(prev => ({ ...prev, [key]: e.target.value }));
|
onChange={(newVals) => {
|
||||||
|
setPendingFilters((prev) => ({ ...prev, [key]: newVals }));
|
||||||
}}
|
}}
|
||||||
options={[{ value: '', label: 'All' }, ...opts]}
|
options={opts}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -253,7 +354,7 @@ export function RecordView({
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
setShowFilters(false);
|
setShowFilters(false);
|
||||||
}}
|
}}
|
||||||
className="mt-4 w-full h-11 bg-slate-800 text-white font-bold rounded-lg hover:bg-slate-700 transition-colors shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-slate-800"
|
className="mt-4 w-full h-11 bg-primary text-white font-bold rounded-lg hover:opacity-90 transition-colors shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary cursor-pointer"
|
||||||
>
|
>
|
||||||
Apply Filters
|
Apply Filters
|
||||||
</button>
|
</button>
|
||||||
@ -284,7 +385,7 @@ export function RecordView({
|
|||||||
onRowClick && 'cursor-pointer active:scale-[0.99]',
|
onRowClick && 'cursor-pointer active:scale-[0.99]',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className={cn(renderItem ? "" : "flex items-center gap-3")}>
|
<div className={cn(renderItem ? '' : 'flex items-center gap-3')}>
|
||||||
<div className="min-w-0 flex-1 w-full">
|
<div className="min-w-0 flex-1 w-full">
|
||||||
{renderItem ? (
|
{renderItem ? (
|
||||||
renderItem(row, fields)
|
renderItem(row, fields)
|
||||||
@ -321,15 +422,15 @@ export function RecordView({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Pagination
|
<Pagination
|
||||||
page={page}
|
page={page}
|
||||||
pageSize={activePageSize}
|
pageSize={activePageSize}
|
||||||
total={total}
|
total={total}
|
||||||
onPage={setPage}
|
onPage={setPage}
|
||||||
onPageSizeChange={(size) => {
|
onPageSizeChange={(size) => {
|
||||||
setActivePageSize(size);
|
setActivePageSize(size);
|
||||||
setPage(1); // Reset to page 1 when changing page size
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import type { WiredRecordViewProps } from './OrdersView';
|
|||||||
import { StoreCard } from '../cards/StoreCard';
|
import { StoreCard } from '../cards/StoreCard';
|
||||||
|
|
||||||
/** Store record view (Store workflow). */
|
/** Store record view (Store workflow). */
|
||||||
export function StoresView({ onRowClick, onEditRow, pageSize, headerActions, refreshKey }: WiredRecordViewProps & { headerActions?: React.ReactNode; onEditRow?: (row: any) => void }) {
|
export function StoresView({ onRowClick, onEditRow, pageSize, headerActions, refreshKey, initialFilters }: WiredRecordViewProps & { headerActions?: React.ReactNode; onEditRow?: (row: any) => void }) {
|
||||||
return (
|
return (
|
||||||
<RecordView
|
<RecordView
|
||||||
client={storeClient}
|
client={storeClient}
|
||||||
@ -15,6 +15,7 @@ export function StoresView({ onRowClick, onEditRow, pageSize, headerActions, ref
|
|||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
headerActions={headerActions}
|
headerActions={headerActions}
|
||||||
refreshKey={refreshKey}
|
refreshKey={refreshKey}
|
||||||
|
initialFilters={initialFilters}
|
||||||
sortBy="instance_id"
|
sortBy="instance_id"
|
||||||
sortDir="desc"
|
sortDir="desc"
|
||||||
renderItem={(row) => <StoreCard row={row} onEdit={onEditRow} />}
|
renderItem={(row) => <StoreCard row={row} onEdit={onEditRow} />}
|
||||||
|
|||||||
@ -518,4 +518,15 @@
|
|||||||
--z-border-success-200: #6ce9a6;
|
--z-border-success-200: #6ce9a6;
|
||||||
--z-border-success-300: #32d584;
|
--z-border-success-300: #32d584;
|
||||||
--z-border-success-400: #12b76a;
|
--z-border-success-400: #12b76a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar hiding utilities */
|
||||||
|
.no-scrollbar::-webkit-scrollbar,
|
||||||
|
.hide-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.no-scrollbar,
|
||||||
|
.hide-scrollbar {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue
Block a user