diff --git a/client/src/App.tsx b/client/src/App.tsx index 12c157e..d640042 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -4,7 +4,7 @@ import PrivateRoute from './components/PrivateRoute'; import AdminRoute from './components/AdminRoute'; import Login from './pages/Login'; import Dashboard from './pages/Dashboard'; -import Tickets from './pages/Tickets'; +import Tickets from './pages/tickets'; import MyTickets from './pages/MyTickets'; import TicketDetail from './pages/ticket-detail'; import Notifications from './pages/Notifications'; diff --git a/client/src/pages/Tickets.tsx b/client/src/pages/Tickets.tsx deleted file mode 100644 index 4d662d3..0000000 --- a/client/src/pages/Tickets.tsx +++ /dev/null @@ -1,630 +0,0 @@ -import { useEffect, useMemo, useState } from 'react'; -import { Link, useNavigate, useSearchParams } from 'react-router-dom'; -import { useShortcut } from '../hooks/useShortcuts'; -import { ChevronLeft, ChevronRight, Trash2, Save } from 'lucide-react'; -import { SEVERITY_BG } from '../lib/severityColors'; -import { formatDistanceToNow } from 'date-fns'; -import { toast } from 'sonner'; -import Layout from '../components/Layout'; -import SeverityBadge from '../components/SeverityBadge'; -import StatusBadge from '../components/StatusBadge'; -import Avatar from '../components/Avatar'; -import CTISelect from '../components/CTISelect'; -import { TicketStatus } from '../types'; -import { useAuth } from '../contexts/AuthContext'; -import { - useTicketsPaged, - useBulkTickets, - useSavedViews, - useCreateSavedView, - useDeleteSavedView, - useUsers, -} from '../api/queries'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog'; - -const STATUS_TABS: { value: TicketStatus | ''; label: string }[] = [ - { value: '', label: 'All' }, - { value: 'OPEN', label: 'Open' }, - { value: 'IN_PROGRESS', label: 'In progress' }, - { value: 'RESOLVED', label: 'Resolved' }, - { value: 'CLOSED', label: 'Closed' }, -]; - -const PAGE_SIZE = 25; -const BULK_LIMIT = 500; - -export default function Tickets() { - const [params, setParams] = useSearchParams(); - const navigate = useNavigate(); - const { user: authUser } = useAuth(); - const { data: users = [] } = useUsers(); - - const status = (params.get('status') ?? '') as TicketStatus | ''; - const severity = params.get('severity') ?? ''; - const assigneeId = params.get('assigneeId') ?? ''; - const categoryId = params.get('categoryId') ?? ''; - const typeId = params.get('typeId') ?? ''; - const itemId = params.get('itemId') ?? ''; - const search = params.get('search') ?? ''; - const page = Math.max(1, Number(params.get('page') ?? '1')); - - const [searchInput, setSearchInput] = useState(search); - const [selected, setSelected] = useState>(new Set()); - const [cursor, setCursor] = useState(-1); - const [saveOpen, setSaveOpen] = useState(false); - const [newViewName, setNewViewName] = useState(''); - const [confirmBulk, setConfirmBulk] = useState< - | { kind: 'close' | 'setSeverity' | 'reassign'; value?: unknown; label: string } - | null - >(null); - - useEffect(() => { - const t = setTimeout(() => { - if (search !== searchInput) { - updateParam('search', searchInput); - updateParam('page', '1'); - } - }, 250); - return () => clearTimeout(t); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [searchInput]); - - const updateParam = (key: string, value: string | null) => { - setParams( - (prev) => { - const next = new URLSearchParams(prev); - if (value === null || value === '') next.delete(key); - else next.set(key, value); - if (key !== 'page') next.delete('page'); // reset page on filter change - return next; - }, - { replace: true }, - ); - }; - - const queryParams = { - status: status || undefined, - severity: severity ? Number(severity) : undefined, - assigneeId: assigneeId || undefined, - categoryId: categoryId || undefined, - typeId: typeId || undefined, - itemId: itemId || undefined, - search: search || undefined, - page, - pageSize: PAGE_SIZE, - }; - - const { data, isLoading, isFetching } = useTicketsPaged(queryParams); - const tickets = data?.data ?? []; - const total = data?.total ?? 0; - const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); - - const savedViewsQ = useSavedViews(); - const createView = useCreateSavedView(); - const deleteView = useDeleteSavedView(); - const bulk = useBulkTickets(); - - const allVisible = tickets.length > 0 && tickets.every((t) => selected.has(t.id)); - const someVisible = tickets.some((t) => selected.has(t.id)); - - const toggleAll = () => { - setSelected((prev) => { - const next = new Set(prev); - if (allVisible) tickets.forEach((t) => next.delete(t.id)); - else tickets.forEach((t) => next.add(t.id)); - return next; - }); - }; - - const toggleOne = (id: string) => { - setSelected((prev) => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); - }; - - const clearSelected = () => setSelected(new Set()); - - const currentFilters = useMemo( - () => ({ - status: status || undefined, - severity: severity ? Number(severity) : undefined, - assigneeId: assigneeId || undefined, - categoryId: categoryId || undefined, - typeId: typeId || undefined, - itemId: itemId || undefined, - search: search || undefined, - }), - [status, severity, assigneeId, categoryId, typeId, itemId, search], - ); - - const runBulk = async (payload: { action: string; value?: unknown }) => { - const ids = Array.from(selected).slice(0, BULK_LIMIT); - try { - const res = (await bulk.mutateAsync({ ids, ...payload })) as { updated?: number }; - toast.success(`Updated ${res.updated ?? ids.length} ticket${ids.length === 1 ? '' : 's'}`); - clearSelected(); - } catch (e) { - toast.error((e as Error).message || 'Bulk action failed'); - } finally { - setConfirmBulk(null); - } - }; - - const activeCount = Object.values(currentFilters).filter(Boolean).length; - - const handleSaveView = async () => { - if (!newViewName.trim()) return; - try { - await createView.mutateAsync({ - name: newViewName.trim(), - filters: currentFilters, - }); - toast.success('Saved view'); - setNewViewName(''); - setSaveOpen(false); - } catch (e) { - toast.error((e as Error).message || 'Failed to save view'); - } - }; - - const applyView = (filters: Record) => { - const next = new URLSearchParams(); - Object.entries(filters).forEach(([k, v]) => { - if (v !== undefined && v !== null && v !== '') next.set(k, String(v)); - }); - setParams(next, { replace: true }); - setSearchInput(String(filters.search ?? '')); - }; - - const agentUsers = users; - - // Keyboard navigation - useEffect(() => { - setCursor((c) => (c >= tickets.length ? tickets.length - 1 : c)); - }, [tickets.length]); - - useShortcut('j', (e) => { - if (tickets.length === 0) return; - e.preventDefault(); - setCursor((c) => Math.min(tickets.length - 1, c < 0 ? 0 : c + 1)); - }, [tickets.length]); - - useShortcut('k', (e) => { - if (tickets.length === 0) return; - e.preventDefault(); - setCursor((c) => Math.max(0, c < 0 ? 0 : c - 1)); - }, [tickets.length]); - - useShortcut('enter', (e) => { - if (cursor < 0 || cursor >= tickets.length) return; - e.preventDefault(); - navigate(`/${tickets[cursor].displayId}`); - }, [cursor, tickets]); - - useShortcut('x', (e) => { - if (cursor < 0 || cursor >= tickets.length) return; - e.preventDefault(); - toggleOne(tickets[cursor].id); - }, [cursor, tickets]); - - return ( - - {/* Status tabs */} -
- {STATUS_TABS.map((tab) => ( - - ))} -
- - {/* Filter bar */} -
- setSearchInput(e.target.value)} - placeholder="Search title, overview, ID…" - className="flex-1 min-w-48 max-w-xs px-3 py-1.5 rounded-md border border-input bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring" - /> - - - - - -
- { - setParams( - (prev) => { - const next = new URLSearchParams(prev); - next.delete('page'); - if (cti.categoryId) next.set('categoryId', cti.categoryId); - else next.delete('categoryId'); - if (cti.typeId) next.set('typeId', cti.typeId); - else next.delete('typeId'); - if (cti.itemId) next.set('itemId', cti.itemId); - else next.delete('itemId'); - return next; - }, - { replace: true }, - ); - }} - /> -
- - {/* Saved views */} - - - - - - Saved views - - {savedViewsQ.data && savedViewsQ.data.length > 0 ? ( - savedViewsQ.data.map((v) => ( -
- - -
- )) - ) : ( -

No saved views yet

- )} - - setSaveOpen(true)} - className="gap-2" - > - - Save current filters - -
-
- -
- {isFetching ? 'Loading…' : `${total} result${total === 1 ? '' : 's'}`} -
-
- - {/* Bulk bar */} - {selected.size > 0 && ( -
- - {selected.size} selected - - -
- - - - - - - setConfirmBulk({ - kind: 'reassign', - value: null, - label: 'Unassign', - }) - } - > - Unassigned - - {agentUsers.map((u) => ( - - setConfirmBulk({ - kind: 'reassign', - value: u.id, - label: `Assign to ${u.displayName}`, - }) - } - > - {u.displayName} - - ))} - - - - - - - - - {[1, 2, 3, 4, 5].map((s) => ( - - setConfirmBulk({ - kind: 'setSeverity', - value: s, - label: `Set severity SEV ${s}`, - }) - } - > - SEV {s} - - ))} - - - - -
- )} - - {/* Ticket list */} - {isLoading ? ( -
Loading…
- ) : tickets.length === 0 ? ( -
No tickets found
- ) : ( -
-
- { - if (el) el.indeterminate = !allVisible && someVisible; - }} - onChange={toggleAll} - className="cursor-pointer" - /> - - {tickets.length} of {total} - -
- -
    - {tickets.map((ticket, idx) => ( -
  • - toggleOne(ticket.id)} - aria-label={`Select ${ticket.displayId}`} - className="cursor-pointer flex-shrink-0" - /> -
    - -
    -
    - - {ticket.title} - - - {ticket.displayId} - -
    -
    - - - - opened {formatDistanceToNow(new Date(ticket.createdAt), { - addSuffix: true, - })}{' '} - by {ticket.createdBy.displayName} - - - · {ticket.category.name} › {ticket.type.name} › {ticket.item.name} - - {ticket.assignee && ( - · assigned {ticket.assignee.displayName} - )} - · {ticket._count?.comments ?? 0} comments -
    -
    -
    - {ticket.assignee ? ( - - ) : null} -
    - -
  • - ))} -
-
- )} - - {/* Pagination */} - {totalPages > 1 && ( -
-
- Page {page} of {totalPages} -
-
- - -
-
- )} - - {/* Save view dialog */} - - - - Save current filters - -
- setNewViewName(e.target.value)} - placeholder="View name" - className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring" - onKeyDown={(e) => { - if (e.key === 'Enter') handleSaveView(); - }} - /> -
- - - - -
-
- - {/* Confirm bulk action */} - !o && setConfirmBulk(null)}> - - - {confirmBulk?.label} - - This will affect {selected.size} ticket{selected.size === 1 ? '' : 's'}. - - - - Cancel - { - if (!confirmBulk) return; - if (confirmBulk.kind === 'close') runBulk({ action: 'close' }); - else - runBulk({ - action: confirmBulk.kind, - value: confirmBulk.value, - }); - }} - > - Confirm - - - - - - ); -} diff --git a/client/src/pages/tickets/BulkActions.tsx b/client/src/pages/tickets/BulkActions.tsx new file mode 100644 index 0000000..6d420c2 --- /dev/null +++ b/client/src/pages/tickets/BulkActions.tsx @@ -0,0 +1,78 @@ +import type { User } from '../../types'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; + +interface BulkActionsProps { + selectedCount: number; + users: User[]; + onClear: () => void; + onConfirm: (bulk: { kind: 'close' | 'setSeverity' | 'reassign'; value?: unknown; label: string }) => void; +} + +export default function BulkActions({ selectedCount, users, onClear, onConfirm }: BulkActionsProps) { + return ( +
+ + {selectedCount} selected + + +
+ + + + + + onConfirm({ kind: 'reassign', value: null, label: 'Unassign' })} + > + Unassigned + + {users.map((u) => ( + onConfirm({ kind: 'reassign', value: u.id, label: `Assign to ${u.displayName}` })} + > + {u.displayName} + + ))} + + + + + + + + + {[1, 2, 3, 4, 5].map((s) => ( + onConfirm({ kind: 'setSeverity', value: s, label: `Set severity SEV ${s}` })} + > + SEV {s} + + ))} + + + + +
+ ); +} diff --git a/client/src/pages/tickets/TicketFilters.tsx b/client/src/pages/tickets/TicketFilters.tsx new file mode 100644 index 0000000..1271766 --- /dev/null +++ b/client/src/pages/tickets/TicketFilters.tsx @@ -0,0 +1,196 @@ +import { Trash2, Save } from 'lucide-react'; +import CTISelect from '../../components/CTISelect'; +import type { TicketStatus, User } from '../../types'; +import type { SavedView } from '../../../../shared/types'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; + +const STATUS_TABS: { value: TicketStatus | ''; label: string }[] = [ + { value: '', label: 'All' }, + { value: 'OPEN', label: 'Open' }, + { value: 'IN_PROGRESS', label: 'In progress' }, + { value: 'RESOLVED', label: 'Resolved' }, + { value: 'CLOSED', label: 'Closed' }, +]; + +interface TicketFiltersProps { + status: TicketStatus | ''; + severity: string; + assigneeId: string; + categoryId: string; + typeId: string; + itemId: string; + searchInput: string; + onSearchChange: (v: string) => void; + onUpdateParam: (key: string, value: string | null) => void; + onSetParams: (fn: (prev: URLSearchParams) => URLSearchParams) => void; + authUser: { id: string; displayName: string } | null; + users: User[]; + savedViews: SavedView[]; + onDeleteView: (id: string) => void; + onApplyView: (filters: Record) => void; + onSaveView: () => void; + activeFilterCount: number; + total: number; + isFetching: boolean; +} + +export default function TicketFilters({ + status, + severity, + assigneeId, + categoryId, + typeId, + itemId, + searchInput, + onSearchChange, + onUpdateParam, + onSetParams, + authUser, + users, + savedViews, + onDeleteView, + onApplyView, + onSaveView, + activeFilterCount, + total, + isFetching, +}: TicketFiltersProps) { + return ( + <> + {/* Status tabs */} +
+ {STATUS_TABS.map((tab) => ( + + ))} +
+ + {/* Filter bar */} +
+ onSearchChange(e.target.value)} + placeholder="Search title, overview, ID…" + className="flex-1 min-w-48 max-w-xs px-3 py-1.5 rounded-md border border-input bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring" + /> + + + + + +
+ { + onSetParams((prev) => { + const next = new URLSearchParams(prev); + next.delete('page'); + if (cti.categoryId) next.set('categoryId', cti.categoryId); + else next.delete('categoryId'); + if (cti.typeId) next.set('typeId', cti.typeId); + else next.delete('typeId'); + if (cti.itemId) next.set('itemId', cti.itemId); + else next.delete('itemId'); + return next; + }); + }} + /> +
+ + {/* Saved views */} + + + + + + Saved views + + {savedViews.length > 0 ? ( + savedViews.map((v) => ( +
+ + +
+ )) + ) : ( +

No saved views yet

+ )} + + + + Save current filters + +
+
+ +
+ {isFetching ? 'Loading…' : `${total} result${total === 1 ? '' : 's'}`} +
+
+ + ); +} diff --git a/client/src/pages/tickets/TicketListItem.tsx b/client/src/pages/tickets/TicketListItem.tsx new file mode 100644 index 0000000..c00011a --- /dev/null +++ b/client/src/pages/tickets/TicketListItem.tsx @@ -0,0 +1,74 @@ +import { Link } from 'react-router-dom'; +import { formatDistanceToNow } from 'date-fns'; +import { SEVERITY_BG } from '../../lib/severityColors'; +import SeverityBadge from '../../components/SeverityBadge'; +import StatusBadge from '../../components/StatusBadge'; +import Avatar from '../../components/Avatar'; +import type { Ticket } from '../../types'; + +interface TicketListItemProps { + ticket: Ticket; + selected: boolean; + focused: boolean; + onToggle: () => void; +} + +export default function TicketListItem({ ticket, selected, focused, onToggle }: TicketListItemProps) { + return ( +
  • + +
    + +
    +
    + + {ticket.title} + + + {ticket.displayId} + +
    +
    + + + + opened {formatDistanceToNow(new Date(ticket.createdAt), { + addSuffix: true, + })}{' '} + by {ticket.createdBy.displayName} + + + · {ticket.category.name} › {ticket.type.name} › {ticket.item.name} + + {ticket.assignee && ( + · assigned {ticket.assignee.displayName} + )} + · {ticket._count?.comments ?? 0} comments +
    +
    +
    + {ticket.assignee ? ( + + ) : null} +
    + +
  • + ); +} diff --git a/client/src/pages/tickets/Tickets.tsx b/client/src/pages/tickets/Tickets.tsx new file mode 100644 index 0000000..db82fc4 --- /dev/null +++ b/client/src/pages/tickets/Tickets.tsx @@ -0,0 +1,377 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { useShortcut } from '../../hooks/useShortcuts'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { toast } from 'sonner'; +import Layout from '../../components/Layout'; +import { TicketStatus } from '../../types'; +import { useAuth } from '../../contexts/AuthContext'; +import { + useTicketsPaged, + useBulkTickets, + useSavedViews, + useCreateSavedView, + useDeleteSavedView, + useUsers, +} from '../../api/queries'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import TicketFilters from './TicketFilters'; +import BulkActions from './BulkActions'; +import TicketListItem from './TicketListItem'; + +const PAGE_SIZE = 25; +const BULK_LIMIT = 500; + +export default function Tickets() { + const [params, setParams] = useSearchParams(); + const navigate = useNavigate(); + const { user: authUser } = useAuth(); + const { data: users = [] } = useUsers(); + + const status = (params.get('status') ?? '') as TicketStatus | ''; + const severity = params.get('severity') ?? ''; + const assigneeId = params.get('assigneeId') ?? ''; + const categoryId = params.get('categoryId') ?? ''; + const typeId = params.get('typeId') ?? ''; + const itemId = params.get('itemId') ?? ''; + const search = params.get('search') ?? ''; + const page = Math.max(1, Number(params.get('page') ?? '1')); + + const [searchInput, setSearchInput] = useState(search); + const [selected, setSelected] = useState>(new Set()); + const [cursor, setCursor] = useState(-1); + const [saveOpen, setSaveOpen] = useState(false); + const [newViewName, setNewViewName] = useState(''); + const [confirmBulk, setConfirmBulk] = useState< + | { kind: 'close' | 'setSeverity' | 'reassign'; value?: unknown; label: string } + | null + >(null); + + useEffect(() => { + const t = setTimeout(() => { + if (search !== searchInput) { + updateParam('search', searchInput); + updateParam('page', '1'); + } + }, 250); + return () => clearTimeout(t); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchInput]); + + const updateParam = (key: string, value: string | null) => { + setParams( + (prev) => { + const next = new URLSearchParams(prev); + if (value === null || value === '') next.delete(key); + else next.set(key, value); + if (key !== 'page') next.delete('page'); + return next; + }, + { replace: true }, + ); + }; + + const queryParams = { + status: status || undefined, + severity: severity ? Number(severity) : undefined, + assigneeId: assigneeId || undefined, + categoryId: categoryId || undefined, + typeId: typeId || undefined, + itemId: itemId || undefined, + search: search || undefined, + page, + pageSize: PAGE_SIZE, + }; + + const { data, isLoading, isFetching } = useTicketsPaged(queryParams); + const tickets = data?.data ?? []; + const total = data?.total ?? 0; + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + + const savedViewsQ = useSavedViews(); + const createView = useCreateSavedView(); + const deleteView = useDeleteSavedView(); + const bulk = useBulkTickets(); + + const allVisible = tickets.length > 0 && tickets.every((t) => selected.has(t.id)); + const someVisible = tickets.some((t) => selected.has(t.id)); + + const toggleAll = () => { + setSelected((prev) => { + const next = new Set(prev); + if (allVisible) tickets.forEach((t) => next.delete(t.id)); + else tickets.forEach((t) => next.add(t.id)); + return next; + }); + }; + + const toggleOne = (id: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const clearSelected = () => setSelected(new Set()); + + const currentFilters = useMemo( + () => ({ + status: status || undefined, + severity: severity ? Number(severity) : undefined, + assigneeId: assigneeId || undefined, + categoryId: categoryId || undefined, + typeId: typeId || undefined, + itemId: itemId || undefined, + search: search || undefined, + }), + [status, severity, assigneeId, categoryId, typeId, itemId, search], + ); + + const runBulk = async (payload: { action: string; value?: unknown }) => { + const ids = Array.from(selected).slice(0, BULK_LIMIT); + try { + const res = (await bulk.mutateAsync({ ids, ...payload })) as { updated?: number }; + toast.success(`Updated ${res.updated ?? ids.length} ticket${ids.length === 1 ? '' : 's'}`); + clearSelected(); + } catch (e) { + toast.error((e as Error).message || 'Bulk action failed'); + } finally { + setConfirmBulk(null); + } + }; + + const activeCount = Object.values(currentFilters).filter(Boolean).length; + + const handleSaveView = async () => { + if (!newViewName.trim()) return; + try { + await createView.mutateAsync({ + name: newViewName.trim(), + filters: currentFilters, + }); + toast.success('Saved view'); + setNewViewName(''); + setSaveOpen(false); + } catch (e) { + toast.error((e as Error).message || 'Failed to save view'); + } + }; + + const applyView = (filters: Record) => { + const next = new URLSearchParams(); + Object.entries(filters).forEach(([k, v]) => { + if (v !== undefined && v !== null && v !== '') next.set(k, String(v)); + }); + setParams(next, { replace: true }); + setSearchInput(String(filters.search ?? '')); + }; + + // Keyboard navigation + useEffect(() => { + setCursor((c) => (c >= tickets.length ? tickets.length - 1 : c)); + }, [tickets.length]); + + useShortcut('j', (e) => { + if (tickets.length === 0) return; + e.preventDefault(); + setCursor((c) => Math.min(tickets.length - 1, c < 0 ? 0 : c + 1)); + }, [tickets.length]); + + useShortcut('k', (e) => { + if (tickets.length === 0) return; + e.preventDefault(); + setCursor((c) => Math.max(0, c < 0 ? 0 : c - 1)); + }, [tickets.length]); + + useShortcut('enter', (e) => { + if (cursor < 0 || cursor >= tickets.length) return; + e.preventDefault(); + navigate(`/${tickets[cursor].displayId}`); + }, [cursor, tickets]); + + useShortcut('x', (e) => { + if (cursor < 0 || cursor >= tickets.length) return; + e.preventDefault(); + toggleOne(tickets[cursor].id); + }, [cursor, tickets]); + + return ( + + setParams(fn, { replace: true })} + authUser={authUser} + users={users} + savedViews={savedViewsQ.data ?? []} + onDeleteView={(id) => deleteView.mutate(id)} + onApplyView={applyView} + onSaveView={() => setSaveOpen(true)} + activeFilterCount={activeCount} + total={total} + isFetching={isFetching} + /> + + {selected.size > 0 && ( + + )} + + {/* Ticket list */} + {isLoading ? ( +
    Loading…
    + ) : tickets.length === 0 ? ( +
    No tickets found
    + ) : ( +
    +
    + { + if (el) el.indeterminate = !allVisible && someVisible; + }} + onChange={toggleAll} + className="cursor-pointer" + /> + + {tickets.length} of {total} + +
    + +
      + {tickets.map((ticket, idx) => ( + toggleOne(ticket.id)} + /> + ))} +
    +
    + )} + + {/* Pagination */} + {totalPages > 1 && ( +
    +
    + Page {page} of {totalPages} +
    +
    + + +
    +
    + )} + + {/* Save view dialog */} + + + + Save current filters + +
    + setNewViewName(e.target.value)} + placeholder="View name" + className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring" + onKeyDown={(e) => { + if (e.key === 'Enter') handleSaveView(); + }} + /> +
    + + + + +
    +
    + + {/* Confirm bulk action */} + !o && setConfirmBulk(null)}> + + + {confirmBulk?.label} + + This will affect {selected.size} ticket{selected.size === 1 ? '' : 's'}. + + + + Cancel + { + if (!confirmBulk) return; + if (confirmBulk.kind === 'close') runBulk({ action: 'close' }); + else + runBulk({ + action: confirmBulk.kind, + value: confirmBulk.value, + }); + }} + > + Confirm + + + + +
    + ); +} diff --git a/client/src/pages/tickets/index.ts b/client/src/pages/tickets/index.ts new file mode 100644 index 0000000..862b8fc --- /dev/null +++ b/client/src/pages/tickets/index.ts @@ -0,0 +1 @@ +export { default } from './Tickets';