feat(04-01): add TicketRowSkeleton and TicketFilterStrip presentational components

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
This commit is contained in:
lorentz 2026-05-03 18:02:13 -04:00
parent 9658640c04
commit 9e10d654ad
2 changed files with 215 additions and 0 deletions

View file

@ -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 (
<div className="sticky top-0 bg-background z-10 border-b px-4 pt-4 pb-3 space-y-2">
{/* Search row — always visible (D-03) */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" aria-hidden="true" />
<Input
type="text"
placeholder="Search tickets, company…"
value={value.q}
onChange={(e) => onChange({ ...value, q: e.target.value })}
className="w-full pl-9 pr-9"
aria-label="Search tickets"
/>
{value.q && (
<button
type="button"
onClick={() => onChange({ ...value, q: '' })}
className="absolute right-3 top-1/2 -translate-y-1/2"
aria-label="Clear search"
>
<X className="w-4 h-4 text-muted-foreground" />
</button>
)}
</div>
{/* Toggle row — always visible (D-01) */}
<Collapsible open={open} onOpenChange={setOpen}>
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">{openTotal} open tickets</p>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="text-xs font-semibold" aria-label="Toggle filters">
<SlidersHorizontal className="w-3.5 h-3.5 mr-1.5" aria-hidden="true" />
Filters{activeCount > 0 ? ` (${activeCount})` : ''}
{open ? <ChevronUp className="w-3.5 h-3.5 ml-1" aria-hidden="true" /> : <ChevronDown className="w-3.5 h-3.5 ml-1" aria-hidden="true" />}
</Button>
</CollapsibleTrigger>
</div>
<CollapsibleContent className="pt-3 space-y-3">
{/* Status (D-02) */}
<div className="space-y-1.5">
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Status</p>
<div className="flex gap-2 flex-wrap">
{STATUS_OPTIONS.map((s) => (
<button
key={s.id}
type="button"
role="checkbox"
aria-checked={value.status.includes(s.id)}
onClick={() => onChange({ ...value, status: toggleInArray(value.status, s.id) })}
className={chipClass(value.status.includes(s.id))}
>
{s.label}
</button>
))}
</div>
</div>
{/* Priority (D-02) */}
<div className="space-y-1.5">
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Priority</p>
<div className="flex gap-2 flex-wrap">
{PRIORITY_OPTIONS.map((p) => (
<button
key={p.id}
type="button"
role="checkbox"
aria-checked={value.priority.includes(p.id)}
onClick={() => onChange({ ...value, priority: toggleInArray(value.priority, p.id) })}
className={chipClass(value.priority.includes(p.id))}
>
{p.label}
</button>
))}
</div>
</div>
{/* Queue (D-02) */}
<div className="space-y-1.5">
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Queue</p>
<Select
value={value.queue !== null ? String(value.queue) : 'all'}
onValueChange={(v) => onChange({ ...value, queue: v === 'all' ? null : parseInt(v, 10) })}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="All queues" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All queues</SelectItem>
{queueOptions.map((q) => (
<SelectItem key={q.id} value={String(q.id)}>{q.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Assigned to me (D-02) */}
<div className="flex items-center justify-between">
<label htmlFor="filter-mine" className="text-sm">Assigned to me</label>
<Switch
id="filter-mine"
checked={value.mine}
onCheckedChange={(checked) => onChange({ ...value, mine: checked })}
/>
</div>
{/* Clear all — only when isFiltered (D-04) */}
{isFiltered && (
<button
type="button"
onClick={onClearAll}
className="text-xs text-muted-foreground underline"
>
Clear all
</button>
)}
</CollapsibleContent>
</Collapsible>
</div>
);
}

View file

@ -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 (
<div className="border-l-4 border-muted px-4 py-4">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2 mt-1" />
<div className="flex gap-2 mt-2 items-center">
<Skeleton className="h-3 w-12" />
<Skeleton className="h-3 w-16 ml-auto" />
</div>
</div>
);
}