added multi filter for record view

This commit is contained in:
suryacp23 2026-07-29 12:58:13 +05:30
parent fa87647641
commit 6d503e4763
9 changed files with 441 additions and 71 deletions

View File

@ -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;
}),
}, },
}); });
} }

View File

@ -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) ---

View File

@ -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>
);
}

View File

@ -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}

View File

@ -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

View File

@ -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} />}

View File

@ -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)
@ -328,7 +429,7 @@ export function RecordView({
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>

View File

@ -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} />}

View File

@ -519,3 +519,14 @@
--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;
}