feat(04-02): rewrite mobile tickets list page with URL-synced filters, priority stripes, and IntersectionObserver infinite scroll
Cherry-picked from 04-02 worktree (b27db7d, 4ba89a0). The full worktree merge included unrelated regressions of phase 2/3 work — only the intended changes (app/mobile/tickets/page.tsx + SUMMARY.md) were kept. - Suspense wrapper for useSearchParams (Next.js 16 requirement) - URL-synced filter state via router.replace - TicketFilterStrip wired with controlled props - Priority-stripe rows (border-l-4 with red/orange/amber/slate) - IntersectionObserver infinite scroll + Load more fallback - toast.error() in load catch blocks per D-21 - Empty states per D-20
This commit is contained in:
parent
422ea2bd0e
commit
5d87455e49
2 changed files with 380 additions and 98 deletions
136
.planning/phases/04-tickets-restyle/04-02-SUMMARY.md
Normal file
136
.planning/phases/04-tickets-restyle/04-02-SUMMARY.md
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
---
|
||||
phase: 04-tickets-restyle
|
||||
plan: 02
|
||||
subsystem: ui
|
||||
tags: [mobile, tickets, infinite-scroll, url-sync, cursor-pagination, suspense, shadcn, typescript]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 04-tickets-restyle
|
||||
plan: 01
|
||||
provides: "GET /api/mobile/tickets cursor-paginated endpoint, TicketFilterStrip, TicketRowSkeleton"
|
||||
- phase: 02-mobile-shell-more-drawer
|
||||
provides: "Mobile layout shell (HeaderBar, BottomNav) that the tickets page docks inside"
|
||||
provides:
|
||||
- "app/mobile/tickets/page.tsx: fully wired mobile tickets list — URL-synced filters, priority-stripe rows, IntersectionObserver infinite scroll, skeleton load, D-20 empty states"
|
||||
affects: []
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "useSearchParams + Suspense boundary: Next.js 16 requirement for URL param hydration in client components"
|
||||
- "router.replace (not push) for filter URL sync: prevents back-button history pollution (D-05)"
|
||||
- "Cursor NOT in URL: fresh visits always start at page 1 (D-07)"
|
||||
- "IntersectionObserver with rootMargin 200px + Load more fallback for a11y (D-12/D-14)"
|
||||
- "toast.error() in BOTH loadFirst and loadMore catch blocks alongside setError (D-21)"
|
||||
- "Queue options derived from first-page tickets — no separate /api/mobile/queues endpoint"
|
||||
- "openTotal approximated as tickets.length + (hasMore ? 1 : 0) — precise count deferred"
|
||||
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- app/mobile/tickets/page.tsx
|
||||
|
||||
key-decisions:
|
||||
- "Default status [1, 8, 7] applied client-side when URL has no status param — matches server default and keeps URL clean on unfiltered visits"
|
||||
- "status='' in URL means explicit 'no status filter'; status absent means default [1, 8, 7]"
|
||||
- "Queue options built incrementally from first-page ticket data — acceptable for v1, no new endpoint needed"
|
||||
- "openTotal is tickets.length + (hasMore ? 1 : 0) — visible label reads 'N open tickets'; precision deferred until count endpoint exists"
|
||||
- "toast.error() always fires alongside setError in catch blocks per D-21"
|
||||
|
||||
requirements-completed: [TICK-01, TICK-02, TICK-03, TICK-04, TICK-05, TICK-06]
|
||||
|
||||
# Metrics
|
||||
duration: 8min
|
||||
completed: 2026-05-03
|
||||
---
|
||||
|
||||
# Phase 4 Plan 02: Mobile Tickets List Page Wiring Summary
|
||||
|
||||
**Rewrote app/mobile/tickets/page.tsx with URL-synced Collapsible filter strip, priority-stripe rows, IntersectionObserver infinite scroll, Load-more fallback, skeleton loading, toast.error on failure, and D-20 empty states**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 8 min
|
||||
- **Completed:** 2026-05-03T22:09:00Z
|
||||
- **Tasks:** 1 code task + 1 human-verify checkpoint (auto-approved)
|
||||
- **Files modified:** 1
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- Replaced the 164-line page-based `MobileTickets` component end-to-end with a 310-line `MobileTicketsPage` (Suspense shell) + `MobileTicketsInner` (state machine)
|
||||
- Wired `TicketFilterStrip` (Plan 01) and `TicketRowSkeleton` (Plan 01) into the page
|
||||
- URL filter sync via `useSearchParams` + `router.replace` — all five params (`q`, `status`, `priority`, `queue`, `mine`) are round-trippable; reload hydrates filter state
|
||||
- Cursor-based infinite scroll via `IntersectionObserver` (rootMargin 200px) with a focusable `Load more` fallback button
|
||||
- Priority-stripe rows: `border-l-4` + locked Tailwind classes `border-red-500` / `border-orange-400` / `border-amber-400` / `border-slate-300`
|
||||
- Two D-20 empty states: "No tickets match your filters" (filtered) and "No tickets to triage right now" (unfiltered)
|
||||
- `toast.error()` in both `loadFirst` and `loadMore` catch blocks (D-21 compliance)
|
||||
- Removed priority dot (`PRIORITY_DOT`), switched title from `line-clamp-2` to `truncate` (D-17 / UI-SPEC)
|
||||
|
||||
## URL <-> Filter State Mapping
|
||||
|
||||
| URL param | Absent behavior | Present value | Component effect |
|
||||
|-----------|----------------|---------------|-----------------|
|
||||
| `q` | `""` (no search) | string | Search input value; debounced 400ms |
|
||||
| `status` | Default `[1, 8, 7]` (Open + In Progress + Waiting) | comma-separated ints | Status chip selection |
|
||||
| `status=` | Empty string → no status filter | `[]` | All statuses shown |
|
||||
| `priority` | `[]` (no filter) | comma-separated ints | Priority chip selection |
|
||||
| `queue` | `null` (all queues) | int | Queue Select value |
|
||||
| `mine` | `false` | `1` | Assigned-to-me Switch |
|
||||
|
||||
Default status `[1, 8, 7]` matches the server-side default — when no `status` param is present, both client and server show the same set of tickets without the URL being polluted with a status parameter.
|
||||
|
||||
## Queue List Derivation
|
||||
|
||||
No `/api/mobile/queues` endpoint was added. Queue options are derived incrementally from ticket data seen in the first page load: `queue_id` + `queue_label` from each `MobileTicket` are deduped by id and sorted alphabetically. The Select control works correctly because it always receives the most recent set after first load. This is acceptable for v1; adding a dedicated queues endpoint is deferred.
|
||||
|
||||
## Open Total Approximation
|
||||
|
||||
`openTotal = tickets.length + (hasMore ? 1 : 0)` — the visible label reads "N open tickets". When `hasMore` is false, this is exact. When `hasMore` is true, it reads as ">N" semantically (though the UI just shows the count). A precise count endpoint is deferred to a future phase.
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Task 1: Rewrite mobile tickets list page** — `b27db7d` (feat)
|
||||
|
||||
## Files Modified
|
||||
|
||||
- `app/mobile/tickets/page.tsx` — Full rewrite: 164 lines (old) → 310 lines (new)
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] Restored Plan 01 route.ts from HEAD after worktree working-tree mismatch**
|
||||
- **Found during:** Task 1 TypeScript check (errors: `MobileTicket` and `MobileTicketListResponse` not exported from route)
|
||||
- **Issue:** The working tree contained the legacy page-based route.ts rather than the cursor-paginated Plan 01 version. This is a consequence of the worktree reset — git HEAD had the Plan 01 route, but the working tree files were from the pre-reset state.
|
||||
- **Fix:** `git checkout 422ea2bd -- app/api/mobile/tickets/route.ts` to restore the Plan 01 cursor-paginated route with exported interfaces.
|
||||
- **Files modified:** `app/api/mobile/tickets/route.ts` (restored to Plan 01 state — not a new change)
|
||||
- **Commit:** Included in `b27db7d` (staged alongside page.tsx)
|
||||
|
||||
**2. [Rule 1 - Bug] Fixed implicit `any` TypeScript error in initials avatar**
|
||||
- **Found during:** Task 1 TypeScript check
|
||||
- **Issue:** `t.assigned_to.split(' ').map(s => s[0])` — `s` was implicitly `any` in strict mode
|
||||
- **Fix:** Added explicit type annotation: `.map((s: string) => s[0])`
|
||||
- **Files modified:** `app/mobile/tickets/page.tsx`
|
||||
- **Commit:** `b27db7d`
|
||||
|
||||
### Checkpoint Auto-approval
|
||||
|
||||
**Task 2: Human-verify checkpoint** — Auto-approved per auto-mode active in parent orchestration. `⚡ Auto-approved: mobile tickets list page with filter strip, priority stripes, infinite scroll, and URL sync.`
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None — all data is fetched from live `/api/mobile/tickets`. No hardcoded values flow to UI rendering. Queue options are derived from real ticket data. The `openTotal` approximation is documented above and is intentional, not a stub.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
No new network endpoints, auth paths, file access patterns, or schema changes were introduced. The URL param threat mitigations documented in the plan's `<threat_model>` (T-04-07 through T-04-10) are implemented:
|
||||
|
||||
- T-04-07: `parseFilterFromSearch()` runs `parseInt + isNaN` filter — non-numeric tokens silently dropped
|
||||
- T-04-09: Search debounced 400ms; other filters are discrete actions
|
||||
|
||||
---
|
||||
|
||||
*Phase: 04-tickets-restyle*
|
||||
*Completed: 2026-05-03*
|
||||
|
|
@ -1,32 +1,25 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useEffect, useState, useCallback, useRef, useMemo, Suspense } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Search, X, RefreshCw, ChevronRight, Clock } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { ChevronRight, Clock, Loader2, RefreshCw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TicketFilterStrip, type TicketFilterValue, type QueueOption } from '@/components/mobile/TicketFilterStrip';
|
||||
import { TicketRowSkeleton } from '@/components/mobile/TicketRowSkeleton';
|
||||
import type { MobileTicket, MobileTicketListResponse } from '@/app/api/mobile/tickets/route';
|
||||
|
||||
interface Ticket {
|
||||
id: number;
|
||||
ticket_number: string;
|
||||
title: string;
|
||||
status: number;
|
||||
priority: number;
|
||||
create_date: string;
|
||||
last_activity_date: string;
|
||||
due_date_time: string | null;
|
||||
queue_id: number;
|
||||
queue_label: string;
|
||||
company_name: string;
|
||||
assigned_to: string;
|
||||
}
|
||||
|
||||
const PRIORITY_DOT: Record<number, string> = {
|
||||
1: 'bg-red-500', 2: 'bg-orange-400', 3: 'bg-yellow-400', 4: 'bg-slate-300',
|
||||
};
|
||||
const PRIORITY_LABEL: Record<number, string> = {
|
||||
1: 'Critical', 2: 'High', 3: 'Medium', 4: 'Low',
|
||||
const PRIORITY_BORDER: Record<number, string> = {
|
||||
1: 'border-red-500',
|
||||
2: 'border-orange-400',
|
||||
3: 'border-amber-400',
|
||||
4: 'border-slate-300',
|
||||
};
|
||||
|
||||
function relTime(ts: string | null) {
|
||||
const DEFAULT_STATUS: number[] = [1, 8, 7]; // Open + In Progress + Waiting (matches API default)
|
||||
|
||||
function relTime(ts: string | null): string {
|
||||
if (!ts) return '—';
|
||||
const diff = Date.now() - new Date(ts).getTime();
|
||||
const m = Math.floor(diff / 60000);
|
||||
|
|
@ -36,107 +29,247 @@ function relTime(ts: string | null) {
|
|||
return `${Math.floor(h / 24)}d ago`;
|
||||
}
|
||||
|
||||
export default function MobileTickets() {
|
||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [priority, setPriority] = useState('');
|
||||
function parseFilterFromSearch(sp: URLSearchParams): TicketFilterValue {
|
||||
const parseIntList = (raw: string | null): number[] => {
|
||||
if (raw === null) return [];
|
||||
if (raw === '') return [];
|
||||
return raw.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n));
|
||||
};
|
||||
const statusParam = sp.get('status');
|
||||
return {
|
||||
q: sp.get('q') ?? '',
|
||||
// when 'status' is absent entirely, fall through to DEFAULT_STATUS so the visible state matches what the API will return
|
||||
status: statusParam === null ? [...DEFAULT_STATUS] : parseIntList(statusParam),
|
||||
priority: parseIntList(sp.get('priority')),
|
||||
queue: sp.get('queue') ? parseInt(sp.get('queue')!, 10) : null,
|
||||
mine: sp.get('mine') === '1',
|
||||
};
|
||||
}
|
||||
|
||||
function filterToSearch(value: TicketFilterValue): URLSearchParams {
|
||||
const sp = new URLSearchParams();
|
||||
if (value.q) sp.set('q', value.q);
|
||||
// Only include status param when it differs from default — keeps URL clean for unfiltered visits (D-06)
|
||||
const isDefaultStatus = value.status.length === DEFAULT_STATUS.length
|
||||
&& DEFAULT_STATUS.every(s => value.status.includes(s));
|
||||
if (!isDefaultStatus && value.status.length > 0) sp.set('status', value.status.join(','));
|
||||
if (value.status.length === 0) sp.set('status', ''); // explicit "no status filter"
|
||||
if (value.priority.length > 0) sp.set('priority', value.priority.join(','));
|
||||
if (value.queue !== null) sp.set('queue', String(value.queue));
|
||||
if (value.mine) sp.set('mine', '1');
|
||||
return sp;
|
||||
}
|
||||
|
||||
function isFilterModified(value: TicketFilterValue): boolean {
|
||||
const isDefaultStatus = value.status.length === DEFAULT_STATUS.length
|
||||
&& DEFAULT_STATUS.every(s => value.status.includes(s));
|
||||
return Boolean(value.q)
|
||||
|| !isDefaultStatus
|
||||
|| value.priority.length > 0
|
||||
|| value.queue !== null
|
||||
|| value.mine;
|
||||
}
|
||||
|
||||
export default function MobileTicketsPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="flex items-center justify-center h-40"><Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /></div>}>
|
||||
<MobileTicketsInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileTicketsInner() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Filter state — initial value from URL (deep-link hydration per D-06)
|
||||
const initialFilter = useMemo(() => parseFilterFromSearch(new URLSearchParams(searchParams.toString())), []);
|
||||
const [filter, setFilter] = useState<TicketFilterValue>(initialFilter);
|
||||
|
||||
// Debounced search — separate from filter so other filters update immediately
|
||||
const [debouncedQ, setDebouncedQ] = useState(initialFilter.q);
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedQ(filter.q), 400);
|
||||
return () => clearTimeout(t);
|
||||
}, [filter.q]);
|
||||
|
||||
// List state
|
||||
const [tickets, setTickets] = useState<MobileTicket[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [openTotal, setOpenTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [queueOptions, setQueueOptions] = useState<QueueOption[]>([]);
|
||||
|
||||
// Debounce search
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedSearch(search), 400);
|
||||
return () => clearTimeout(t);
|
||||
}, [search]);
|
||||
// Build URL search params for the API call given a filter and optional cursor
|
||||
const buildApiParams = useCallback((f: TicketFilterValue, q: string, cursor: string | null): URLSearchParams => {
|
||||
const sp = new URLSearchParams();
|
||||
if (q) sp.set('q', q);
|
||||
if (f.status.length > 0) sp.set('status', f.status.join(','));
|
||||
else sp.set('status', ''); // explicit no-status (vs. omit = use default on server)
|
||||
if (f.priority.length > 0) sp.set('priority', f.priority.join(','));
|
||||
if (f.queue !== null) sp.set('queue', String(f.queue));
|
||||
if (f.mine) sp.set('mine', '1');
|
||||
if (cursor) sp.set('cursor', cursor);
|
||||
sp.set('limit', '25');
|
||||
return sp;
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async (pg = 1, append = false) => {
|
||||
if (pg === 1) setLoading(true); else setLoadingMore(true);
|
||||
// Fetch first page (filters changed)
|
||||
const loadFirst = useCallback(async (f: TicketFilterValue, q: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams({ page: String(pg) });
|
||||
if (debouncedSearch) params.set('q', debouncedSearch);
|
||||
if (priority) params.set('priority', priority);
|
||||
const r = await fetch(`/api/mobile/tickets?${params}`);
|
||||
const d = await r.json();
|
||||
setTickets(prev => append ? [...prev, ...d.tickets] : d.tickets);
|
||||
setTotal(d.total);
|
||||
setPage(pg);
|
||||
const sp = buildApiParams(f, q, null);
|
||||
const r = await fetch(`/api/mobile/tickets?${sp.toString()}`);
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
const data: MobileTicketListResponse = await r.json();
|
||||
setTickets(data.tickets);
|
||||
setNextCursor(data.nextCursor);
|
||||
setHasMore(data.hasMore);
|
||||
// Approximate "open total" from first page until a count endpoint exists; keep tickets.length when hasMore=false
|
||||
setOpenTotal(data.tickets.length + (data.hasMore ? 1 : 0));
|
||||
// Derive queue options from the first page so the Select shows real labels (best-effort; deduped by id)
|
||||
setQueueOptions(prev => {
|
||||
const seen = new Map<number, QueueOption>();
|
||||
for (const opt of prev) seen.set(opt.id, opt);
|
||||
for (const t of data.tickets) {
|
||||
if (t.queue_id && t.queue_label && !seen.has(t.queue_id)) {
|
||||
seen.set(t.queue_id, { id: t.queue_id, label: t.queue_label });
|
||||
}
|
||||
}
|
||||
return Array.from(seen.values()).sort((a, b) => a.label.localeCompare(b.label));
|
||||
});
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'Failed to load tickets';
|
||||
setError(msg);
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setLoading(false); setLoadingMore(false);
|
||||
setLoading(false);
|
||||
}
|
||||
}, [debouncedSearch, priority]);
|
||||
}, [buildApiParams]);
|
||||
|
||||
useEffect(() => { load(1, false); }, [load]);
|
||||
// Fetch next page (cursor advance)
|
||||
const loadMore = useCallback(async () => {
|
||||
if (loadingMore || !hasMore || !nextCursor) return;
|
||||
setLoadingMore(true);
|
||||
setError(null);
|
||||
try {
|
||||
const sp = buildApiParams(filter, debouncedQ, nextCursor);
|
||||
const r = await fetch(`/api/mobile/tickets?${sp.toString()}`);
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
const data: MobileTicketListResponse = await r.json();
|
||||
setTickets(prev => [...prev, ...data.tickets]);
|
||||
setNextCursor(data.nextCursor);
|
||||
setHasMore(data.hasMore);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'Failed to load more tickets';
|
||||
setError(msg);
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [loadingMore, hasMore, nextCursor, filter, debouncedQ, buildApiParams]);
|
||||
|
||||
const hasMore = tickets.length < total;
|
||||
// Reload first page when filter or debounced search changes (D-05/D-06: also push URL)
|
||||
useEffect(() => {
|
||||
const next = filterToSearch({ ...filter, q: debouncedQ });
|
||||
const nextStr = next.toString();
|
||||
if (nextStr !== searchParams.toString()) {
|
||||
router.replace(`/mobile/tickets${nextStr ? `?${nextStr}` : ''}`, { scroll: false });
|
||||
}
|
||||
void loadFirst(filter, debouncedQ);
|
||||
// Intentionally exclude searchParams from deps to prevent loop with router.replace
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debouncedQ, filter.status, filter.priority, filter.queue, filter.mine, loadFirst, router]);
|
||||
|
||||
// IntersectionObserver — infinite scroll trigger (D-12, D-13)
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
const node = sentinelRef.current;
|
||||
if (!node) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting && hasMore && !loadingMore && !loading) {
|
||||
void loadMore();
|
||||
}
|
||||
},
|
||||
{ rootMargin: '200px' },
|
||||
);
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, [hasMore, loadingMore, loading, loadMore]);
|
||||
|
||||
// Clear all (D-04, D-20 empty-state CTA)
|
||||
const clearAll = useCallback(() => {
|
||||
setFilter({ q: '', status: [...DEFAULT_STATUS], priority: [], queue: null, mine: false });
|
||||
}, []);
|
||||
|
||||
const filtered = isFilterModified(filter);
|
||||
|
||||
// ───── Render ─────
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Search + filter bar */}
|
||||
<div className="px-4 pt-4 pb-3 space-y-2 sticky top-0 bg-background z-10 border-b">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search tickets, company…"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="w-full pl-9 pr-9 py-2.5 rounded-xl border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||
/>
|
||||
{search && (
|
||||
<button onClick={() => setSearch('')} className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<X className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 overflow-x-auto pb-0.5 scrollbar-none">
|
||||
{(['', '1', '2', '3', '4'] as const).map(p => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPriority(p)}
|
||||
className={`shrink-0 px-3 py-1 rounded-full text-xs font-medium border transition-colors ${
|
||||
priority === p
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'border-border hover:bg-accent'
|
||||
}`}
|
||||
>
|
||||
{p === '' ? 'All' : PRIORITY_LABEL[parseInt(p)]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{total} open tickets</p>
|
||||
</div>
|
||||
<TicketFilterStrip
|
||||
value={filter}
|
||||
onChange={setFilter}
|
||||
queueOptions={queueOptions}
|
||||
openTotal={openTotal}
|
||||
isFiltered={filtered}
|
||||
onClearAll={clearAll}
|
||||
/>
|
||||
|
||||
{/* List */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-40">
|
||||
<RefreshCw className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
<div className="divide-y">
|
||||
{Array.from({ length: 5 }).map((_, i) => <TicketRowSkeleton key={i} />)}
|
||||
</div>
|
||||
) : tickets.length === 0 ? (
|
||||
<div className="text-center py-16 text-sm text-muted-foreground">No tickets found</div>
|
||||
// Empty state (D-20)
|
||||
<div className="text-center py-12 px-4 space-y-3">
|
||||
{filtered ? (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">No tickets match your filters</p>
|
||||
<Button variant="outline" size="sm" onClick={clearAll}>Clear filters</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">No tickets to triage right now</p>
|
||||
<Button variant="ghost" size="sm" onClick={() => loadFirst(filter, debouncedQ)} aria-label="Refresh ticket list">
|
||||
<RefreshCw className="w-4 h-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="divide-y">
|
||||
{tickets.map(t => (
|
||||
<Link key={t.id} href={`/mobile/tickets/${t.id}`}
|
||||
className="flex items-start gap-3 px-4 py-3.5 hover:bg-accent transition-colors active:bg-accent">
|
||||
<div className={`mt-1.5 w-2.5 h-2.5 rounded-full shrink-0 ${PRIORITY_DOT[t.priority] ?? 'bg-slate-400'}`} />
|
||||
{tickets.map((t) => (
|
||||
<Link
|
||||
key={t.id}
|
||||
href={`/mobile/tickets/${t.id}`}
|
||||
className={`flex items-start border-l-4 ${PRIORITY_BORDER[t.priority] ?? 'border-slate-300'} px-4 py-4 hover:bg-muted/50 active:bg-muted/50 transition-colors`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm font-semibold leading-snug line-clamp-2">{t.title}</p>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0 mt-0.5" />
|
||||
<p className="text-sm font-semibold leading-snug truncate">{t.title}</p>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0 mt-0.5" aria-hidden="true" />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">{t.company_name}</p>
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5">{t.company_name}</p>
|
||||
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
|
||||
<span className="text-[10px] bg-muted rounded px-1.5 py-0.5 font-mono">{t.ticket_number}</span>
|
||||
{t.queue_label && (
|
||||
<span className="text-[10px] text-muted-foreground">{t.queue_label}</span>
|
||||
)}
|
||||
{t.assigned_to && (
|
||||
<span className="inline-flex items-center justify-center h-5 w-5 rounded-full bg-primary/15 text-primary text-[10px] font-semibold">
|
||||
{t.assigned_to.split(' ').map((s: string) => s[0]).filter(Boolean).slice(0, 2).join('').toUpperCase() || '·'}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] text-muted-foreground flex items-center gap-0.5 ml-auto">
|
||||
<Clock className="w-3 h-3" />
|
||||
<Clock className="w-3 h-3" aria-hidden="true" />
|
||||
{relTime(t.last_activity_date)}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -145,14 +278,27 @@ export default function MobileTickets() {
|
|||
))}
|
||||
</div>
|
||||
|
||||
{/* Sentinel — IntersectionObserver target (D-12) */}
|
||||
<div ref={sentinelRef} aria-hidden="true" />
|
||||
|
||||
{/* Loading-more spinner (D-21) */}
|
||||
{loadingMore && (
|
||||
<div className="flex justify-center py-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" aria-hidden="true" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Load more fallback button (D-14, TICK-06) */}
|
||||
{hasMore && (
|
||||
<div className="p-4">
|
||||
<button
|
||||
onClick={() => load(page + 1, true)}
|
||||
type="button"
|
||||
onClick={() => void loadMore()}
|
||||
disabled={loadingMore}
|
||||
className="w-full py-3 rounded-xl border text-sm font-medium hover:bg-accent transition-colors disabled:opacity-50"
|
||||
aria-label="Load more tickets"
|
||||
className="w-full py-3 rounded-xl border text-sm font-semibold hover:bg-muted/50 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loadingMore ? 'Loading…' : `Load more (${total - tickets.length} remaining)`}
|
||||
{error ? 'Retry' : loadingMore ? 'Loading…' : 'Load more'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue