From 9e10d654ad6110bea2e6695fb333d97dce1b26eb Mon Sep 17 00:00:00 2001 From: lorentz Date: Sun, 3 May 2026 18:02:13 -0400 Subject: [PATCH] feat(04-01): add TicketRowSkeleton and TicketFilterStrip presentational components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TicketRowSkeleton (D-21): - border-l-4 border-muted stripe + 3 Skeleton lines matching ticket row layout - 5 instances rendered during initial load TicketFilterStrip (D-01 through D-04): - Sticky top-0 container with always-visible search input - Collapsible panel with status chips (Open/In Progress/Waiting), priority chips (Critical/High/Medium/Low), queue Select, and mine Switch - Controlled component — URL sync is parent's responsibility (D-05) - Active filter count badge in Filters button - Clear all button appears only when isFiltered (D-04) - Exports TicketFilterValue and QueueOption interfaces for Plan 02 import --- components/mobile/TicketFilterStrip.tsx | 194 ++++++++++++++++++++++++ components/mobile/TicketRowSkeleton.tsx | 21 +++ 2 files changed, 215 insertions(+) create mode 100644 components/mobile/TicketFilterStrip.tsx create mode 100644 components/mobile/TicketRowSkeleton.tsx diff --git a/components/mobile/TicketFilterStrip.tsx b/components/mobile/TicketFilterStrip.tsx new file mode 100644 index 0000000..943333d --- /dev/null +++ b/components/mobile/TicketFilterStrip.tsx @@ -0,0 +1,194 @@ +'use client'; + +/* TicketFilterStrip — phase 04 (TICK-01, TICK-02). + * Purpose: sticky search input + Collapsible filter panel (status, priority, queue, mine) + * with controlled values; URL sync is the parent page's responsibility. + * Props: value, onChange, queueOptions, openTotal, isFiltered, onClearAll. */ + +import { useState } from 'react'; +import { Search, X, SlidersHorizontal, ChevronDown, ChevronUp } from 'lucide-react'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from '@/components/ui/select'; +import { Switch } from '@/components/ui/switch'; + +export interface QueueOption { + id: number; + label: string; +} + +export interface TicketFilterValue { + q: string; + status: number[]; // [] = no filter — caller treats default elsewhere + priority: number[]; // [] = no filter + queue: number | null; + mine: boolean; +} + +export interface TicketFilterStripProps { + value: TicketFilterValue; + onChange: (next: TicketFilterValue) => void; + queueOptions: QueueOption[]; + openTotal: number; + isFiltered: boolean; // true when ≥1 non-default filter is active (excludes default status) + onClearAll: () => void; +} + +const STATUS_OPTIONS: Array<{ id: number; label: string }> = [ + { id: 1, label: 'Open' }, + { id: 8, label: 'In Progress' }, + { id: 7, label: 'Waiting' }, +]; + +const PRIORITY_OPTIONS: Array<{ id: number; label: string }> = [ + { id: 1, label: 'Critical' }, + { id: 2, label: 'High' }, + { id: 3, label: 'Medium' }, + { id: 4, label: 'Low' }, +]; + +function chipClass(active: boolean): string { + const base = 'shrink-0 px-3 py-1 rounded-full text-xs font-semibold border transition-colors min-h-[32px]'; + return active + ? `${base} bg-primary text-primary-foreground border-primary` + : `${base} border-border hover:bg-muted/50`; +} + +function toggleInArray(arr: number[], id: number): number[] { + return arr.includes(id) ? arr.filter(x => x !== id) : [...arr, id]; +} + +export function TicketFilterStrip(props: TicketFilterStripProps) { + const { value, onChange, queueOptions, openTotal, isFiltered, onClearAll } = props; + const [open, setOpen] = useState(false); + + const activeCount = + (value.status.length > 0 && !(value.status.length === 3 && value.status.includes(1) && value.status.includes(7) && value.status.includes(8)) ? 1 : 0) + + (value.priority.length > 0 ? 1 : 0) + + (value.queue !== null ? 1 : 0) + + (value.mine ? 1 : 0); + + return ( +
+ {/* Search row — always visible (D-03) */} +
+
+ + {/* Toggle row — always visible (D-01) */} + +
+

{openTotal} open tickets

+ + + +
+ + + {/* Status (D-02) */} +
+

Status

+
+ {STATUS_OPTIONS.map((s) => ( + + ))} +
+
+ + {/* Priority (D-02) */} +
+

Priority

+
+ {PRIORITY_OPTIONS.map((p) => ( + + ))} +
+
+ + {/* Queue (D-02) */} +
+

Queue

+ +
+ + {/* Assigned to me (D-02) */} +
+ + onChange({ ...value, mine: checked })} + /> +
+ + {/* Clear all — only when isFiltered (D-04) */} + {isFiltered && ( + + )} +
+
+
+ ); +} diff --git a/components/mobile/TicketRowSkeleton.tsx b/components/mobile/TicketRowSkeleton.tsx new file mode 100644 index 0000000..96c30e1 --- /dev/null +++ b/components/mobile/TicketRowSkeleton.tsx @@ -0,0 +1,21 @@ +'use client'; + +/* TicketRowSkeleton — phase 04 (TICK-05/D-21). + * Purpose: skeleton placeholder row that matches the priority-stripe ticket row layout + * for the initial-load state of /mobile/tickets. + * Props: none — purely presentational. */ + +import { Skeleton } from '@/components/ui/skeleton'; + +export function TicketRowSkeleton() { + return ( +
+ + +
+ + +
+
+ ); +}