{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "tickets-01", "title": "Tickets 01", "description": "Sortable, filterable tickets table with inline cell editing, drag-to-reorder rows, a new-ticket modal, and a floating bulk-edit action bar.", "dependencies": [ "lucide-react", "@dnd-kit/core", "@dnd-kit/modifiers", "@dnd-kit/sortable", "@dnd-kit/utilities" ], "registryDependencies": [ "alert-dialog", "avatar", "badge", "button", "checkbox", "combobox", "drawer", "dropdown-menu", "field", "input", "input-group", "label", "modal", "radio-group", "select", "selection-bar", "stepper", "table", "toggle-group" ], "files": [ { "path": "registry/default/blocks/tickets-01/page.tsx", "content": "import { TicketsPage } from \"@/components/tickets-page\";\n\nexport default function Page() {\n return ;\n}\n", "type": "registry:page", "target": "app/tickets/page.tsx" }, { "path": "registry/default/blocks/tickets-01/components/tickets-page.tsx", "content": "\"use client\";\n\nimport { arrayMove } from \"@dnd-kit/sortable\";\nimport {\n ChevronDownIcon,\n DownloadIcon,\n FileSpreadsheetIcon,\n MoreHorizontalIcon,\n PlusIcon,\n TrashIcon,\n} from \"lucide-react\";\nimport { useEffect, useMemo, useState } from \"react\";\nimport { DeleteTicketsDialog } from \"@/components/delete-tickets-dialog\";\nimport {\n channelLabels,\n healthLabels,\n initialTickets,\n priorityLabels,\n SEED_TAGS,\n statusLabels,\n type Ticket,\n type TicketAssignee,\n type TicketChannel,\n type TicketHealth,\n type TicketPriority,\n type TicketStatus,\n ticketAssignees,\n} from \"@/components/mock-tickets\";\nimport { NewTicketModal } from \"@/components/new-ticket-modal\";\nimport { TicketDetailDrawer } from \"@/components/ticket-detail-drawer\";\nimport { TicketsPagination } from \"@/components/tickets-pagination\";\nimport {\n type SortState,\n type TicketColumnId,\n TicketsTable,\n ticketColumns,\n} from \"@/components/tickets-table\";\nimport {\n type SortPreset,\n type StatusFilter,\n TicketsToolbar,\n type ViewMode,\n} from \"@/components/tickets-toolbar\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuGroup,\n DropdownMenuItem,\n DropdownMenuLabel,\n DropdownMenuRadioGroup,\n DropdownMenuRadioItem,\n DropdownMenuSeparator,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport {\n SelectionBar,\n SelectionBarBullet,\n SelectionBarButton,\n SelectionBarClose,\n SelectionBarGroup,\n SelectionBarLabel,\n SelectionBarLink,\n SelectionBarSeparator,\n} from \"@/components/ui/selection-bar\";\n\nconst ALL_COLUMN_IDS = ticketColumns.map((c) => c.id);\n\nconst PRIORITY_RANK: Record = {\n urgent: 0,\n high: 1,\n medium: 2,\n low: 3,\n todo: 4,\n};\nconst STATUS_RANK: Record = {\n open: 0,\n pending: 1,\n resolved: 2,\n closed: 3,\n};\n\nfunction sortByPreset(tickets: Ticket[], preset: SortPreset): Ticket[] {\n switch (preset) {\n case \"manual\":\n return tickets;\n case \"priority-desc\":\n return [...tickets].sort(\n (a, b) => PRIORITY_RANK[a.priority] - PRIORITY_RANK[b.priority]\n );\n case \"past-due\":\n return [...tickets].sort((a, b) => {\n const aBreached = a.health === \"breached\" ? 0 : 1;\n const bBreached = b.health === \"breached\" ? 0 : 1;\n return aBreached - bBreached;\n });\n case \"escalated\":\n return [...tickets].sort((a, b) => {\n const aUrgent = a.priority === \"urgent\" ? 0 : 1;\n const bUrgent = b.priority === \"urgent\" ? 0 : 1;\n return aUrgent - bUrgent;\n });\n default:\n return [...tickets].sort(\n (a, b) =>\n STATUS_RANK[a.status] - STATUS_RANK[b.status] ||\n a.ticketNumber.localeCompare(b.ticketNumber)\n );\n }\n}\n\nfunction applyColumnSort(tickets: Ticket[], sort: SortState): Ticket[] {\n const direction = sort.direction === \"desc\" ? -1 : 1;\n return [...tickets].sort((a, b) => {\n const av = compareValueFor(a, sort.columnId);\n const bv = compareValueFor(b, sort.columnId);\n if (av === bv) {\n return 0;\n }\n return av < bv ? -1 * direction : 1 * direction;\n });\n}\n\nfunction compareValueFor(ticket: Ticket, columnId: TicketColumnId): string {\n switch (columnId) {\n case \"ticketNumber\":\n return ticket.ticketNumber;\n case \"subject\":\n return ticket.subject.toLowerCase();\n case \"status\":\n return String(STATUS_RANK[ticket.status]);\n case \"priority\":\n return String(PRIORITY_RANK[ticket.priority]);\n case \"assignee\":\n return ticket.assignee?.name.toLowerCase() ?? \"~unassigned\";\n case \"category\":\n return ticket.category;\n case \"channel\":\n return ticket.channel;\n case \"health\":\n return ticket.health;\n default:\n return \"\";\n }\n}\n\nexport function TicketsPage() {\n const [tickets, setTickets] = useState(initialTickets);\n const [searchQuery, setSearchQuery] = useState(\"\");\n const [sortPreset, setSortPreset] = useState(\"manual\");\n const [columnSort, setColumnSort] = useState(null);\n const [statusFilter, setStatusFilter] = useState(\"all\");\n const [view, setView] = useState(\"table\");\n const [visibleColumns, setVisibleColumns] = useState>(\n () => new Set(ALL_COLUMN_IDS)\n );\n const [selectedIds, setSelectedIds] = useState>(new Set());\n const [isCreateOpen, setCreateOpen] = useState(false);\n const [isDeleteOpen, setDeleteOpen] = useState(false);\n const [nextTicketSeq, setNextTicketSeq] = useState(initialTickets.length + 1);\n const [pageIndex, setPageIndex] = useState(0);\n const [pageSize, setPageSize] = useState(10);\n const [openedTicketId, setOpenedTicketId] = useState(null);\n\n const visibleTickets = useMemo(() => {\n let result = tickets;\n if (statusFilter !== \"all\") {\n result = result.filter((t) => t.status === statusFilter);\n }\n if (searchQuery.trim().length > 0) {\n const q = searchQuery.toLowerCase();\n result = result.filter(\n (t) =>\n t.subject.toLowerCase().includes(q) ||\n t.ticketNumber.toLowerCase().includes(q) ||\n t.assignee?.name.toLowerCase().includes(q)\n );\n }\n result = sortByPreset(result, sortPreset);\n if (columnSort) {\n result = applyColumnSort(result, columnSort);\n }\n return result;\n }, [tickets, statusFilter, searchQuery, sortPreset, columnSort]);\n\n const pageCount = Math.max(1, Math.ceil(visibleTickets.length / pageSize));\n const paginatedTickets = useMemo(\n () =>\n visibleTickets.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize),\n [visibleTickets, pageIndex, pageSize]\n );\n\n const availableTags = useMemo(\n () =>\n Array.from(\n new Set([...SEED_TAGS, ...tickets.flatMap((t) => t.tags ?? [])])\n ).sort(),\n [tickets]\n );\n\n // Clamp page when the dataset shrinks beneath the current page (e.g. delete or filter).\n useEffect(() => {\n if (pageIndex >= pageCount) {\n setPageIndex(pageCount - 1);\n }\n }, [pageCount, pageIndex]);\n\n const handleSearchChange = (next: string) => {\n setSearchQuery(next);\n setPageIndex(0);\n };\n\n const handleStatusFilterChange = (next: StatusFilter) => {\n setStatusFilter(next);\n setPageIndex(0);\n };\n\n const handlePageSizeChange = (next: number) => {\n setPageSize(next);\n setPageIndex(0);\n };\n\n const handleToggleRow = (id: string, checked: boolean) => {\n setSelectedIds((prev) => {\n const next = new Set(prev);\n if (checked) {\n next.add(id);\n } else {\n next.delete(id);\n }\n return next;\n });\n };\n\n const handleToggleAllVisible = (checked: boolean) => {\n setSelectedIds((prev) => {\n const next = new Set(prev);\n if (checked) {\n for (const t of paginatedTickets) {\n next.add(t.id);\n }\n } else {\n for (const t of paginatedTickets) {\n next.delete(t.id);\n }\n }\n return next;\n });\n };\n\n const handleClearSelection = () => setSelectedIds(new Set());\n\n const handleSelectAllFiltered = () => {\n setSelectedIds(new Set(visibleTickets.map((t) => t.id)));\n };\n\n const handleUpdateTicket = (id: string, patch: Partial) => {\n setTickets((prev) =>\n prev.map((t) => (t.id === id ? { ...t, ...patch } : t))\n );\n };\n\n const applyToSelection = (patch: Partial) => {\n setTickets((prev) =>\n prev.map((t) => (selectedIds.has(t.id) ? { ...t, ...patch } : t))\n );\n };\n\n const handleCreateTicket = (\n draft: Omit\n ) => {\n const padded = String(nextTicketSeq).padStart(3, \"0\");\n const newTicket: Ticket = {\n ...draft,\n id: `t-${padded}`,\n ticketNumber: `#-${padded}`,\n health: \"on-track\",\n };\n setTickets((prev) => [newTicket, ...prev]);\n setNextTicketSeq((seq) => seq + 1);\n };\n\n const handleConfirmDelete = () => {\n setTickets((prev) => prev.filter((t) => !selectedIds.has(t.id)));\n setSelectedIds(new Set());\n };\n\n const handleReorder = (activeId: string, overId: string) => {\n setTickets((prev) => {\n const oldIndex = prev.findIndex((t) => t.id === activeId);\n const newIndex = prev.findIndex((t) => t.id === overId);\n if (oldIndex < 0 || newIndex < 0) {\n return prev;\n }\n return arrayMove(prev, oldIndex, newIndex);\n });\n };\n\n const toggleColumn = (id: TicketColumnId) => {\n setVisibleColumns((prev) => {\n const next = new Set(prev);\n if (next.has(id)) {\n if (next.size > 1) {\n next.delete(id);\n }\n } else {\n next.add(id);\n }\n return next;\n });\n };\n\n const selectedCount = selectedIds.size;\n const visibleSelectedCount = visibleTickets.filter((t) =>\n selectedIds.has(t.id)\n ).length;\n const showSelectAllVisibleAction =\n visibleSelectedCount > 0 && visibleSelectedCount < visibleTickets.length;\n\n return (\n
\n
\n
\n

\n Tickets\n

\n
\n \n \n \n \n \n
\n
\n\n \n\n {view === \"table\" ? (\n <>\n \n \n \n ) : (\n
\n Kanban view coming soon — switch back to Table view to see tickets.\n
\n )}\n
\n\n 0}>\n \n \n \n {selectedCount} {selectedCount === 1 ? \"ticket\" : \"tickets\"}{\" \"}\n selected\n \n {showSelectAllVisibleAction ? (\n <>\n \n \n Select all {visibleTickets.length}\n \n \n ) : null}\n \n\n \n }>\n Change status\n \n \n \n \n applyToSelection({ status: value as TicketStatus })\n }\n value=\"\"\n >\n {([\"open\", \"pending\", \"resolved\", \"closed\"] as const).map(\n (status) => (\n \n {statusLabels[status]}\n \n )\n )}\n \n \n \n \n\n \n }>\n Assign\n \n \n \n applyToSelection({ assignee: undefined })}\n >\n Unassigned\n \n {ticketAssignees.map((assignee: TicketAssignee) => (\n applyToSelection({ assignee })}\n >\n {assignee.name}\n \n ))}\n \n \n \n\n \n }>\n Priority\n \n \n \n \n applyToSelection({ priority: value as TicketPriority })\n }\n value=\"\"\n >\n {([\"urgent\", \"high\", \"medium\", \"low\", \"todo\"] as const).map(\n (priority) => (\n \n {priorityLabels[priority]}\n \n )\n )}\n \n \n \n \n\n \n\n \n }>\n \n \n \n \n Health\n \n applyToSelection({ health: value as TicketHealth })\n }\n value=\"\"\n >\n {([\"on-track\", \"warning\", \"breached\"] as const).map((h) => (\n \n {healthLabels[h]}\n \n ))}\n \n \n \n \n Channel\n \n applyToSelection({ channel: value as TicketChannel })\n }\n value=\"\"\n >\n {([\"email\", \"chat\", \"slack\"] as const).map((c) => (\n \n {channelLabels[c]}\n \n ))}\n \n \n \n \n\n \n \n Export CSV\n \n setDeleteOpen(true)}\n tone=\"destructive\"\n >\n \n Delete\n \n \n\n \n\n \n\n setOpenedTicketId(null)}\n onSave={handleUpdateTicket}\n ticket={\n openedTicketId\n ? (tickets.find((t) => t.id === openedTicketId) ?? null)\n : null\n }\n />\n
\n );\n}\n", "type": "registry:component" }, { "path": "registry/default/blocks/tickets-01/components/tickets-table.tsx", "content": "\"use client\";\n\nimport {\n closestCenter,\n DndContext,\n type DragEndEvent,\n KeyboardSensor,\n MouseSensor,\n TouchSensor,\n useSensor,\n useSensors,\n} from \"@dnd-kit/core\";\nimport { restrictToVerticalAxis } from \"@dnd-kit/modifiers\";\nimport {\n SortableContext,\n useSortable,\n verticalListSortingStrategy,\n} from \"@dnd-kit/sortable\";\nimport { CSS } from \"@dnd-kit/utilities\";\nimport {\n ArrowDownIcon,\n ArrowUpDownIcon,\n ArrowUpIcon,\n ChevronsUpDownIcon,\n ExternalLinkIcon,\n GripVerticalIcon,\n HashIcon,\n MailIcon,\n MessageSquareIcon,\n MinusIcon,\n TagIcon,\n TriangleAlertIcon,\n} from \"lucide-react\";\nimport { useId, useMemo } from \"react\";\nimport {\n categoryLabels,\n channelLabels,\n healthLabels,\n priorityLabels,\n statusLabels,\n type Ticket,\n type TicketAssignee,\n type TicketCategory,\n type TicketChannel,\n type TicketHealth,\n type TicketPriority,\n type TicketStatus,\n} from \"@/components/mock-tickets\";\nimport { TicketChannelCell } from \"@/components/ticket-channel-cell\";\nimport { TicketHealthBadge } from \"@/components/ticket-health-badge\";\nimport { TicketPriorityCell } from \"@/components/ticket-priority-cell\";\nimport { TicketStatusBadge } from \"@/components/ticket-status-badge\";\nimport { Avatar, AvatarFallback, AvatarImage } from \"@/components/ui/avatar\";\nimport { Button } from \"@/components/ui/button\";\nimport { Checkbox } from \"@/components/ui/checkbox\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuGroup,\n DropdownMenuRadioGroup,\n DropdownMenuRadioItem,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n Table,\n TableBody,\n TableCell,\n TableFooter,\n TableHead,\n TableHeader,\n TableRow,\n} from \"@/components/ui/table\";\nimport { useCellEditing } from \"@/components/use-cell-editing\";\nimport { cn } from \"@/lib/utils\";\n\nexport type TicketColumnId =\n | \"ticketNumber\"\n | \"subject\"\n | \"status\"\n | \"priority\"\n | \"assignee\"\n | \"category\"\n | \"channel\"\n | \"health\";\n\ninterface ColumnDef {\n icon: React.ComponentType<{ className?: string }>;\n id: TicketColumnId;\n label: string;\n /** Whether this column is hidden on small screens. */\n responsive?: boolean;\n width?: string;\n}\n\nexport const ticketColumns: ColumnDef[] = [\n { id: \"ticketNumber\", label: \"ID\", icon: HashIcon, width: \"w-[88px]\" },\n { id: \"subject\", label: \"Subject\", icon: TagIcon },\n {\n id: \"status\",\n label: \"Status\",\n icon: ChevronsUpDownIcon,\n width: \"w-[140px]\",\n },\n {\n id: \"priority\",\n label: \"Priority\",\n icon: TriangleAlertIcon,\n width: \"w-[140px]\",\n },\n { id: \"assignee\", label: \"Assignee\", icon: UserDot, width: \"w-[180px]\" },\n {\n id: \"category\",\n label: \"Category\",\n icon: TagIcon,\n width: \"w-[140px]\",\n responsive: true,\n },\n {\n id: \"channel\",\n label: \"Channel\",\n icon: MailIcon,\n width: \"w-[120px]\",\n responsive: true,\n },\n {\n id: \"health\",\n label: \"Health\",\n icon: TimerIcon,\n width: \"w-[140px]\",\n responsive: true,\n },\n];\n\nfunction UserDot({ className }: { className?: string }) {\n return (\n \n Assignee\n \n \n \n );\n}\n\nfunction TimerIcon({ className }: { className?: string }) {\n return (\n \n Health\n \n \n \n );\n}\n\nexport type SortDirection = \"asc\" | \"desc\" | null;\nexport interface SortState {\n columnId: TicketColumnId;\n direction: SortDirection;\n}\n\nfunction nextSortDirection(current: SortDirection): SortDirection {\n if (current === null) {\n return \"asc\";\n }\n if (current === \"asc\") {\n return \"desc\";\n }\n return null;\n}\n\nconst STATUS_OPTIONS: TicketStatus[] = [\n \"open\",\n \"pending\",\n \"resolved\",\n \"closed\",\n];\nconst PRIORITY_OPTIONS: TicketPriority[] = [\n \"urgent\",\n \"high\",\n \"medium\",\n \"low\",\n \"todo\",\n];\nconst CATEGORY_OPTIONS: TicketCategory[] = [\n \"billing\",\n \"technical\",\n \"access\",\n \"subscription\",\n \"other\",\n];\nconst CHANNEL_OPTIONS: TicketChannel[] = [\"email\", \"chat\", \"slack\"];\nconst HEALTH_OPTIONS: TicketHealth[] = [\"on-track\", \"warning\", \"breached\"];\n\ninterface TicketsTableProps {\n assigneeOptions: TicketAssignee[];\n /** When false, drag handles render dimmed and listeners are not attached. */\n dragEnabled: boolean;\n onOpenTicket: (id: string) => void;\n onReorder: (activeId: string, overId: string) => void;\n onSortChange: (next: SortState | null) => void;\n onToggleAllVisible: (checked: boolean) => void;\n onToggleRow: (id: string, checked: boolean) => void;\n onUpdateTicket: (id: string, patch: Partial) => void;\n selectedIds: Set;\n sort: SortState | null;\n tickets: Ticket[];\n visibleColumns: Set;\n}\n\nexport function TicketsTable({\n tickets,\n assigneeOptions,\n dragEnabled,\n onOpenTicket,\n onReorder,\n selectedIds,\n onToggleRow,\n onToggleAllVisible,\n visibleColumns,\n sort,\n onSortChange,\n onUpdateTicket,\n}: TicketsTableProps) {\n const editing = useCellEditing();\n const visibleColumnDefs = ticketColumns.filter((col) =>\n visibleColumns.has(col.id)\n );\n\n const sortableId = useId();\n const sensors = useSensors(\n useSensor(MouseSensor, {}),\n useSensor(TouchSensor, {}),\n useSensor(KeyboardSensor, {})\n );\n const ticketIds = useMemo(() => tickets.map((t) => t.id), [tickets]);\n\n const handleDragEnd = (event: DragEndEvent) => {\n const { active, over } = event;\n if (active && over && active.id !== over.id) {\n onReorder(String(active.id), String(over.id));\n }\n };\n\n const visibleCount = tickets.length;\n const selectedVisibleCount = tickets.filter((t) =>\n selectedIds.has(t.id)\n ).length;\n const allVisibleSelected =\n visibleCount > 0 && selectedVisibleCount === visibleCount;\n const someVisibleSelected = selectedVisibleCount > 0 && !allVisibleSelected;\n\n const summary = {\n openCount: tickets.filter((t) => t.status === \"open\").length,\n urgentCount: tickets.filter((t) => t.priority === \"urgent\").length,\n unassignedCount: tickets.filter((t) => !t.assignee).length,\n breachedCount: tickets.filter((t) => t.health === \"breached\").length,\n };\n\n const handleSubjectCommit = (rowId: string) => {\n const value = editing.draftValue.trim();\n if (value.length > 0) {\n onUpdateTicket(rowId, { subject: value });\n }\n editing.cancel();\n };\n\n const handleSortClick = (columnId: TicketColumnId) => {\n if (sort?.columnId !== columnId) {\n onSortChange({ columnId, direction: \"asc\" });\n return;\n }\n const next = nextSortDirection(sort.direction);\n onSortChange(next === null ? null : { columnId, direction: next });\n };\n\n const renderRowCells = (ticket: Ticket) => (\n <>\n \n onToggleRow(ticket.id, Boolean(c))}\n />\n \n {visibleColumnDefs.map((column) => (\n \n {renderCell({\n ticket,\n columnId: column.id,\n assigneeOptions,\n editing,\n onCommitSubject: () => handleSubjectCommit(ticket.id),\n onOpenTicket,\n onUpdateTicket,\n })}\n \n ))}\n \n );\n\n return (\n
\n \n \n \n \n \n \n onToggleAllVisible(checked)}\n />\n \n {visibleColumnDefs.map((column) => (\n \n handleSortClick(column.id)}\n sort={sort}\n />\n \n ))}\n \n \n\n \n {tickets.length === 0 ? (\n \n \n No tickets match your filters.\n \n \n ) : (\n \n {tickets.map((ticket) => (\n \n {renderRowCells(ticket)}\n \n ))}\n \n )}\n \n\n \n \n \n \n {visibleColumnDefs.map((column) => (\n \n {renderSummary(column.id, tickets.length, summary)}\n \n ))}\n \n \n
\n \n
\n );\n}\n\nfunction DragHandle({ id, disabled }: { id: string; disabled: boolean }) {\n const { attributes, listeners } = useSortable({ id });\n const handleAttrs = disabled ? {} : { ...attributes, ...listeners };\n\n return (\n \n \n \n );\n}\n\nfunction DraggableRow({\n ticket,\n isSelected,\n dragEnabled,\n children,\n}: {\n ticket: Ticket;\n isSelected: boolean;\n dragEnabled: boolean;\n children: React.ReactNode;\n}) {\n const { setNodeRef, transform, transition, isDragging } = useSortable({\n id: ticket.id,\n });\n\n return (\n \n \n \n \n {children}\n \n );\n}\n\nfunction SortableHeader({\n column,\n sort,\n onClick,\n}: {\n column: ColumnDef;\n sort: SortState | null;\n onClick: () => void;\n}) {\n const Icon = column.icon;\n const isActive = sort?.columnId === column.id;\n const direction = isActive ? sort.direction : null;\n let SortIcon = ArrowUpDownIcon;\n if (direction === \"asc\") {\n SortIcon = ArrowUpIcon;\n } else if (direction === \"desc\") {\n SortIcon = ArrowDownIcon;\n }\n\n return (\n \n \n \n {column.label}\n \n \n \n );\n}\n\nfunction renderCell({\n ticket,\n columnId,\n assigneeOptions,\n editing,\n onCommitSubject,\n onOpenTicket,\n onUpdateTicket,\n}: {\n ticket: Ticket;\n columnId: TicketColumnId;\n assigneeOptions: TicketAssignee[];\n editing: ReturnType;\n onCommitSubject: () => void;\n onOpenTicket: (id: string) => void;\n onUpdateTicket: (id: string, patch: Partial) => void;\n}) {\n switch (columnId) {\n case \"ticketNumber\":\n return (\n \n {ticket.ticketNumber}\n \n );\n\n case \"subject\": {\n const isEditing = editing.isEditing(ticket.id, \"subject\");\n if (isEditing) {\n return (\n editing.setDraftValue(event.target.value)}\n onKeyDown={(event) => {\n if (event.key === \"Enter\") {\n event.preventDefault();\n onCommitSubject();\n } else if (event.key === \"Escape\") {\n event.preventDefault();\n editing.cancel();\n }\n }}\n size=\"sm\"\n value={editing.draftValue}\n />\n );\n }\n return (\n
\n \n editing.startEdit(\n { rowId: ticket.id, columnId: \"subject\" },\n ticket.subject\n )\n }\n type=\"button\"\n >\n {ticket.subject}\n \n onOpenTicket(ticket.id)}\n size=\"sm\"\n variant=\"outline\"\n >\n \n Open\n \n
\n );\n }\n\n case \"status\":\n return (\n \n \n \n \n \n \n \n onUpdateTicket(ticket.id, {\n status: value as TicketStatus,\n })\n }\n value={ticket.status}\n >\n {STATUS_OPTIONS.map((option) => (\n \n {statusLabels[option]}\n \n ))}\n \n \n \n \n );\n\n case \"priority\":\n return (\n \n \n \n \n \n \n \n onUpdateTicket(ticket.id, {\n priority: value as TicketPriority,\n })\n }\n value={ticket.priority}\n >\n {PRIORITY_OPTIONS.map((option) => (\n \n {priorityLabels[option]}\n \n ))}\n \n \n \n \n );\n\n case \"assignee\":\n return (\n \n \n \n \n \n \n {\n if (value === \"__unassigned\") {\n onUpdateTicket(ticket.id, { assignee: undefined });\n return;\n }\n const matched = assigneeOptions.find((a) => a.name === value);\n if (matched) {\n onUpdateTicket(ticket.id, { assignee: matched });\n }\n }}\n value={ticket.assignee?.name ?? \"__unassigned\"}\n >\n \n Unassigned\n \n {assigneeOptions.map((assignee) => (\n \n {assignee.name}\n \n ))}\n \n \n \n \n );\n\n case \"category\":\n return (\n \n \n {categoryLabels[ticket.category]}\n \n \n \n \n onUpdateTicket(ticket.id, {\n category: value as TicketCategory,\n })\n }\n value={ticket.category}\n >\n {CATEGORY_OPTIONS.map((option) => (\n \n {categoryLabels[option]}\n \n ))}\n \n \n \n \n );\n\n case \"channel\":\n return (\n \n \n \n \n \n \n \n onUpdateTicket(ticket.id, {\n channel: value as TicketChannel,\n })\n }\n value={ticket.channel}\n >\n {CHANNEL_OPTIONS.map((option) => (\n \n {channelLabels[option]}\n \n ))}\n \n \n \n \n );\n\n case \"health\":\n return (\n \n \n \n \n \n \n \n onUpdateTicket(ticket.id, { health: value as TicketHealth })\n }\n value={ticket.health}\n >\n {HEALTH_OPTIONS.map((option) => (\n \n {healthLabels[option]}\n \n ))}\n \n \n \n \n );\n\n default:\n return null;\n }\n}\n\nfunction renderSummary(\n columnId: TicketColumnId,\n totalCount: number,\n summary: {\n openCount: number;\n urgentCount: number;\n unassignedCount: number;\n breachedCount: number;\n }\n) {\n switch (columnId) {\n case \"subject\":\n return `${totalCount} ${totalCount === 1 ? \"ticket\" : \"tickets\"}`;\n case \"status\":\n return `${summary.openCount} open`;\n case \"priority\":\n return `${summary.urgentCount} urgent`;\n case \"assignee\":\n return `${summary.unassignedCount} unassigned`;\n case \"health\":\n return `${summary.breachedCount} breached`;\n default:\n return null;\n }\n}\n\nfunction AssigneeDisplay({ assignee }: { assignee?: TicketAssignee }) {\n if (!assignee) {\n return (\n \n \n Unassigned\n \n );\n }\n\n const initials = assignee.name\n .split(\" \")\n .map((part) => part[0])\n .join(\"\")\n .slice(0, 2)\n .toUpperCase();\n\n return (\n \n \n {assignee.avatarUrl ? (\n \n ) : null}\n {initials}\n \n {assignee.name}\n \n );\n}\n\n// ---------------------------------------------------------------------------\n// SelectAllCheckbox — visually distinguishes indeterminate from checked\n// ---------------------------------------------------------------------------\n\nfunction SelectAllCheckbox({\n checked,\n indeterminate,\n onChange,\n}: {\n checked: boolean;\n indeterminate: boolean;\n onChange: (next: boolean) => void;\n}) {\n if (indeterminate) {\n return (\n // biome-ignore lint/a11y/useSemanticElements: native can't render a custom indeterminate icon in a way that matches the row checkbox styling\n onChange(true)}\n role=\"checkbox\"\n type=\"button\"\n >\n \n \n );\n }\n\n return (\n onChange(Boolean(next))}\n />\n );\n}\n\n// Re-export the icons used in column defs above\nexport {\n HashIcon,\n MailIcon,\n MessageSquareIcon,\n TagIcon,\n TimerIcon,\n TriangleAlertIcon,\n UserDot,\n};\n", "type": "registry:component" }, { "path": "registry/default/blocks/tickets-01/components/tickets-toolbar.tsx", "content": "\"use client\";\n\nimport {\n ArrowUpDownIcon,\n KanbanIcon,\n SearchIcon,\n SlidersHorizontalIcon,\n TableIcon,\n} from \"lucide-react\";\nimport { statusLabels, type TicketStatus } from \"@/components/mock-tickets\";\nimport { TicketsFiltersDrawer } from \"@/components/tickets-filters-drawer\";\nimport { type TicketColumnId, ticketColumns } from \"@/components/tickets-table\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n DropdownMenu,\n DropdownMenuCheckboxItem,\n DropdownMenuContent,\n DropdownMenuGroup,\n DropdownMenuLabel,\n DropdownMenuRadioGroup,\n DropdownMenuRadioItem,\n DropdownMenuSeparator,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport {\n InputGroup,\n InputGroupAddon,\n InputGroupInput,\n} from \"@/components/ui/input-group\";\nimport { ToggleGroup, ToggleGroupItem } from \"@/components/ui/toggle-group\";\n\nexport type SortPreset =\n | \"manual\"\n | \"board-order\"\n | \"priority-desc\"\n | \"past-due\"\n | \"escalated\";\nexport type ViewMode = \"table\" | \"board\";\nexport type StatusFilter = \"all\" | TicketStatus;\n\nexport const sortPresetLabels: Record = {\n manual: \"Manual\",\n \"board-order\": \"Board order\",\n \"priority-desc\": \"Priority high → low\",\n \"past-due\": \"Past due first\",\n escalated: \"Escalated first\",\n};\n\ninterface TicketsToolbarProps {\n onSearchChange: (next: string) => void;\n onSortPresetChange: (next: SortPreset) => void;\n onStatusFilterChange: (next: StatusFilter) => void;\n onToggleColumn: (id: TicketColumnId) => void;\n onViewChange: (next: ViewMode) => void;\n searchQuery: string;\n sortPreset: SortPreset;\n statusFilter: StatusFilter;\n view: ViewMode;\n visibleColumns: Set;\n}\n\nexport function TicketsToolbar({\n searchQuery,\n onSearchChange,\n view,\n onViewChange,\n sortPreset,\n onSortPresetChange,\n statusFilter,\n onStatusFilterChange,\n visibleColumns,\n onToggleColumn,\n}: TicketsToolbarProps) {\n return (\n
\n \n \n \n \n onSearchChange(event.target.value)}\n placeholder=\"Search tickets by subject, customer, or ID…\"\n type=\"search\"\n value={searchQuery}\n />\n \n\n {/* Mobile: single Filters button → opens drawer */}\n
\n \n
\n\n {/* Desktop: full button cluster */}\n
\n \n }>\n \n {sortPresetLabels[sortPreset]}\n \n \n \n \n onSortPresetChange(value as SortPreset)\n }\n value={sortPreset}\n >\n {(\n [\n \"manual\",\n \"board-order\",\n \"priority-desc\",\n \"past-due\",\n \"escalated\",\n ] as const\n ).map((preset) => (\n \n {sortPresetLabels[preset]}\n \n ))}\n \n \n \n \n\n \n }>\n \n Table options\n \n \n \n Columns\n {ticketColumns.map((column) => {\n const checked = visibleColumns.has(column.id);\n return (\n onToggleColumn(column.id)}\n >\n {column.label}\n \n );\n })}\n \n \n \n\n \n }>\n {statusFilter === \"all\"\n ? \"All Statuses\"\n : statusLabels[statusFilter]}\n \n \n \n \n onStatusFilterChange(value as StatusFilter)\n }\n value={statusFilter}\n >\n \n All Statuses\n \n \n Open\n \n Pending\n \n \n Resolved\n \n \n Closed\n \n \n \n \n \n\n {\n const last = next.at(-1);\n if (last === \"board\" || last === \"table\") {\n onViewChange(last);\n }\n }}\n value={[view]}\n variant=\"outline\"\n >\n \n \n \n \n \n \n \n
\n
\n );\n}\n", "type": "registry:component" }, { "path": "registry/default/blocks/tickets-01/components/tickets-filters-drawer.tsx", "content": "\"use client\";\n\nimport { ArrowLeftIcon, ChevronRightIcon, FilterIcon } from \"lucide-react\";\nimport { useState } from \"react\";\nimport { statusLabels } from \"@/components/mock-tickets\";\nimport { type TicketColumnId, ticketColumns } from \"@/components/tickets-table\";\nimport {\n type SortPreset,\n type StatusFilter,\n sortPresetLabels,\n type ViewMode,\n} from \"@/components/tickets-toolbar\";\nimport { Button } from \"@/components/ui/button\";\nimport { Checkbox } from \"@/components/ui/checkbox\";\nimport {\n Drawer,\n DrawerContent,\n DrawerFooter,\n DrawerHeader,\n DrawerTitle,\n DrawerTrigger,\n} from \"@/components/ui/drawer\";\nimport { RadioGroup, RadioGroupItem } from \"@/components/ui/radio-group\";\nimport {\n Stepper,\n StepperContent,\n StepperStep,\n useStepper,\n} from \"@/components/ui/stepper\";\n\nconst SORT_OPTIONS: SortPreset[] = [\n \"manual\",\n \"board-order\",\n \"priority-desc\",\n \"past-due\",\n \"escalated\",\n];\n\nconst STATUS_OPTIONS: { value: StatusFilter; label: string }[] = [\n { value: \"all\", label: \"All Statuses\" },\n { value: \"open\", label: statusLabels.open },\n { value: \"pending\", label: statusLabels.pending },\n { value: \"resolved\", label: statusLabels.resolved },\n { value: \"closed\", label: statusLabels.closed },\n];\n\nconst VIEW_OPTIONS: { value: ViewMode; label: string }[] = [\n { value: \"table\", label: \"Table\" },\n { value: \"board\", label: \"Board\" },\n];\n\ninterface TicketsFiltersDrawerProps {\n onSortPresetChange: (next: SortPreset) => void;\n onStatusFilterChange: (next: StatusFilter) => void;\n onToggleColumn: (id: TicketColumnId) => void;\n onViewChange: (next: ViewMode) => void;\n sortPreset: SortPreset;\n statusFilter: StatusFilter;\n view: ViewMode;\n visibleColumns: Set;\n}\n\nexport function TicketsFiltersDrawer({\n sortPreset,\n onSortPresetChange,\n statusFilter,\n onStatusFilterChange,\n view,\n onViewChange,\n visibleColumns,\n onToggleColumn,\n}: TicketsFiltersDrawerProps) {\n const [open, setOpen] = useState(false);\n const [step, setStep] = useState(0);\n\n return (\n {\n setOpen(isOpen);\n if (!isOpen) {\n setStep(0);\n }\n }}\n open={open}\n >\n }>\n \n Filters\n \n \n
\n \n \n \n \n Filters\n \n \n \n \n \n \n\n \n \n
\n {\n if (val) {\n onSortPresetChange(val as SortPreset);\n }\n }}\n value={sortPreset}\n >\n {SORT_OPTIONS.map((preset) => (\n // biome-ignore lint/a11y/noLabelWithoutControl: control is inside label\n \n \n \n {sortPresetLabels[preset]}\n \n \n ))}\n \n
\n
\n\n \n \n
\n {\n if (val) {\n onStatusFilterChange(val as StatusFilter);\n }\n }}\n value={statusFilter}\n >\n {STATUS_OPTIONS.map((option) => (\n // biome-ignore lint/a11y/noLabelWithoutControl: control is inside label\n \n \n {option.label}\n \n ))}\n \n
\n
\n\n \n \n
\n {ticketColumns.map((column) => (\n // biome-ignore lint/a11y/noLabelWithoutControl: control is inside label\n \n onToggleColumn(column.id)}\n />\n {column.label}\n \n ))}\n
\n
\n\n \n \n
\n {\n if (val === \"table\" || val === \"board\") {\n onViewChange(val);\n }\n }}\n value={view}\n >\n {VIEW_OPTIONS.map((option) => (\n // biome-ignore lint/a11y/noLabelWithoutControl: control is inside label\n \n \n {option.label}\n \n ))}\n \n
\n
\n
\n
\n
\n
\n \n );\n}\n\nfunction FilterMenu({\n sortLabel,\n statusLabel,\n viewLabel,\n visibleColumnsCount,\n}: {\n sortLabel: string;\n statusLabel: string;\n viewLabel: string;\n visibleColumnsCount: number;\n}) {\n const { goTo } = useStepper();\n const items: { label: string; value: string; step: number }[] = [\n { label: \"Sort by\", value: sortLabel, step: 1 },\n { label: \"Status\", value: statusLabel, step: 2 },\n {\n label: \"Columns\",\n value: `${visibleColumnsCount} visible`,\n step: 3,\n },\n { label: \"View\", value: viewLabel, step: 4 },\n ];\n\n return (\n
\n {items.map((item) => (\n goTo(item.step)}\n type=\"button\"\n >\n {item.label}\n \n {item.value}\n \n \n \n ))}\n
\n );\n}\n\nfunction SubHeader({ title }: { title: string }) {\n const { goTo } = useStepper();\n return (\n \n
\n goTo(0)}\n size=\"icon-xs\"\n variant=\"ghost\"\n >\n \n \n {title}\n
\n
\n );\n}\n", "type": "registry:component" }, { "path": "registry/default/blocks/tickets-01/components/tickets-pagination.tsx", "content": "\"use client\";\n\nimport {\n ChevronLeftIcon,\n ChevronRightIcon,\n ChevronsLeftIcon,\n ChevronsRightIcon,\n} from \"lucide-react\";\nimport { useId } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Label } from \"@/components/ui/label\";\nimport {\n Select,\n SelectContent,\n SelectGroup,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"@/components/ui/select\";\n\nconst PAGE_SIZE_OPTIONS = [5, 10, 20, 50];\n\ninterface TicketsPaginationProps {\n onPageIndexChange: (next: number) => void;\n onPageSizeChange: (next: number) => void;\n pageIndex: number;\n pageSize: number;\n selectedCount: number;\n totalCount: number;\n}\n\nexport function TicketsPagination({\n totalCount,\n selectedCount,\n pageIndex,\n pageSize,\n onPageIndexChange,\n onPageSizeChange,\n}: TicketsPaginationProps) {\n const pageCount = Math.max(1, Math.ceil(totalCount / pageSize));\n const canGoPrev = pageIndex > 0;\n const canGoNext = pageIndex < pageCount - 1;\n const pageSizeId = useId();\n\n return (\n
\n
\n {selectedCount} of {totalCount} row(s) selected.\n
\n\n
\n
\n \n {\n if (value) {\n onPageSizeChange(Number(value));\n }\n }}\n value={String(pageSize)}\n >\n \n \n \n \n \n {PAGE_SIZE_OPTIONS.map((option) => (\n \n {option}\n \n ))}\n \n \n \n
\n\n
\n Page {pageIndex + 1} of {pageCount}\n
\n\n
\n onPageIndexChange(0)}\n size=\"icon-sm\"\n variant=\"outline\"\n >\n \n \n onPageIndexChange(pageIndex - 1)}\n size=\"icon-sm\"\n variant=\"outline\"\n >\n \n \n onPageIndexChange(pageIndex + 1)}\n size=\"icon-sm\"\n variant=\"outline\"\n >\n \n \n onPageIndexChange(pageCount - 1)}\n size=\"icon-sm\"\n variant=\"outline\"\n >\n \n \n
\n
\n
\n );\n}\n", "type": "registry:component" }, { "path": "registry/default/blocks/tickets-01/components/ticket-detail-drawer.tsx", "content": "\"use client\";\n\nimport { PlusIcon, TicketIcon, XIcon } from \"lucide-react\";\nimport { Fragment, useEffect, useState } from \"react\";\nimport {\n categoryLabels,\n channelLabels,\n healthLabels,\n statusLabels,\n type Ticket,\n type TicketAssignee,\n type TicketCategory,\n type TicketChannel,\n type TicketHealth,\n type TicketPriority,\n type TicketStatus,\n} from \"@/components/mock-tickets\";\nimport { TicketPriorityCell } from \"@/components/ticket-priority-cell\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n Combobox,\n ComboboxChip,\n ComboboxChips,\n ComboboxChipsInput,\n ComboboxContent,\n ComboboxItem,\n ComboboxList,\n ComboboxSeparator,\n ComboboxValue,\n useComboboxAnchor,\n} from \"@/components/ui/combobox\";\nimport {\n Drawer,\n DrawerClose,\n DrawerContent,\n DrawerFooter,\n DrawerHeader,\n DrawerTitle,\n} from \"@/components/ui/drawer\";\nimport { Field, FieldGroup, FieldLabel } from \"@/components/ui/field\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n Select,\n SelectContent,\n SelectGroup,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"@/components/ui/select\";\n\nconst PRIORITY_OPTIONS: TicketPriority[] = [\n \"urgent\",\n \"high\",\n \"medium\",\n \"low\",\n \"todo\",\n];\nconst STATUS_OPTIONS: TicketStatus[] = [\n \"open\",\n \"pending\",\n \"resolved\",\n \"closed\",\n];\nconst CATEGORY_OPTIONS: TicketCategory[] = [\n \"billing\",\n \"technical\",\n \"access\",\n \"subscription\",\n \"other\",\n];\nconst CHANNEL_OPTIONS: TicketChannel[] = [\"email\", \"chat\", \"slack\"];\nconst HEALTH_OPTIONS: TicketHealth[] = [\"on-track\", \"warning\", \"breached\"];\n\nconst UNASSIGNED = \"__unassigned\";\n\ninterface TicketDraft {\n assigneeName: string;\n category: TicketCategory;\n channel: TicketChannel;\n health: TicketHealth;\n priority: TicketPriority;\n status: TicketStatus;\n subject: string;\n tags: string[];\n}\n\ninterface TicketDetailDrawerProps {\n assigneeOptions: TicketAssignee[];\n availableTags: string[];\n onClose: () => void;\n onSave: (id: string, patch: Partial) => void;\n ticket: Ticket | null;\n}\n\nfunction toDraft(ticket: Ticket): TicketDraft {\n return {\n subject: ticket.subject,\n status: ticket.status,\n priority: ticket.priority,\n category: ticket.category,\n channel: ticket.channel,\n health: ticket.health,\n assigneeName: ticket.assignee?.name ?? UNASSIGNED,\n tags: ticket.tags ?? [],\n };\n}\n\nexport function TicketDetailDrawer({\n ticket,\n assigneeOptions,\n availableTags,\n onClose,\n onSave,\n}: TicketDetailDrawerProps) {\n const [draft, setDraft] = useState(\n ticket ? toDraft(ticket) : null\n );\n const [tagInput, setTagInput] = useState(\"\");\n const tagsAnchor = useComboboxAnchor();\n\n // Reseed the draft whenever a different ticket is opened.\n useEffect(() => {\n if (ticket) {\n setDraft(toDraft(ticket));\n } else {\n setDraft(null);\n }\n }, [ticket]);\n\n const handleSave = () => {\n if (!(ticket && draft)) {\n return;\n }\n const assignee =\n draft.assigneeName === UNASSIGNED\n ? undefined\n : assigneeOptions.find((a) => a.name === draft.assigneeName);\n onSave(ticket.id, {\n subject: draft.subject.trim() || ticket.subject,\n status: draft.status,\n priority: draft.priority,\n category: draft.category,\n channel: draft.channel,\n health: draft.health,\n assignee,\n tags: draft.tags,\n });\n onClose();\n };\n\n return (\n {\n if (!open) {\n onClose();\n }\n }}\n open={Boolean(ticket)}\n swipeDirection=\"right\"\n >\n \n {ticket && draft ? (\n <>\n \n \n \n \n
\n \n {ticket.subject}\n \n
\n \n {ticket.ticketNumber}\n \n \n \n \n }\n />\n
\n\n
\n \n \n \n Ticket name\n \n \n setDraft((d) =>\n d ? { ...d, subject: event.target.value } : d\n )\n }\n value={draft.subject}\n />\n \n\n \n Priority\n {\n if (value) {\n setDraft((d) =>\n d ? { ...d, priority: value as TicketPriority } : d\n );\n }\n }}\n value={draft.priority}\n >\n \n \n \n \n \n {PRIORITY_OPTIONS.map((option) => (\n \n \n \n ))}\n \n \n \n \n\n
\n \n Status\n {\n if (value) {\n setDraft((d) =>\n d ? { ...d, status: value as TicketStatus } : d\n );\n }\n }}\n value={draft.status}\n >\n \n \n \n \n \n {STATUS_OPTIONS.map((option) => (\n \n {statusLabels[option]}\n \n ))}\n \n \n \n \n\n \n Health\n {\n if (value) {\n setDraft((d) =>\n d ? { ...d, health: value as TicketHealth } : d\n );\n }\n }}\n value={draft.health}\n >\n \n \n \n \n \n {HEALTH_OPTIONS.map((option) => (\n \n {healthLabels[option]}\n \n ))}\n \n \n \n \n
\n\n
\n \n Category\n {\n if (value) {\n setDraft((d) =>\n d ? { ...d, category: value as TicketCategory } : d\n );\n }\n }}\n value={draft.category}\n >\n \n \n \n \n \n {CATEGORY_OPTIONS.map((option) => (\n \n {categoryLabels[option]}\n \n ))}\n \n \n \n \n\n \n Channel\n {\n if (value) {\n setDraft((d) =>\n d ? { ...d, channel: value as TicketChannel } : d\n );\n }\n }}\n value={draft.channel}\n >\n \n \n \n \n \n {CHANNEL_OPTIONS.map((option) => (\n \n {channelLabels[option]}\n \n ))}\n \n \n \n \n
\n\n \n Tags\n {\n setDraft((d) => (d ? { ...d, tags: next } : d));\n setTagInput(\"\");\n }}\n tagsAnchor={tagsAnchor}\n value={draft.tags}\n />\n \n\n \n Assignee\n {\n if (value) {\n setDraft((d) =>\n d ? { ...d, assigneeName: value } : d\n );\n }\n }}\n value={draft.assigneeName}\n >\n \n \n \n \n \n Unassigned\n {assigneeOptions.map((option) => (\n \n {option.name}\n \n ))}\n \n \n \n \n
\n
\n\n \n Cancel} />\n \n \n \n ) : null}\n
\n \n );\n}\n\ninterface TagsComboboxProps {\n availableTags: string[];\n inputValue: string;\n onInputValueChange: (next: string) => void;\n onValueChange: (next: string[]) => void;\n tagsAnchor: ReturnType;\n value: string[];\n}\n\nfunction TagsCombobox({\n availableTags,\n inputValue,\n onInputValueChange,\n onValueChange,\n tagsAnchor,\n value,\n}: TagsComboboxProps) {\n const trimmed = inputValue.trim();\n const lower = trimmed.toLowerCase();\n const exactMatch = availableTags.some((t) => t.toLowerCase() === lower);\n const items =\n trimmed && !exactMatch ? [...availableTags, trimmed] : availableTags;\n\n return (\n onValueChange(next as string[])}\n value={value}\n >\n \n \n {(values: string[]) => (\n <>\n {values.map((tag) => (\n {tag}\n ))}\n \n \n )}\n \n \n \n \n {(item: string) => {\n const isCreate = !availableTags.includes(item);\n return (\n \n {isCreate && }\n \n {isCreate ? (\n \n \n Add "{item}"\n \n ) : (\n item\n )}\n \n \n );\n }}\n \n \n \n );\n}\n", "type": "registry:component" }, { "path": "registry/default/blocks/tickets-01/components/new-ticket-modal.tsx", "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport {\n categoryLabels,\n channelLabels,\n priorityLabels,\n statusLabels,\n type Ticket,\n type TicketAssignee,\n type TicketCategory,\n type TicketChannel,\n type TicketPriority,\n type TicketStatus,\n} from \"@/components/mock-tickets\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n Field,\n FieldDescription,\n FieldGroup,\n FieldLabel,\n} from \"@/components/ui/field\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n Modal,\n ModalClose,\n ModalContent,\n ModalDescription,\n ModalFooter,\n ModalHeader,\n ModalTitle,\n} from \"@/components/ui/modal\";\nimport {\n Select,\n SelectContent,\n SelectGroup,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"@/components/ui/select\";\n\ntype NewTicketDraft = Omit & {\n assigneeName?: string;\n};\n\ninterface NewTicketModalProps {\n assigneeOptions: TicketAssignee[];\n onOpenChange: (open: boolean) => void;\n onSubmit: (draft: Omit) => void;\n open: boolean;\n}\n\nconst initialDraft: NewTicketDraft = {\n subject: \"\",\n status: \"open\",\n priority: \"medium\",\n category: \"other\",\n channel: \"email\",\n};\n\nexport function NewTicketModal({\n open,\n onOpenChange,\n assigneeOptions,\n onSubmit,\n}: NewTicketModalProps) {\n const [draft, setDraft] = useState(initialDraft);\n const [touched, setTouched] = useState(false);\n\n const handleSubmit = (event: React.FormEvent) => {\n event.preventDefault();\n setTouched(true);\n if (draft.subject.trim().length === 0) {\n return;\n }\n\n const assignee = draft.assigneeName\n ? assigneeOptions.find((a) => a.name === draft.assigneeName)\n : undefined;\n\n onSubmit({\n subject: draft.subject.trim(),\n status: draft.status,\n priority: draft.priority,\n category: draft.category,\n channel: draft.channel,\n assignee,\n });\n\n setDraft(initialDraft);\n setTouched(false);\n onOpenChange(false);\n };\n\n const handleOpenChange = (next: boolean) => {\n if (!next) {\n setDraft(initialDraft);\n setTouched(false);\n }\n onOpenChange(next);\n };\n\n return (\n \n \n \n New ticket\n \n Capture an incoming request. You can edit details later.\n \n \n\n
\n \n \n Subject\n \n setDraft((d) => ({ ...d, subject: event.target.value }))\n }\n placeholder=\"Short summary of the issue\"\n value={draft.subject}\n />\n {touched && draft.subject.trim().length === 0 ? (\n \n Subject is required.\n \n ) : null}\n \n\n
\n \n Status\n \n setDraft((d) => ({\n ...d,\n status: value as TicketStatus,\n }))\n }\n value={draft.status}\n >\n \n \n \n \n \n {([\"open\", \"pending\", \"resolved\", \"closed\"] as const).map(\n (option) => (\n \n {statusLabels[option]}\n \n )\n )}\n \n \n \n \n\n \n Priority\n \n setDraft((d) => ({\n ...d,\n priority: value as TicketPriority,\n }))\n }\n value={draft.priority}\n >\n \n \n \n \n \n {(\n [\"urgent\", \"high\", \"medium\", \"low\", \"todo\"] as const\n ).map((option) => (\n \n {priorityLabels[option]}\n \n ))}\n \n \n \n \n\n \n Category\n \n setDraft((d) => ({\n ...d,\n category: value as TicketCategory,\n }))\n }\n value={draft.category}\n >\n \n \n \n \n \n {(\n [\n \"billing\",\n \"technical\",\n \"access\",\n \"subscription\",\n \"other\",\n ] as const\n ).map((option) => (\n \n {categoryLabels[option]}\n \n ))}\n \n \n \n \n\n \n Channel\n \n setDraft((d) => ({\n ...d,\n channel: value as TicketChannel,\n }))\n }\n value={draft.channel}\n >\n \n \n \n \n \n {([\"email\", \"chat\", \"slack\"] as const).map((option) => (\n \n {channelLabels[option]}\n \n ))}\n \n \n \n \n
\n\n \n Assignee\n \n setDraft((d) => ({\n ...d,\n assigneeName:\n value === \"__unassigned\" || value == null\n ? undefined\n : (value as string),\n }))\n }\n value={draft.assigneeName ?? \"__unassigned\"}\n >\n \n \n \n \n \n Unassigned\n {assigneeOptions.map((option) => (\n \n {option.name}\n \n ))}\n \n \n \n \n
\n\n \n }>\n Cancel\n \n \n \n
\n
\n
\n );\n}\n", "type": "registry:component" }, { "path": "registry/default/blocks/tickets-01/components/delete-tickets-dialog.tsx", "content": "\"use client\";\n\nimport {\n AlertDialog,\n AlertDialogAction,\n AlertDialogCancel,\n AlertDialogContent,\n AlertDialogDescription,\n AlertDialogFooter,\n AlertDialogHeader,\n AlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\n\ninterface DeleteTicketsDialogProps {\n count: number;\n onConfirm: () => void;\n onOpenChange: (open: boolean) => void;\n open: boolean;\n}\n\nexport function DeleteTicketsDialog({\n open,\n onOpenChange,\n count,\n onConfirm,\n}: DeleteTicketsDialogProps) {\n return (\n \n \n \n \n Delete {count} {count === 1 ? \"ticket\" : \"tickets\"}?\n \n \n This permanently removes the selected{\" \"}\n {count === 1 ? \"ticket\" : \"tickets\"}. This action cannot be undone.\n \n \n \n Cancel\n {\n onConfirm();\n onOpenChange(false);\n }}\n variant=\"destructive\"\n >\n Delete\n \n \n \n \n );\n}\n", "type": "registry:component" }, { "path": "registry/default/blocks/tickets-01/components/use-cell-editing.ts", "content": "\"use client\";\n\nimport { useCallback, useState } from \"react\";\n\nexport interface EditingCell {\n columnId: string;\n rowId: string;\n}\n\nexport function useCellEditing() {\n const [editingCell, setEditingCell] = useState(null);\n const [draftValue, setDraftValue] = useState(\"\");\n\n const startEdit = useCallback((cell: EditingCell, initialValue: string) => {\n setEditingCell(cell);\n setDraftValue(initialValue);\n }, []);\n\n const cancel = useCallback(() => {\n setEditingCell(null);\n setDraftValue(\"\");\n }, []);\n\n const isEditing = useCallback(\n (rowId: string, columnId: string) =>\n editingCell?.rowId === rowId && editingCell?.columnId === columnId,\n [editingCell]\n );\n\n return {\n editingCell,\n draftValue,\n setDraftValue,\n startEdit,\n cancel,\n isEditing,\n };\n}\n", "type": "registry:component" }, { "path": "registry/default/blocks/tickets-01/components/ticket-priority-cell.tsx", "content": "\"use client\";\n\nimport {\n ArrowDownIcon,\n ArrowUpIcon,\n CircleDashedIcon,\n MinusIcon,\n} from \"lucide-react\";\nimport { priorityLabels, type TicketPriority } from \"@/components/mock-tickets\";\nimport { cn } from \"@/lib/utils\";\n\nconst priorityStyles: Record<\n TicketPriority,\n { icon: React.ComponentType<{ className?: string }>; tone: string }\n> = {\n urgent: { icon: ArrowUpIcon, tone: \"text-rose-500 dark:text-rose-400\" },\n high: { icon: ArrowUpIcon, tone: \"text-orange-500 dark:text-orange-400\" },\n medium: { icon: MinusIcon, tone: \"text-muted-foreground\" },\n low: { icon: ArrowDownIcon, tone: \"text-sky-500 dark:text-sky-400\" },\n todo: { icon: CircleDashedIcon, tone: \"text-muted-foreground\" },\n};\n\nexport function TicketPriorityCell({\n priority,\n className,\n}: {\n priority: TicketPriority;\n className?: string;\n}) {\n const { icon: Icon, tone } = priorityStyles[priority];\n return (\n \n \n {priorityLabels[priority]}\n \n );\n}\n", "type": "registry:component" }, { "path": "registry/default/blocks/tickets-01/components/ticket-status-badge.tsx", "content": "\"use client\";\n\nimport { statusLabels, type TicketStatus } from \"@/components/mock-tickets\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { cn } from \"@/lib/utils\";\n\nconst statusStyles: Record = {\n open: \"border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300\",\n pending:\n \"border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300\",\n resolved:\n \"border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300\",\n closed: \"border-zinc-500/30 bg-zinc-500/10 text-zinc-700 dark:text-zinc-300\",\n};\n\nexport function TicketStatusBadge({ status }: { status: TicketStatus }) {\n return (\n \n {statusLabels[status]}\n \n );\n}\n", "type": "registry:component" }, { "path": "registry/default/blocks/tickets-01/components/ticket-health-badge.tsx", "content": "\"use client\";\n\nimport { healthLabels, type TicketHealth } from \"@/components/mock-tickets\";\nimport { cn } from \"@/lib/utils\";\n\nconst healthStyles: Record = {\n \"on-track\": \"text-emerald-600 dark:text-emerald-400\",\n warning: \"text-amber-600 dark:text-amber-400\",\n breached: \"text-rose-600 dark:text-rose-400\",\n};\n\nconst healthDots: Record = {\n \"on-track\": \"bg-emerald-500\",\n warning: \"bg-amber-500\",\n breached: \"bg-rose-500\",\n};\n\nexport function TicketHealthBadge({ health }: { health: TicketHealth }) {\n return (\n \n \n {healthLabels[health]}\n \n );\n}\n", "type": "registry:component" }, { "path": "registry/default/blocks/tickets-01/components/ticket-channel-cell.tsx", "content": "\"use client\";\n\nimport { HashIcon, MailIcon, MessageSquareIcon } from \"lucide-react\";\nimport { channelLabels, type TicketChannel } from \"@/components/mock-tickets\";\nimport { cn } from \"@/lib/utils\";\n\nconst channelIcons: Record<\n TicketChannel,\n React.ComponentType<{ className?: string }>\n> = {\n email: MailIcon,\n chat: MessageSquareIcon,\n slack: HashIcon,\n};\n\nexport function TicketChannelCell({\n channel,\n className,\n}: {\n channel: TicketChannel;\n className?: string;\n}) {\n const Icon = channelIcons[channel];\n return (\n \n \n {channelLabels[channel]}\n \n );\n}\n", "type": "registry:component" }, { "path": "registry/default/blocks/tickets-01/components/mock-tickets.ts", "content": "export type TicketStatus = \"open\" | \"pending\" | \"resolved\" | \"closed\";\nexport type TicketPriority = \"urgent\" | \"high\" | \"medium\" | \"low\" | \"todo\";\nexport type TicketHealth = \"on-track\" | \"warning\" | \"breached\";\nexport type TicketChannel = \"email\" | \"chat\" | \"slack\";\nexport type TicketCategory =\n | \"billing\"\n | \"technical\"\n | \"access\"\n | \"subscription\"\n | \"other\";\n\nexport interface TicketAssignee {\n avatarUrl?: string;\n name: string;\n}\n\nexport interface Ticket {\n assignee?: TicketAssignee;\n category: TicketCategory;\n channel: TicketChannel;\n health: TicketHealth;\n id: string;\n priority: TicketPriority;\n status: TicketStatus;\n subject: string;\n tags?: string[];\n ticketNumber: string;\n}\n\n/** Seed tag pool used by the detail-drawer combobox before any ticket has tags. */\nexport const SEED_TAGS = [\n \"billing\",\n \"regression\",\n \"customer\",\n \"p0\",\n \"follow-up\",\n \"needs-review\",\n];\n\nexport const ticketAssignees: TicketAssignee[] = [\n { name: \"Jason Duong\" },\n { name: \"Annie Nguyen\" },\n { name: \"Lam Tran\" },\n { name: \"Nhi Pham\" },\n { name: \"Thanh Le\" },\n { name: \"Minh Ho\" },\n { name: \"Bao Truong\" },\n];\n\nexport const statusLabels: Record = {\n open: \"Open\",\n pending: \"Pending\",\n resolved: \"Resolved\",\n closed: \"Closed\",\n};\n\nexport const priorityLabels: Record = {\n urgent: \"Urgent\",\n high: \"High\",\n medium: \"Medium\",\n low: \"Low\",\n todo: \"Todo\",\n};\n\nexport const healthLabels: Record = {\n \"on-track\": \"On track\",\n warning: \"Warning\",\n breached: \"Breached\",\n};\n\nexport const channelLabels: Record = {\n email: \"Email\",\n chat: \"Chat\",\n slack: \"Slack\",\n};\n\nexport const categoryLabels: Record = {\n billing: \"Billing\",\n technical: \"Technical\",\n access: \"Access\",\n subscription: \"Subscription\",\n other: \"Other\",\n};\n\nexport const initialTickets: Ticket[] = [\n {\n id: \"t-007\",\n ticketNumber: \"#-007\",\n subject: \"Close out unused seats after the regional team restructure\",\n status: \"closed\",\n priority: \"todo\",\n assignee: { name: \"Lam Tran\" },\n category: \"other\",\n channel: \"chat\",\n health: \"on-track\",\n },\n {\n id: \"t-017\",\n ticketNumber: \"#-017\",\n subject: \"Broken help article is slowing onboarding conversion\",\n status: \"closed\",\n priority: \"todo\",\n assignee: { name: \"Nhi Pham\" },\n category: \"other\",\n channel: \"chat\",\n health: \"on-track\",\n },\n {\n id: \"t-020\",\n ticketNumber: \"#-020\",\n subject: \"VAT breakdown per region is needed for the renewal review\",\n status: \"closed\",\n priority: \"todo\",\n assignee: { name: \"Thanh Le\" },\n category: \"billing\",\n channel: \"email\",\n health: \"on-track\",\n },\n {\n id: \"t-001\",\n ticketNumber: \"#-001\",\n subject: \"Low activation in week-one onboarding cohort for Pine & Peak\",\n status: \"open\",\n priority: \"high\",\n assignee: { name: \"Jason Duong\" },\n category: \"technical\",\n channel: \"email\",\n health: \"warning\",\n tags: [\"customer\", \"follow-up\"],\n },\n {\n id: \"t-002\",\n ticketNumber: \"#-002\",\n subject: \"Expansion review blocked by delayed Salesforce health sync\",\n status: \"open\",\n priority: \"urgent\",\n assignee: { name: \"Jason Duong\" },\n category: \"technical\",\n channel: \"slack\",\n health: \"on-track\",\n },\n {\n id: \"t-004\",\n ticketNumber: \"#-004\",\n subject: \"Executive business review needs clearer API export guidance\",\n status: \"open\",\n priority: \"medium\",\n assignee: { name: \"Annie Nguyen\" },\n category: \"technical\",\n channel: \"email\",\n health: \"on-track\",\n },\n {\n id: \"t-005\",\n ticketNumber: \"#-005\",\n subject: \"Renewal prep needs invoice usage breakdown by workspace\",\n status: \"open\",\n priority: \"medium\",\n category: \"billing\",\n channel: \"chat\",\n health: \"warning\",\n },\n {\n id: \"t-008\",\n ticketNumber: \"#-008\",\n subject: \"Champion cannot reset admin access before the training session\",\n status: \"open\",\n priority: \"urgent\",\n category: \"access\",\n channel: \"chat\",\n health: \"warning\",\n },\n {\n id: \"t-012\",\n ticketNumber: \"#-012\",\n subject: \"Push notification drop is hurting mobile adoption metrics\",\n status: \"open\",\n priority: \"urgent\",\n assignee: { name: \"Jason Duong\" },\n category: \"technical\",\n channel: \"slack\",\n health: \"breached\",\n },\n {\n id: \"t-014\",\n ticketNumber: \"#-014\",\n subject: \"SLA export timezone mismatch is affecting QBR reporting\",\n status: \"open\",\n priority: \"high\",\n assignee: { name: \"Bao Truong\" },\n category: \"technical\",\n channel: \"email\",\n health: \"warning\",\n },\n {\n id: \"t-016\",\n ticketNumber: \"#-016\",\n subject: \"Domain migration left the new workspace owner unverified\",\n status: \"open\",\n priority: \"urgent\",\n category: \"access\",\n channel: \"slack\",\n health: \"breached\",\n },\n {\n id: \"t-003\",\n ticketNumber: \"#-003\",\n subject: \"Need admin ownership model before enterprise rollout\",\n status: \"pending\",\n priority: \"high\",\n assignee: { name: \"Jason Duong\" },\n category: \"access\",\n channel: \"email\",\n health: \"breached\",\n },\n {\n id: \"t-009\",\n ticketNumber: \"#-009\",\n subject:\n \"CSV import adoption stalled for the multilingual customer segment\",\n status: \"pending\",\n priority: \"high\",\n assignee: { name: \"Nhi Pham\" },\n category: \"technical\",\n channel: \"email\",\n health: \"warning\",\n },\n {\n id: \"t-011\",\n ticketNumber: \"#-011\",\n subject: \"Finance review asks for invoice split by cost center\",\n status: \"pending\",\n priority: \"medium\",\n assignee: { name: \"Thanh Le\" },\n category: \"billing\",\n channel: \"chat\",\n health: \"warning\",\n },\n {\n id: \"t-015\",\n ticketNumber: \"#-015\",\n subject: \"Plan phased seat expansion for the next quarter launch\",\n status: \"pending\",\n priority: \"medium\",\n assignee: { name: \"Lam Tran\" },\n category: \"subscription\",\n channel: \"chat\",\n health: \"on-track\",\n },\n {\n id: \"t-018\",\n ticketNumber: \"#-018\",\n subject: \"Need audit log retention options for the enterprise rollout\",\n status: \"pending\",\n priority: \"high\",\n assignee: { name: \"Jason Duong\" },\n category: \"subscription\",\n channel: \"email\",\n health: \"warning\",\n },\n {\n id: \"t-006\",\n ticketNumber: \"#-006\",\n subject: \"Customer asks for a rollout plan for conditional workflows\",\n status: \"resolved\",\n priority: \"low\",\n assignee: { name: \"Jason Duong\" },\n category: \"subscription\",\n channel: \"chat\",\n health: \"on-track\",\n },\n {\n id: \"t-010\",\n ticketNumber: \"#-010\",\n subject: \"Need a credit summary for the canceled onboarding add-on\",\n status: \"resolved\",\n priority: \"low\",\n assignee: { name: \"Minh Ho\" },\n category: \"billing\",\n channel: \"email\",\n health: \"on-track\",\n },\n {\n id: \"t-013\",\n ticketNumber: \"#-013\",\n subject: \"Seat transfer request between subsidiaries before renewal\",\n status: \"resolved\",\n priority: \"medium\",\n assignee: { name: \"Annie Nguyen\" },\n category: \"subscription\",\n channel: \"email\",\n health: \"on-track\",\n },\n {\n id: \"t-019\",\n ticketNumber: \"#-019\",\n subject: \"Intercom sync duplicates are skewing lifecycle reporting\",\n status: \"resolved\",\n priority: \"low\",\n assignee: { name: \"Minh Ho\" },\n category: \"technical\",\n channel: \"slack\",\n health: \"on-track\",\n },\n];\n", "type": "registry:component" } ], "categories": [ "data" ], "type": "registry:block" }