From 6d503e47639dfc360776b2c55416efac63470eec Mon Sep 17 00:00:00 2001 From: suryacp23 Date: Wed, 29 Jul 2026 12:58:13 +0530 Subject: [PATCH] added multi filter for record view --- src/api/client.ts | 22 ++- src/api/types.ts | 3 +- src/components/reusable/Select.tsx | 244 ++++++++++++++++++++++++++++ src/components/rv/CallsView.tsx | 3 +- src/components/rv/DailyLogsView.tsx | 3 +- src/components/rv/OrdersView.tsx | 4 +- src/components/rv/RecordView.tsx | 219 ++++++++++++++++++------- src/components/rv/StoresView.tsx | 3 +- src/styles/styles.css | 11 ++ 9 files changed, 441 insertions(+), 71 deletions(-) diff --git a/src/api/client.ts b/src/api/client.ts index cf8a2b8..b0e0454 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -144,21 +144,29 @@ export class ZinoClient { // --- Views --- recordView(rvUid: string, params: RecordViewParams = {}): Promise { + const alias = params.preset_alias ?? params.presetAlias; return this.request('POST', `/app/${APP_ID}/view/recordview`, { rv_template_uid: rvUid, - preset_alias: params.preset_alias, + ...(alias ? { preset_alias: alias } : {}), search_query: { page: params.page ?? 1, limit: params.limit ?? 50, sort_by: params.sortBy ?? '', sort_dir: params.sortDir ?? 'desc', search: params.search ?? '', - filters: (params.filters ?? []).map((f) => ({ - field_key: f.field_key, - value: f.value, - value2: '', - data_type: f.data_type ?? 'string', - })), + filters: (params.filters ?? []).map((f) => { + const item: Record = { + field_key: f.field_key, + data_type: f.data_type ?? 'character varying', + }; + if (f.values && f.values.length > 0) { + item.values = f.values; + } else { + item.value = f.value ?? ''; + item.value2 = ''; + } + return item; + }), }, }); } diff --git a/src/api/types.ts b/src/api/types.ts index 383bf54..f2c9077 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -66,8 +66,9 @@ export interface RecordViewParams { sortBy?: string; sortDir?: 'asc' | 'desc'; 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; + presetAlias?: string; } // --- Form schema (POST /app/{appId}/view/form-screens) --- diff --git a/src/components/reusable/Select.tsx b/src/components/reusable/Select.tsx index e8e479d..1a2cd96 100644 --- a/src/components/reusable/Select.tsx +++ b/src/components/reusable/Select.tsx @@ -216,3 +216,247 @@ export function Select({ ); } + +export interface MultiSelectProps { + label?: string; + hint?: string; + error?: string; + placeholder?: string; + /** Either strings or {value,label} objects. */ + options?: Array; + 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({}); + const [draftValues, setDraftValues] = useState(value ?? []); + + const containerRef = useRef(null); + const dropdownRef = useRef(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 ( +
+ {label && ( + + )} + + {/* Trigger Box */} +
+ 0 ? "text-strong font-semibold" : "text-faint")}> + {triggerText} + + +
+ + {/* Portaled Dropdown Menu Overlay */} + {isOpen && !disabled && createPortal( +
e.stopPropagation()} + > + {searchable && ( +
+ + 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 && ( + + )} +
+ )} + +
+ {filteredOptions.length > 0 ? ( + filteredOptions.map((opt) => { + const isSelected = draftValues.includes(opt.value); + return ( +
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" + )} + > + {}} + className="rounded border-slate-300 text-navy-600 focus:ring-navy-500 pointer-events-none h-3.5 w-3.5" + /> + {opt.label} +
+ ); + }) + ) : ( +
+ No matching options +
+ )} +
+ + {/* Action Footer */} +
+ + +
+
, + document.body + )} + + {(hint || error) && ( + {error || hint} + )} +
+ ); +} diff --git a/src/components/rv/CallsView.tsx b/src/components/rv/CallsView.tsx index ed75986..c1330e4 100644 --- a/src/components/rv/CallsView.tsx +++ b/src/components/rv/CallsView.tsx @@ -5,7 +5,7 @@ import type { WiredRecordViewProps } from './OrdersView'; import { CallCard } from '../cards/CallCard'; /** 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 ( 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 ( ) => React.ReactNode; refreshKey?: number; + initialFilters?: Record; } /** 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 ( } diff --git a/src/components/rv/RecordView.tsx b/src/components/rv/RecordView.tsx index 275f2e0..96336d4 100644 --- a/src/components/rv/RecordView.tsx +++ b/src/components/rv/RecordView.tsx @@ -1,5 +1,5 @@ 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 { formatValue } from '../../lib/format'; import type { ZinoClient } from '../../api/client'; @@ -9,7 +9,7 @@ import { Spinner } from '../reusable/Spinner'; import { EmptyState } from '../reusable/EmptyState'; import { StatsTiles } from '../reusable/StatsTiles'; import { AnalyticsChart } from '../reusable/AnalyticsChart'; -import { Select } from '../reusable/Select'; +import { MultiSelect } from '../reusable/Select'; import { Input } from '../reusable/Input'; import { Modal } from '../reusable/Modal'; @@ -22,7 +22,9 @@ export interface RecordViewProps { title?: string; /** Restrict/order visible columns by field_key. Defaults to all fields. */ columns?: string[]; - /** Rows per page. @default 25 */ + /** Columns to hide. */ + omitColumns?: string[]; + /** Rows per page. @default 20 */ pageSize?: number; /** Click handler — receives the raw row + index. */ onRowClick?: (row: Record, index: number) => void; @@ -46,18 +48,20 @@ export interface RecordViewProps { hideTiles?: boolean; /** Pass a preset alias to apply server-side presets */ preset_alias?: string; + /** Default filters to apply initially. */ + initialFilters?: Record; } /** - * Generic Zino record-view table. Fetches `POST /app/{id}/view/recordview` - * (server-side paginated + searchable) and renders config.fields as columns. - * Wire it to a specific view via the thin wrappers in this folder. + * Generic Zino record-view table for mobile. Fetches `POST /app/{id}/view/recordview` + * (server-side paginated + searchable) and renders cards or table items. */ export function RecordView({ client, rvUid, title = 'Records', columns, + omitColumns, pageSize = 20, onRowClick, rowKey, @@ -70,13 +74,29 @@ export function RecordView({ hideChart = false, hideTiles = false, preset_alias, + initialFilters = {}, }: RecordViewProps) { const [page, setPage] = useState(1); const [activePageSize, setActivePageSize] = useState(pageSize); const [search, setSearch] = useState(''); const [debounced, setDebounced] = useState(''); - const [activeFilters, setActiveFilters] = useState>({}); - const [pendingFilters, setPendingFilters] = useState>({}); + + const initialFiltersNormalized = useMemo(() => { + const res: Record = {}; + 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>(initialFiltersNormalized); + const [pendingFilters, setPendingFilters] = useState>(initialFiltersNormalized); const [showFilters, setShowFilters] = useState(false); const [resp, setResp] = useState(null); const [loading, setLoading] = useState(true); @@ -95,15 +115,18 @@ export function RecordView({ }; }, [search]); + const activeFiltersStr = JSON.stringify(activeFilters); + const filtersParam = useMemo(() => { - return Object.entries(activeFilters) - .filter(([, val]) => val) - .map(([key, val]) => ({ + const parsed = JSON.parse(activeFiltersStr) as Record; + return Object.entries(parsed) + .filter(([_, vals]) => vals && vals.length > 0) + .map(([key, vals]) => ({ field_key: key, - value: val, - data_type: 'string', + values: vals, + data_type: 'character varying', })); - }, [activeFilters]); + }, [activeFiltersStr]); useEffect(() => { let live = true; @@ -127,26 +150,44 @@ export function RecordView({ const fields: RecordViewField[] = useMemo(() => { const all = resp?.config.fields ?? []; - if (!columns) return all; - const byKey = new Map(all.map((f) => [f.field_key, f])); - return columns - .map((k) => byKey.get(k) ?? ({ field_key: k, output_label: k, data_type: 'string', is_filter: false, is_search: false } as RecordViewField)) - .filter(Boolean); - }, [resp, columns]); + let result = all; + if (columns) { + const byKey = new Map(all.map((f) => [f.field_key, f])); + result = columns + .map((k) => byKey.get(k) ?? ({ field_key: k, output_label: k, data_type: 'string', is_filter: false, is_search: false } as RecordViewField)) + .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 total = resp?.pagination?.total_count ?? rows.length; const tileValues = resp?.tile_values; - const chartData = resp?.chart_data; - const filterEntries = resp?.config?.filter_options - ? Object.entries(resp.config.filter_options).filter(([, o]) => o && o.length > 0) - : []; + const filterEntries = useMemo(() => { + if (!resp?.config) return []; + const keys = new Set(); + 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 ( -
+
{!hideTiles && } {!hideChart && } @@ -156,9 +197,9 @@ export function RecordView({ {headerActions}
-
-
-
+
+
+
- {Object.values(activeFilters).filter(Boolean).length > 0 && ( - - {Object.values(activeFilters).filter(Boolean).length} - + {activeFilterCount > 0 && ( + )} )}
+ + {/* Active Filter Chips & Clear All */} + {Object.entries(activeFilters).some(([_, vals]) => vals && vals.length > 0) && ( +
+ Filters: + {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) => ( + + {label}: + {val} + + + )); + })} + +
+ )} + {filterEntries.length > 0 && ( vals && vals.length > 0) && ( @@ -210,39 +300,50 @@ export function RecordView({ } >
- {filterEntries.map(([key, options]) => { - const fieldDef = resp?.config.fields.find(f => f.field_key === key); - const label = fieldDef?.output_label || key; - const isDateField = fieldDef?.data_type === 'date' || key.toLowerCase().includes('date'); + {filterEntries.map((key) => { + 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()); + const isDateField = + fieldDef?.data_type === 'date' || + fieldDef?.data_type === 'datetime' || + key.toLowerCase().includes('date') || + key.toLowerCase() === 'created_at' || + key.toLowerCase() === 'updated_at'; if (isDateField) { return ( c.toUpperCase())} - value={pendingFilters[key] || ''} + label={label} + value={pendingFilters[key]?.[0] || ''} 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, - 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 ( -