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:
parent
9658640c04
commit
9e10d654ad
2 changed files with 215 additions and 0 deletions
194
components/mobile/TicketFilterStrip.tsx
Normal file
194
components/mobile/TicketFilterStrip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
components/mobile/TicketRowSkeleton.tsx
Normal file
21
components/mobile/TicketRowSkeleton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue