From 56916c27275992aeb0b62972d3e7a15b988d56bf Mon Sep 17 00:00:00 2001 From: Sofia-Freitas54 Date: Tue, 12 May 2026 16:51:41 -0300 Subject: [PATCH 01/12] feat(ServiceOrders): Add service order table with filters, summary, and pagination --- .../[subdomain]/service-orders/[id]/page.tsx | 355 +++++++ .../[subdomain]/service-orders/page.tsx | 20 +- apps/web/components.json | 4 +- .../components/dashboard/account-popover.tsx | 2 +- apps/web/components/dashboard/dash-link.tsx | 10 - .../dashboard/employees/data-table.tsx | 2 +- .../dashboard/service-order/dash-link.tsx | 24 + .../service-order/service-order-filter.tsx | 172 ++++ .../service-order/service-order-table.tsx | 218 +++++ .../components/dashboard/sidebar/header.tsx | 2 +- apps/web/components/ui/calendar.tsx | 220 +++++ apps/web/components/ui/command.tsx | 184 ++++ .../ui/data-table-advanced-toolbar.tsx | 36 + .../ui/data-table-column-header.tsx | 99 ++ .../components/ui/data-table-date-filter.tsx | 225 +++++ .../ui/data-table-faceted-filter.tsx | 194 ++++ .../components/ui/data-table-filter-list.tsx | 837 +++++++++++++++++ .../components/ui/data-table-filter-menu.tsx | 882 ++++++++++++++++++ .../components/ui/data-table-pagination.tsx | 108 +++ .../components/ui/data-table-range-filter.tsx | 122 +++ .../web/components/ui/data-table-skeleton.tsx | 115 +++ .../ui/data-table-slider-filter.tsx | 256 +++++ .../components/ui/data-table-sort-list.tsx | 405 ++++++++ apps/web/components/ui/data-table-toolbar.tsx | 148 +++ .../components/ui/data-table-view-options.tsx | 89 ++ apps/web/components/ui/data-table.tsx | 103 ++ apps/web/components/ui/faceted.tsx | 283 ++++++ .../ui/service-order-status-badge.tsx | 50 + apps/web/components/ui/slider.tsx | 63 ++ apps/web/components/ui/sortable.tsx | 581 ++++++++++++ apps/web/components/ui/timeline.tsx | 712 ++++++++++++++ apps/web/config/data-table.ts | 82 ++ apps/web/hooks/use-callback-ref.ts | 27 + apps/web/hooks/use-data-table.ts | 345 +++++++ apps/web/hooks/use-debounced-callback.ts | 28 + .../web/hooks/use-isomorphic-layout-effect.ts | 6 + apps/web/hooks/use-lazy-ref.ts | 13 + apps/web/lib/compose-refs.ts | 62 ++ apps/web/lib/data-table.ts | 79 ++ apps/web/lib/format.ts | 19 + apps/web/lib/id.ts | 29 + apps/web/lib/parsers.ts | 104 +++ apps/web/lib/utils/service-orders.ts | 44 + apps/web/package.json | 10 +- apps/web/types/data-table.ts | 53 ++ bun.lock | 813 ++++++++-------- package.json | 4 + packages/constants/src/service-orders.ts | 40 + packages/mock/package.json | 3 + packages/mock/src/index.ts | 18 + packages/mock/src/options/filter-order.ts | 61 ++ packages/mock/src/options/table-order.ts | 290 ++++++ packages/schemas/src/service-orders.ts | 2 +- 53 files changed, 8251 insertions(+), 402 deletions(-) create mode 100644 apps/web/app/(protected)/dashboard/[subdomain]/service-orders/[id]/page.tsx delete mode 100644 apps/web/components/dashboard/dash-link.tsx create mode 100644 apps/web/components/dashboard/service-order/dash-link.tsx create mode 100644 apps/web/components/dashboard/service-order/service-order-filter.tsx create mode 100644 apps/web/components/dashboard/service-order/service-order-table.tsx create mode 100644 apps/web/components/ui/calendar.tsx create mode 100644 apps/web/components/ui/command.tsx create mode 100644 apps/web/components/ui/data-table-advanced-toolbar.tsx create mode 100644 apps/web/components/ui/data-table-column-header.tsx create mode 100644 apps/web/components/ui/data-table-date-filter.tsx create mode 100644 apps/web/components/ui/data-table-faceted-filter.tsx create mode 100644 apps/web/components/ui/data-table-filter-list.tsx create mode 100644 apps/web/components/ui/data-table-filter-menu.tsx create mode 100644 apps/web/components/ui/data-table-pagination.tsx create mode 100644 apps/web/components/ui/data-table-range-filter.tsx create mode 100644 apps/web/components/ui/data-table-skeleton.tsx create mode 100644 apps/web/components/ui/data-table-slider-filter.tsx create mode 100644 apps/web/components/ui/data-table-sort-list.tsx create mode 100644 apps/web/components/ui/data-table-toolbar.tsx create mode 100644 apps/web/components/ui/data-table-view-options.tsx create mode 100644 apps/web/components/ui/data-table.tsx create mode 100644 apps/web/components/ui/faceted.tsx create mode 100644 apps/web/components/ui/service-order-status-badge.tsx create mode 100644 apps/web/components/ui/slider.tsx create mode 100644 apps/web/components/ui/sortable.tsx create mode 100644 apps/web/components/ui/timeline.tsx create mode 100644 apps/web/config/data-table.ts create mode 100644 apps/web/hooks/use-callback-ref.ts create mode 100644 apps/web/hooks/use-data-table.ts create mode 100644 apps/web/hooks/use-debounced-callback.ts create mode 100644 apps/web/hooks/use-isomorphic-layout-effect.ts create mode 100644 apps/web/hooks/use-lazy-ref.ts create mode 100644 apps/web/lib/compose-refs.ts create mode 100644 apps/web/lib/data-table.ts create mode 100644 apps/web/lib/format.ts create mode 100644 apps/web/lib/id.ts create mode 100644 apps/web/lib/parsers.ts create mode 100644 apps/web/lib/utils/service-orders.ts create mode 100644 apps/web/types/data-table.ts create mode 100644 packages/constants/src/service-orders.ts create mode 100644 packages/mock/src/options/filter-order.ts create mode 100644 packages/mock/src/options/table-order.ts diff --git a/apps/web/app/(protected)/dashboard/[subdomain]/service-orders/[id]/page.tsx b/apps/web/app/(protected)/dashboard/[subdomain]/service-orders/[id]/page.tsx new file mode 100644 index 0000000..cb52e88 --- /dev/null +++ b/apps/web/app/(protected)/dashboard/[subdomain]/service-orders/[id]/page.tsx @@ -0,0 +1,355 @@ +import { mockServiceOrders } from "@fixr/mock"; +import { + ArrowLeft, + CheckCircle, + ClipboardList, + Clock, + DollarSign, + FileUser, + History, + Image as ImageIcon, + MoreHorizontal, + Package, + Pencil, + Phone, + User, + UserCheck, + Wrench, + XCircle, +} from "lucide-react"; +import { notFound } from "next/navigation"; +import { DashLink } from "@/components/dashboard/service-order/dash-link"; +import { Button } from "@/components/ui/button"; +import { ServiceOrderStatusBadge } from "@/components/ui/service-order-status-badge"; +import { + Timeline, + TimelineConnector, + TimelineContent, + TimelineDescription, + TimelineDot, + TimelineHeader, + TimelineItem, + TimelineTime, + TimelineTitle, +} from "@/components/ui/timeline"; +import { + getStatusClass, + getStatusClassTable, + type ServiceOrderStatusId, +} from "@/lib/utils/service-orders"; + +type Params = Promise<{ subdomain: string; id: string }>; + +function getStatusIcon(statusId: string) { + switch (statusId) { + case "parts_pending": + return Package; + case "analysis": + return ClipboardList; + case "finished": + return CheckCircle; + case "canceled": + return XCircle; + case "quote_pending": + return DollarSign; + case "approval_pending": + return Clock; + case "in_progress": + return Wrench; + case "ready_for_pickup": + return UserCheck; + case "contacted": + return Phone; + default: + return MoreHorizontal; + } +} + +function getTimelineConnectorBg(statusId: string) { + const classes = getStatusClassTable(statusId as ServiceOrderStatusId); + // pega a classe de bg-* do retorno (fallback transparente) + return ( + classes.split(" ").find((c) => c.startsWith("bg-")) ?? "bg-transparent" + ); +} + +interface HistoryStatus { + id: string; + label: string; +} + +interface HistoryItem { + id: string; + status: HistoryStatus; + dateTime: string; + comment: string; +} + +interface HistoryGroup { + status: HistoryStatus; + entries: HistoryItem[]; +} + +function groupHistoryByStatus(history: HistoryItem[]): HistoryGroup[] { + return history.reduce((acc, item) => { + const lastGroup = acc.at(-1); + + if (lastGroup && lastGroup.status.id === item.status.id) { + lastGroup.entries.push(item); + return acc; + } + + acc.push({ + status: item.status, + entries: [item], + }); + + return acc; + }, []); +} + +export default async function ServiceOrderDetailsPage({ + params, +}: { + params: Params; +}) { + const { subdomain, id } = await params; + + const order = mockServiceOrders.find((item) => item.id === id); + + if (!order) { + notFound(); + } + + const groupedHistory = groupHistoryByStatus( + order.history.map((item) => ({ + id: item.id, + status: { + id: item.status, + label: item.label, + }, + dateTime: item.dateTime, + comment: item.comment, + })) + ); + + return ( +
+
+ + +
+

+ Ordem de serviço N° {order.orderNumber} +

+

+ Detalhes completos da ordem de serviço. +

+
+
+ +
+
+
+ +

Resumo da ordem

+ +
+ +
+

+ Número: {order.orderNumber} +

+

+ Status:{" "} + + + {order.status.label} + + +

+

+ Linha: {order.line} +

+

+ Marca: {order.mark} +

+

+ Modelo: {order.model} +

+
+
+ +
+
+ +

Dados do técnico

+ +
+ +
+

+ Responsável:{" "} + {order.technician} +

+

+ Observações: {order.notes} +

+
+
+ +
+
+ +

Dados do cliente

+ +
+ +
+

+ Nome: {order.client.name} +

+

+ CPF: {order.client.cpf} +

+

+ Telefone:{" "} + {order.client.phone} +

+
+
+ +
+
+ +

Histórico de observações

+ +
+ + {groupedHistory.length > 0 ? ( + + {groupedHistory.map((group, idx) => { + const StatusIcon = getStatusIcon(group.status.id); + const isLast = idx === groupedHistory.length - 1; + const connectorBg = getTimelineConnectorBg(group.status.id); + + return ( + + + + + + {/* connector usa cor do status atual (mantém cor do anterior ao transitar) */} + {isLast ? ( + + ) : ( + + )} + + + + + {group.entries.at(-1)?.dateTime} + + {group.status.label} + + +
+ {group.entries.map((entry) => ( + + + {entry.dateTime} + + {entry.comment} + + ))} +
+
+
+ ); + })} +
+ ) : ( +

+ Nenhum histórico registrado. +

+ )} +
+ +
+
+ +

Peças necessárias para o reparo

+ +
+ +
+ {order.parts?.map((part) => ( + + {part} + + ))} +
+
+ +
+
+ +

Imagens do aparelho

+ +
+ + {order.images && order.images.length > 0 ? ( +
+ {order.images.map((image) => ( +
+ {/** biome-ignore lint/correctness/useImageSize: */} + {image.description} +

+ {image.description} +

+
+ ))} +
+ ) : ( +

+ Nenhuma imagem registrada. +

+ )} +
+
+
+ ); +} diff --git a/apps/web/app/(protected)/dashboard/[subdomain]/service-orders/page.tsx b/apps/web/app/(protected)/dashboard/[subdomain]/service-orders/page.tsx index 2989999..69b671c 100644 --- a/apps/web/app/(protected)/dashboard/[subdomain]/service-orders/page.tsx +++ b/apps/web/app/(protected)/dashboard/[subdomain]/service-orders/page.tsx @@ -1,6 +1,7 @@ import { Plus } from "lucide-react"; -import { DashLink } from "@/components/dashboard/dash-link"; import { Heading } from "@/components/dashboard/heading"; +import { DashLink } from "@/components/dashboard/service-order/dash-link"; +import { ServiceOrdersTable } from "@/components/dashboard/service-order/service-order-table"; import { Button } from "@/components/ui/button"; type Params = Promise<{ subdomain: string }>; @@ -18,11 +19,18 @@ export default async function ServiceOrdersPage({ description={"Controle as ordens de serviço de seus clientes"} title={"Ordens de serviço"} /> - + +
+ +
+ +
+ +
); } diff --git a/apps/web/components.json b/apps/web/components.json index b7b9791..fcfb3f8 100644 --- a/apps/web/components.json +++ b/apps/web/components.json @@ -18,5 +18,7 @@ "lib": "@/lib", "hooks": "@/hooks" }, - "registries": {} + "registries": { + "@diceui": "https://diceui.com/r/{name}.json" + } } diff --git a/apps/web/components/dashboard/account-popover.tsx b/apps/web/components/dashboard/account-popover.tsx index 136b5de..df4e83e 100644 --- a/apps/web/components/dashboard/account-popover.tsx +++ b/apps/web/components/dashboard/account-popover.tsx @@ -8,7 +8,7 @@ import { Avatar, type AvatarProps } from "../account/profile-avatar"; import { SignOutButton } from "../auth/signout-button"; import { Button } from "../ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; -import { DashLink } from "./dash-link"; +import { DashLink } from "./service-order/dash-link"; export function AccountPopover({ session, diff --git a/apps/web/components/dashboard/dash-link.tsx b/apps/web/components/dashboard/dash-link.tsx deleted file mode 100644 index 3b17266..0000000 --- a/apps/web/components/dashboard/dash-link.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import Link, { type LinkProps } from "next/link"; - -export function DashLink({ - subdomain, - href, - ...props -}: React.AnchorHTMLAttributes & - LinkProps & { subdomain: string }) { - return ; -} diff --git a/apps/web/components/dashboard/employees/data-table.tsx b/apps/web/components/dashboard/employees/data-table.tsx index df89b3d..68e977b 100644 --- a/apps/web/components/dashboard/employees/data-table.tsx +++ b/apps/web/components/dashboard/employees/data-table.tsx @@ -28,7 +28,7 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { DashLink } from "../dash-link"; +import { DashLink } from "../service-order/dash-link"; interface DataTableProps { columns: ColumnDef[]; diff --git a/apps/web/components/dashboard/service-order/dash-link.tsx b/apps/web/components/dashboard/service-order/dash-link.tsx new file mode 100644 index 0000000..59a40d7 --- /dev/null +++ b/apps/web/components/dashboard/service-order/dash-link.tsx @@ -0,0 +1,24 @@ +import Link from "next/link"; +import type { ComponentPropsWithoutRef, ReactNode } from "react"; + +type DashLinkProps = Omit, "href"> & { + href: string; + subdomain: string; + children: ReactNode; +}; + +export function DashLink({ + href, + subdomain, + children, + ...props +}: DashLinkProps) { + const normalizedHref = href.startsWith("/") ? href : `/${href}`; + const hrefWithSubdomain = `/dashboard/${subdomain}${normalizedHref}`; + + return ( + + {children} + + ); +} diff --git a/apps/web/components/dashboard/service-order/service-order-filter.tsx b/apps/web/components/dashboard/service-order/service-order-filter.tsx new file mode 100644 index 0000000..3eb729a --- /dev/null +++ b/apps/web/components/dashboard/service-order/service-order-filter.tsx @@ -0,0 +1,172 @@ +"use client"; + +import { getFilterOptions } from "@fixr/mock"; +import { useQuery } from "@tanstack/react-query"; +import { Calendar, Filter, Monitor, Tag, User } from "lucide-react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; + +export function ServiceOrderFilters() { + const [open, setOpen] = useState(false); + const [line, setLine] = useState(""); + const [technician, setTechnician] = useState(""); + const [status, setStatus] = useState(""); + const [date, setDate] = useState(""); + + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + //const queryClient = useQueryClient(); + + const { data: filters = { lines: [], technicians: [], statuses: [] } } = + useQuery({ + queryKey: ["filters"], + queryFn: () => getFilterOptions(), // TODO: replace with API call when ready + }); + + const applyFilters = () => { + const params = new URLSearchParams(searchParams?.toString() ?? ""); + + if (line) { + params.set("line", line); + } else { + params.delete("line"); + } + + if (technician) { + params.set("technician", technician); + } else { + params.delete("technician"); + } + + if (status) { + params.set("status", status); + } else { + params.delete("status"); + } + + if (date) { + params.set("date", date); + } else { + params.delete("date"); + } + + const query = params.toString(); + router.push(`${pathname}${query ? `?${query}` : ""}`); + setOpen(false); + }; + + return ( + + + + + + + + Filtros + + Aprimore sua busca com filtros específicos + + + +
+
+
+ +

Linha

+
+ +
+ +
+
+ +

Técnico

+
+ +
+ +
+
+ +

Status

+
+ +
+ +
+
+ +

Data

+
+ setDate(e.target.value)} + type="date" + value={date} + /> +
+ +
+ +
+
+
+
+ ); +} diff --git a/apps/web/components/dashboard/service-order/service-order-table.tsx b/apps/web/components/dashboard/service-order/service-order-table.tsx new file mode 100644 index 0000000..311b752 --- /dev/null +++ b/apps/web/components/dashboard/service-order/service-order-table.tsx @@ -0,0 +1,218 @@ +"use client"; + +import { mockServiceOrders, type ServiceOrderRow } from "@fixr/mock"; +import { + createColumnHelper, + getCoreRowModel, + getPaginationRowModel, + useReactTable, +} from "@tanstack/react-table"; +import { useMemo, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { DataTable } from "@/components/ui/data-table"; +import { Input } from "@/components/ui/input"; +import { DashLink } from "../service-order/dash-link"; + +const columnHelper = createColumnHelper(); + +interface Props { + subdomain: string; +} + +const STATUS_STYLE_MAP_TABLE = { + parts_pending: "bg-green-400 text-green-900", + analysis: "bg-green-400 text-green-900", + finished: "bg-blue-400 text-blue-900", + canceled: "bg-rose-400 text-rose-900", + quote_pending: "bg-green-400 text-green-900", + approval_pending: "bg-green-400 text-green-900", + in_progress: "bg-blue-400 text-blue-900", + ready_for_pickup: "bg-green-400 text-green-900", + contacted: "bg-gray-400 text-gray-900", +} as const; + +function getStatusClassTable(statusId: string) { + return ( + STATUS_STYLE_MAP_TABLE[statusId as keyof typeof STATUS_STYLE_MAP_TABLE] ?? + "bg-gray-100 text-gray-900" + ); +} + +export function ServiceOrdersTable({ subdomain }: Props) { + const [search, setSearch] = useState(""); + const [statusFilter, setStatusFilter] = useState(""); + const [lineFilter, setLineFilter] = useState(""); + const [techFilter, setTechFilter] = useState(""); + + // derive filter options from mock + const statusOptions = useMemo( + () => + Array.from( + new Map( + mockServiceOrders.map((s) => [ + s.status.id, + { id: s.status.id, label: s.status.label }, + ]) + ).values() + ), + [] + ); + const lineOptions = useMemo( + () => Array.from(new Set(mockServiceOrders.map((s) => s.line))), + [] + ); + const techOptions = useMemo( + () => Array.from(new Set(mockServiceOrders.map((s) => s.technician))), + [] + ); + + const filteredData = useMemo(() => { + return mockServiceOrders.filter((row) => { + if (search) { + const num = String(row.orderNumber).toLowerCase(); + if (!num.includes(search.toLowerCase())) { + return false; + } + } + if (statusFilter && row.status.id !== statusFilter) { + return false; + } + if (lineFilter && row.line !== lineFilter) { + return false; + } + if (techFilter && row.technician !== techFilter) { + return false; + } + return true; + }); + }, [search, statusFilter, lineFilter, techFilter]); + + const columns = useMemo( + () => [ + columnHelper.accessor("orderNumber", { + header: "Número da ordem", + cell: ({ getValue }) => ( + {getValue()} + ), + }), + columnHelper.accessor("line", { header: "Linha" }), + columnHelper.accessor("technician", { header: "Técnico responsável" }), + columnHelper.accessor("status", { + header: "Status", + cell: ({ row }) => { + const status = row.original.status; + return ( + + {status.label} + + ); + }, + }), + columnHelper.display({ + id: "actions", + header: () =>
Ações
, + cell: ({ row }) => ( +
+ +
+ ), + }), + ], + [subdomain] + ); + + const table = useReactTable({ + data: filteredData, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + initialState: { + pagination: { + pageIndex: 0, + pageSize: 10, + }, + }, + }); + + return ( +
+ {/* search + filters */} +
+
+ setSearch(e.target.value)} + placeholder="Busque pelo número da ordem de serviço..." + value={search} + /> + + + + + + +
+ +
+ +
+
+ + +
+ ); +} diff --git a/apps/web/components/dashboard/sidebar/header.tsx b/apps/web/components/dashboard/sidebar/header.tsx index 8ccfd14..3788e0e 100644 --- a/apps/web/components/dashboard/sidebar/header.tsx +++ b/apps/web/components/dashboard/sidebar/header.tsx @@ -3,7 +3,7 @@ import { useParams, usePathname } from "next/navigation"; import { Logo } from "@/components/svg/logo"; import { getDashboardRouteName } from "@/lib/utils/get-dashboard-route-name"; -import { DashLink } from "../dash-link"; +import { DashLink } from "../service-order/dash-link"; import { FloatingToggle } from "./floating-toggle"; export function Header() { diff --git a/apps/web/components/ui/calendar.tsx b/apps/web/components/ui/calendar.tsx new file mode 100644 index 0000000..4755145 --- /dev/null +++ b/apps/web/components/ui/calendar.tsx @@ -0,0 +1,220 @@ +"use client" + +import * as React from "react" +import { + ChevronDownIcon, + ChevronLeftIcon, + ChevronRightIcon, +} from "lucide-react" +import { + DayPicker, + getDefaultClassNames, + type DayButton, +} from "react-day-picker" + +import { cn } from "@/lib/utils" +import { Button, buttonVariants } from "@/components/ui/button" + +function Calendar({ + className, + classNames, + showOutsideDays = true, + captionLayout = "label", + buttonVariant = "ghost", + formatters, + components, + ...props +}: React.ComponentProps & { + buttonVariant?: React.ComponentProps["variant"] +}) { + const defaultClassNames = getDefaultClassNames() + + return ( + svg]:rotate-180`, + String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, + className + )} + captionLayout={captionLayout} + formatters={{ + formatMonthDropdown: (date) => + date.toLocaleString("default", { month: "short" }), + ...formatters, + }} + classNames={{ + root: cn("w-fit", defaultClassNames.root), + months: cn( + "relative flex flex-col gap-4 md:flex-row", + defaultClassNames.months + ), + month: cn("flex w-full flex-col gap-4", defaultClassNames.month), + nav: cn( + "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", + defaultClassNames.nav + ), + button_previous: cn( + buttonVariants({ variant: buttonVariant }), + "size-(--cell-size) p-0 select-none aria-disabled:opacity-50", + defaultClassNames.button_previous + ), + button_next: cn( + buttonVariants({ variant: buttonVariant }), + "size-(--cell-size) p-0 select-none aria-disabled:opacity-50", + defaultClassNames.button_next + ), + month_caption: cn( + "flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)", + defaultClassNames.month_caption + ), + dropdowns: cn( + "flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium", + defaultClassNames.dropdowns + ), + dropdown_root: cn( + "relative rounded-md border border-input shadow-xs has-focus:border-ring has-focus:ring-[3px] has-focus:ring-ring/50", + defaultClassNames.dropdown_root + ), + dropdown: cn( + "absolute inset-0 bg-popover opacity-0", + defaultClassNames.dropdown + ), + caption_label: cn( + "font-medium select-none", + captionLayout === "label" + ? "text-sm" + : "flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground", + defaultClassNames.caption_label + ), + table: "w-full border-collapse", + weekdays: cn("flex", defaultClassNames.weekdays), + weekday: cn( + "flex-1 rounded-md text-[0.8rem] font-normal text-muted-foreground select-none", + defaultClassNames.weekday + ), + week: cn("mt-2 flex w-full", defaultClassNames.week), + week_number_header: cn( + "w-(--cell-size) select-none", + defaultClassNames.week_number_header + ), + week_number: cn( + "text-[0.8rem] text-muted-foreground select-none", + defaultClassNames.week_number + ), + day: cn( + "group/day relative aspect-square h-full w-full p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-md", + props.showWeekNumber + ? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md" + : "[&:first-child[data-selected=true]_button]:rounded-l-md", + defaultClassNames.day + ), + range_start: cn( + "rounded-l-md bg-accent", + defaultClassNames.range_start + ), + range_middle: cn("rounded-none", defaultClassNames.range_middle), + range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end), + today: cn( + "rounded-md bg-accent text-accent-foreground data-[selected=true]:rounded-none", + defaultClassNames.today + ), + outside: cn( + "text-muted-foreground aria-selected:text-muted-foreground", + defaultClassNames.outside + ), + disabled: cn( + "text-muted-foreground opacity-50", + defaultClassNames.disabled + ), + hidden: cn("invisible", defaultClassNames.hidden), + ...classNames, + }} + components={{ + Root: ({ className, rootRef, ...props }) => { + return ( +
+ ) + }, + Chevron: ({ className, orientation, ...props }) => { + if (orientation === "left") { + return ( + + ) + } + + if (orientation === "right") { + return ( + + ) + } + + return ( + + ) + }, + DayButton: CalendarDayButton, + WeekNumber: ({ children, ...props }) => { + return ( + +
+ {children} +
+ + ) + }, + ...components, + }} + {...props} + /> + ) +} + +function CalendarDayButton({ + className, + day, + modifiers, + ...props +}: React.ComponentProps) { + const defaultClassNames = getDefaultClassNames() + + const ref = React.useRef(null) + React.useEffect(() => { + if (modifiers.focused) ref.current?.focus() + }, [modifiers.focused]) + + return ( + + + + {multiple ? ( + + ) : ( + + )} + + + ); +} diff --git a/apps/web/components/ui/data-table-faceted-filter.tsx b/apps/web/components/ui/data-table-faceted-filter.tsx new file mode 100644 index 0000000..47a7cbc --- /dev/null +++ b/apps/web/components/ui/data-table-faceted-filter.tsx @@ -0,0 +1,194 @@ +"use client"; + +import type { Column } from "@tanstack/react-table"; +import { Check, PlusCircle, XCircle } from "lucide-react"; +import * as React from "react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Separator } from "@/components/ui/separator"; +import { cn } from "@/lib/utils"; +type Option = { + value: string; + label: string; + icon?: React.ComponentType; + count?: number; +}; + +interface DataTableFacetedFilterProps { + column?: Column; + title?: string; + options: Option[]; + multiple?: boolean; +} + +export function DataTableFacetedFilter({ + column, + title, + options, + multiple, +}: DataTableFacetedFilterProps) { + const [open, setOpen] = React.useState(false); + + const columnFilterValue = column?.getFilterValue(); + const selectedValues = new Set( + Array.isArray(columnFilterValue) ? columnFilterValue : [], + ); + + const onItemSelect = React.useCallback( + (option: Option, isSelected: boolean) => { + if (!column) return; + + if (multiple) { + const newSelectedValues = new Set(selectedValues); + if (isSelected) { + newSelectedValues.delete(option.value); + } else { + newSelectedValues.add(option.value); + } + const filterValues = Array.from(newSelectedValues); + column.setFilterValue(filterValues.length ? filterValues : undefined); + } else { + column.setFilterValue(isSelected ? undefined : [option.value]); + setOpen(false); + } + }, + [column, multiple, selectedValues], + ); + + const onReset = React.useCallback( + (event?: React.MouseEvent) => { + event?.stopPropagation(); + column?.setFilterValue(undefined); + }, + [column], + ); + + return ( + + + + + + + + + No results found. + + {options.map((option) => { + const isSelected = selectedValues.has(option.value); + + return ( + onItemSelect(option, isSelected)} + > +
+ +
+ {option.icon && } + {option.label} + {option.count && ( + + {option.count} + + )} +
+ ); + })} +
+ {selectedValues.size > 0 && ( + <> + + + onReset()} + className="justify-center text-center" + > + Clear filters + + + + )} +
+
+
+
+ ); +} diff --git a/apps/web/components/ui/data-table-filter-list.tsx b/apps/web/components/ui/data-table-filter-list.tsx new file mode 100644 index 0000000..d5dcdec --- /dev/null +++ b/apps/web/components/ui/data-table-filter-list.tsx @@ -0,0 +1,837 @@ +"use client"; + +import type { Column, ColumnMeta, Table } from "@tanstack/react-table"; +import { + CalendarIcon, + Check, + ChevronsUpDown, + GripVertical, + ListFilter, + Trash2, +} from "lucide-react"; +import { parseAsStringEnum, useQueryState } from "nuqs"; +import * as React from "react"; + +import { DataTableRangeFilter } from "@/components/ui/data-table-range-filter"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Calendar } from "@/components/ui/calendar"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Faceted, + FacetedBadgeList, + FacetedContent, + FacetedEmpty, + FacetedGroup, + FacetedInput, + FacetedItem, + FacetedList, + FacetedTrigger, +} from "@/components/ui/faceted"; +import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Sortable, + SortableContent, + SortableItem, + SortableItemHandle, + SortableOverlay, +} from "@/components/ui/sortable"; +import { dataTableConfig } from "@/config/data-table"; +import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; +import { getDefaultFilterOperator, getFilterOperators } from "@/lib/data-table"; +import { formatDate } from "@/lib/format"; +import { generateId } from "@/lib/id"; +import { getFiltersStateParser } from "@/lib/parsers"; +import { cn } from "@/lib/utils"; +import type { + ExtendedColumnFilter, + FilterOperator, + JoinOperator, +} from "@/types/data-table"; + +const DEBOUNCE_MS = 300; +const THROTTLE_MS = 50; +const FILTER_SHORTCUT_KEY = "f"; +const REMOVE_FILTER_SHORTCUTS = ["backspace", "delete"]; + +interface DataTableFilterListProps + extends React.ComponentProps { + table: Table; + debounceMs?: number; + throttleMs?: number; + shallow?: boolean; + disabled?: boolean; +} + +export function DataTableFilterList({ + table, + debounceMs = DEBOUNCE_MS, + throttleMs = THROTTLE_MS, + shallow = true, + disabled, + ...props +}: DataTableFilterListProps) { + const id = React.useId(); + const labelId = React.useId(); + const descriptionId = React.useId(); + const [open, setOpen] = React.useState(false); + const addButtonRef = React.useRef(null); + + const columns = React.useMemo(() => { + return table + .getAllColumns() + .filter((column) => column.columnDef.enableColumnFilter); + }, [table]); + + const [filters, setFilters] = useQueryState( + table.options.meta?.queryKeys?.filters ?? "filters", + getFiltersStateParser(columns.map((field) => field.id)) + .withDefault([]) + .withOptions({ + clearOnDefault: true, + shallow, + throttleMs, + }), + ); + const debouncedSetFilters = useDebouncedCallback(setFilters, debounceMs); + + const [joinOperator, setJoinOperator] = useQueryState( + table.options.meta?.queryKeys?.joinOperator ?? "", + parseAsStringEnum(["and", "or"]).withDefault("and").withOptions({ + clearOnDefault: true, + shallow, + }), + ); + + const onFilterAdd = React.useCallback(() => { + const column = columns[0]; + + if (!column) return; + + debouncedSetFilters([ + ...filters, + { + id: column.id as Extract, + value: "", + variant: column.columnDef.meta?.variant ?? "text", + operator: getDefaultFilterOperator( + column.columnDef.meta?.variant ?? "text", + ), + filterId: generateId({ length: 8 }), + }, + ]); + }, [columns, filters, debouncedSetFilters]); + + const onFilterUpdate = React.useCallback( + ( + filterId: string, + updates: Partial, "filterId">>, + ) => { + debouncedSetFilters((prevFilters) => { + const updatedFilters = prevFilters.map((filter) => { + if (filter.filterId === filterId) { + return { ...filter, ...updates } as ExtendedColumnFilter; + } + return filter; + }); + return updatedFilters; + }); + }, + [debouncedSetFilters], + ); + + const onFilterRemove = React.useCallback( + (filterId: string) => { + const updatedFilters = filters.filter( + (filter) => filter.filterId !== filterId, + ); + void setFilters(updatedFilters); + requestAnimationFrame(() => { + addButtonRef.current?.focus(); + }); + }, + [filters, setFilters], + ); + + const onFiltersReset = React.useCallback(() => { + void setFilters(null); + void setJoinOperator("and"); + }, [setFilters, setJoinOperator]); + + React.useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + if ( + event.target instanceof HTMLInputElement || + event.target instanceof HTMLTextAreaElement || + (event.target instanceof HTMLElement && + event.target.contentEditable === "true") + ) { + return; + } + + if ( + event.key.toLowerCase() === FILTER_SHORTCUT_KEY && + (event.ctrlKey || event.metaKey) && + event.shiftKey + ) { + event.preventDefault(); + setOpen((prev) => !prev); + } + } + + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, []); + + const onTriggerKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if ( + REMOVE_FILTER_SHORTCUTS.includes(event.key.toLowerCase()) && + filters.length > 0 + ) { + event.preventDefault(); + onFilterRemove(filters[filters.length - 1]?.filterId ?? ""); + } + }, + [filters, onFilterRemove], + ); + + return ( + ) => item.filterId} + > + + + + + +
+

+ {filters.length > 0 ? "Filters" : "No filters applied"} +

+

0 && "sr-only", + )} + > + {filters.length > 0 + ? "Modify filters to refine your rows." + : "Add filters to refine your rows."} +

+
+ {filters.length > 0 ? ( + +
+ {filters.map((filter, index) => ( + + key={filter.filterId} + filter={filter} + index={index} + filterItemId={`${id}-filter-${filter.filterId}`} + joinOperator={joinOperator} + setJoinOperator={setJoinOperator} + columns={columns} + onFilterUpdate={onFilterUpdate} + onFilterRemove={onFilterRemove} + /> + ))} +
+
+ ) : null} +
+ + {filters.length > 0 ? ( + + ) : null} +
+
+
+ +
+
+
+
+
+
+
+
+ + + ); +} + +interface DataTableFilterItemProps { + filter: ExtendedColumnFilter; + index: number; + filterItemId: string; + joinOperator: JoinOperator; + setJoinOperator: (value: JoinOperator) => void; + columns: Column[]; + onFilterUpdate: ( + filterId: string, + updates: Partial, "filterId">>, + ) => void; + onFilterRemove: (filterId: string) => void; +} + +function DataTableFilterItem({ + filter, + index, + filterItemId, + joinOperator, + setJoinOperator, + columns, + onFilterUpdate, + onFilterRemove, +}: DataTableFilterItemProps) { + const [showFieldSelector, setShowFieldSelector] = React.useState(false); + const [showOperatorSelector, setShowOperatorSelector] = React.useState(false); + const [showValueSelector, setShowValueSelector] = React.useState(false); + + const column = columns.find((column) => column.id === filter.id); + + const joinOperatorListboxId = `${filterItemId}-join-operator-listbox`; + const fieldListboxId = `${filterItemId}-field-listbox`; + const operatorListboxId = `${filterItemId}-operator-listbox`; + const inputId = `${filterItemId}-input`; + + const columnMeta = column?.columnDef.meta; + const filterOperators = getFilterOperators(filter.variant); + + const onItemKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if ( + event.target instanceof HTMLInputElement || + event.target instanceof HTMLTextAreaElement + ) { + return; + } + + if (showFieldSelector || showOperatorSelector || showValueSelector) { + return; + } + + if (REMOVE_FILTER_SHORTCUTS.includes(event.key.toLowerCase())) { + event.preventDefault(); + onFilterRemove(filter.filterId); + } + }, + [ + filter.filterId, + showFieldSelector, + showOperatorSelector, + showValueSelector, + onFilterRemove, + ], + ); + + if (!column) return null; + + return ( + +
+
+ {index === 0 ? ( + Where + ) : index === 1 ? ( + + ) : ( + + {joinOperator} + + )} +
+ + + + + + + + + No fields found. + + {columns.map((column) => ( + { + onFilterUpdate(filter.filterId, { + id: value as Extract, + variant: column.columnDef.meta?.variant ?? "text", + operator: getDefaultFilterOperator( + column.columnDef.meta?.variant ?? "text", + ), + value: "", + }); + + setShowFieldSelector(false); + }} + > + + {column.columnDef.meta?.label} + + + + ))} + + + + + + +
+ {onFilterInputRender({ + filter, + inputId, + column, + columnMeta, + onFilterUpdate, + showValueSelector, + setShowValueSelector, + })} +
+ + + + +
+
+ ); +} + +function onFilterInputRender({ + filter, + inputId, + column, + columnMeta, + onFilterUpdate, + showValueSelector, + setShowValueSelector, +}: { + filter: ExtendedColumnFilter; + inputId: string; + column: Column; + columnMeta?: ColumnMeta; + onFilterUpdate: ( + filterId: string, + updates: Partial, "filterId">>, + ) => void; + showValueSelector: boolean; + setShowValueSelector: (value: boolean) => void; +}) { + if (filter.operator === "isEmpty" || filter.operator === "isNotEmpty") { + return ( +
+ ); + } + + switch (filter.variant) { + case "text": + case "number": + case "range": { + if ( + (filter.variant === "range" && filter.operator === "isBetween") || + filter.operator === "isBetween" + ) { + return ( + + ); + } + + const isNumber = + filter.variant === "number" || filter.variant === "range"; + + return ( + + onFilterUpdate(filter.filterId, { + value: event.target.value, + }) + } + /> + ); + } + + case "boolean": { + if (Array.isArray(filter.value)) return null; + + const inputListboxId = `${inputId}-listbox`; + + return ( + + ); + } + + case "select": + case "multiSelect": { + const inputListboxId = `${inputId}-listbox`; + + const multiple = filter.variant === "multiSelect"; + const selectedValues = multiple + ? Array.isArray(filter.value) + ? filter.value + : [] + : typeof filter.value === "string" + ? filter.value + : undefined; + + return ( + { + onFilterUpdate(filter.filterId, { + value, + }); + }} + multiple={multiple} + > + + + + + + + No options found. + + {columnMeta?.options?.map((option) => ( + + {option.icon && } + {option.label} + {option.count && ( + + {option.count} + + )} + + ))} + + + + + ); + } + + case "date": + case "dateRange": { + const inputListboxId = `${inputId}-listbox`; + + const dateValue = Array.isArray(filter.value) + ? filter.value.filter(Boolean) + : [filter.value, filter.value].filter(Boolean); + + const startDate = dateValue[0] + ? new Date(Number(dateValue[0])) + : undefined; + const endDate = dateValue[1] ? new Date(Number(dateValue[1])) : undefined; + + const isSameDate = + startDate && + endDate && + startDate.toDateString() === endDate.toDateString(); + + const displayValue = + filter.operator === "isBetween" && dateValue.length === 2 && !isSameDate + ? `${formatDate(startDate, { month: "short" })} - ${formatDate(endDate, { month: "short" })}` + : startDate + ? formatDate(startDate, { month: "short" }) + : "Pick a date"; + + return ( + + + + + + {filter.operator === "isBetween" ? ( + { + onFilterUpdate(filter.filterId, { + value: date + ? [ + (date.from?.getTime() ?? "").toString(), + (date.to?.getTime() ?? "").toString(), + ] + : [], + }); + }} + /> + ) : ( + { + onFilterUpdate(filter.filterId, { + value: (date?.getTime() ?? "").toString(), + }); + setShowValueSelector(false); + }} + /> + )} + + + ); + } + + default: + return null; + } +} diff --git a/apps/web/components/ui/data-table-filter-menu.tsx b/apps/web/components/ui/data-table-filter-menu.tsx new file mode 100644 index 0000000..a625506 --- /dev/null +++ b/apps/web/components/ui/data-table-filter-menu.tsx @@ -0,0 +1,882 @@ +"use client"; + +import type { Column, Table } from "@tanstack/react-table"; +import { + BadgeCheck, + CalendarIcon, + Check, + ListFilter, + Text, + X, +} from "lucide-react"; +import { useQueryState } from "nuqs"; +import * as React from "react"; + +import { DataTableRangeFilter } from "@/components/ui/data-table-range-filter"; +import { Button } from "@/components/ui/button"; +import { Calendar } from "@/components/ui/calendar"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; +import { getDefaultFilterOperator, getFilterOperators } from "@/lib/data-table"; +import { formatDate } from "@/lib/format"; +import { generateId } from "@/lib/id"; +import { getFiltersStateParser } from "@/lib/parsers"; +import { cn } from "@/lib/utils"; +import type { ExtendedColumnFilter, FilterOperator } from "@/types/data-table"; + +const DEBOUNCE_MS = 300; +const THROTTLE_MS = 50; +const FILTER_SHORTCUT_KEY = "f"; +const REMOVE_FILTER_SHORTCUTS = ["backspace", "delete"]; + +interface DataTableFilterMenuProps + extends React.ComponentProps { + table: Table; + debounceMs?: number; + throttleMs?: number; + shallow?: boolean; + disabled?: boolean; +} + +export function DataTableFilterMenu({ + table, + debounceMs = DEBOUNCE_MS, + throttleMs = THROTTLE_MS, + shallow = true, + disabled, + ...props +}: DataTableFilterMenuProps) { + const id = React.useId(); + + const columns = React.useMemo(() => { + return table + .getAllColumns() + .filter((column) => column.columnDef.enableColumnFilter); + }, [table]); + + const [open, setOpen] = React.useState(false); + const [selectedColumn, setSelectedColumn] = + React.useState | null>(null); + const [inputValue, setInputValue] = React.useState(""); + const triggerRef = React.useRef(null); + const inputRef = React.useRef(null); + + const onOpenChange = React.useCallback((open: boolean) => { + setOpen(open); + + if (!open) { + setTimeout(() => { + setSelectedColumn(null); + setInputValue(""); + }, 100); + } + }, []); + + const onInputKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if ( + REMOVE_FILTER_SHORTCUTS.includes(event.key.toLowerCase()) && + !inputValue && + selectedColumn + ) { + event.preventDefault(); + setSelectedColumn(null); + } + }, + [inputValue, selectedColumn], + ); + + const [filters, setFilters] = useQueryState( + table.options.meta?.queryKeys?.filters ?? "filters", + getFiltersStateParser(columns.map((field) => field.id)) + .withDefault([]) + .withOptions({ + clearOnDefault: true, + shallow, + throttleMs, + }), + ); + const debouncedSetFilters = useDebouncedCallback(setFilters, debounceMs); + + const onFilterAdd = React.useCallback( + (column: Column, value: string) => { + if (!value.trim() && column.columnDef.meta?.variant !== "boolean") { + return; + } + + const filterValue = + column.columnDef.meta?.variant === "multiSelect" ? [value] : value; + + const newFilter: ExtendedColumnFilter = { + id: column.id as Extract, + value: filterValue, + variant: column.columnDef.meta?.variant ?? "text", + operator: getDefaultFilterOperator( + column.columnDef.meta?.variant ?? "text", + ), + filterId: generateId({ length: 8 }), + }; + + debouncedSetFilters([...filters, newFilter]); + setOpen(false); + + setTimeout(() => { + setSelectedColumn(null); + setInputValue(""); + }, 100); + }, + [filters, debouncedSetFilters], + ); + + const onFilterRemove = React.useCallback( + (filterId: string) => { + const updatedFilters = filters.filter( + (filter) => filter.filterId !== filterId, + ); + debouncedSetFilters(updatedFilters); + requestAnimationFrame(() => { + triggerRef.current?.focus(); + }); + }, + [filters, debouncedSetFilters], + ); + + const onFilterUpdate = React.useCallback( + ( + filterId: string, + updates: Partial, "filterId">>, + ) => { + debouncedSetFilters((prevFilters) => { + const updatedFilters = prevFilters.map((filter) => { + if (filter.filterId === filterId) { + return { ...filter, ...updates } as ExtendedColumnFilter; + } + return filter; + }); + return updatedFilters; + }); + }, + [debouncedSetFilters], + ); + + const onFiltersReset = React.useCallback(() => { + debouncedSetFilters([]); + }, [debouncedSetFilters]); + + React.useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + if ( + event.target instanceof HTMLInputElement || + event.target instanceof HTMLTextAreaElement || + (event.target instanceof HTMLElement && + event.target.contentEditable === "true") + ) { + return; + } + + if ( + event.key.toLowerCase() === FILTER_SHORTCUT_KEY && + (event.ctrlKey || event.metaKey) && + event.shiftKey + ) { + event.preventDefault(); + setOpen((prev) => !prev); + } + } + + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, []); + + const onTriggerKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if ( + REMOVE_FILTER_SHORTCUTS.includes(event.key.toLowerCase()) && + filters.length > 0 + ) { + event.preventDefault(); + onFilterRemove(filters[filters.length - 1]?.filterId ?? ""); + } + }, + [filters, onFilterRemove], + ); + + return ( +
+ {filters.map((filter) => ( + + ))} + {filters.length > 0 && ( + + )} + + + + + + + + + {selectedColumn ? ( + <> + {selectedColumn.columnDef.meta?.options && ( + No options found. + )} + onFilterAdd(selectedColumn, value)} + /> + + ) : ( + <> + No fields found. + + {columns.map((column) => ( + { + setSelectedColumn(column); + setInputValue(""); + requestAnimationFrame(() => { + inputRef.current?.focus(); + }); + }} + > + {column.columnDef.meta?.icon && ( + + )} + + {column.columnDef.meta?.label ?? column.id} + + + ))} + + + )} + + + + +
+ ); +} + +interface DataTableFilterItemProps { + filter: ExtendedColumnFilter; + filterItemId: string; + columns: Column[]; + onFilterUpdate: ( + filterId: string, + updates: Partial, "filterId">>, + ) => void; + onFilterRemove: (filterId: string) => void; +} + +function DataTableFilterItem({ + filter, + filterItemId, + columns, + onFilterUpdate, + onFilterRemove, +}: DataTableFilterItemProps) { + { + const [showFieldSelector, setShowFieldSelector] = React.useState(false); + const [showOperatorSelector, setShowOperatorSelector] = + React.useState(false); + const [showValueSelector, setShowValueSelector] = React.useState(false); + + const column = columns.find((column) => column.id === filter.id); + + const operatorListboxId = `${filterItemId}-operator-listbox`; + const inputId = `${filterItemId}-input`; + + const columnMeta = column?.columnDef.meta; + const filterOperators = getFilterOperators(filter.variant); + + const onItemKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if ( + event.target instanceof HTMLInputElement || + event.target instanceof HTMLTextAreaElement + ) { + return; + } + + if (showFieldSelector || showOperatorSelector || showValueSelector) { + return; + } + + if (REMOVE_FILTER_SHORTCUTS.includes(event.key.toLowerCase())) { + event.preventDefault(); + onFilterRemove(filter.filterId); + } + }, + [ + filter.filterId, + showFieldSelector, + showOperatorSelector, + showValueSelector, + onFilterRemove, + ], + ); + + if (!column) return null; + + return ( +
+ + + + + + + + + No fields found. + + {columns.map((column) => ( + { + onFilterUpdate(filter.filterId, { + id: column.id as Extract, + variant: column.columnDef.meta?.variant ?? "text", + operator: getDefaultFilterOperator( + column.columnDef.meta?.variant ?? "text", + ), + value: "", + }); + + setShowFieldSelector(false); + }} + > + {column.columnDef.meta?.icon && ( + + )} + + {column.columnDef.meta?.label ?? column.id} + + + + ))} + + + + + + + {onFilterInputRender({ + filter, + column, + inputId, + onFilterUpdate, + showValueSelector, + setShowValueSelector, + })} + +
+ ); + } +} + +interface FilterValueSelectorProps { + column: Column; + value: string; + onSelect: (value: string) => void; +} + +function FilterValueSelector({ + column, + value, + onSelect, +}: FilterValueSelectorProps) { + const variant = column.columnDef.meta?.variant ?? "text"; + + switch (variant) { + case "boolean": + return ( + + onSelect("true")}> + True + + onSelect("false")}> + False + + + ); + + case "select": + case "multiSelect": + return ( + + {column.columnDef.meta?.options?.map((option) => ( + onSelect(option.value)} + > + {option.icon && } + {option.label} + {option.count && ( + + {option.count} + + )} + + ))} + + ); + + case "date": + case "dateRange": + return ( + onSelect(date?.getTime().toString() ?? "")} + /> + ); + + default: { + const isEmpty = !value.trim(); + + return ( + + onSelect(value)} + disabled={isEmpty} + > + {isEmpty ? ( + <> + + Type to add filter... + + ) : ( + <> + + Filter by "{value}" + + )} + + + ); + } + } +} + +function onFilterInputRender({ + filter, + column, + inputId, + onFilterUpdate, + showValueSelector, + setShowValueSelector, +}: { + filter: ExtendedColumnFilter; + column: Column; + inputId: string; + onFilterUpdate: ( + filterId: string, + updates: Partial, "filterId">>, + ) => void; + showValueSelector: boolean; + setShowValueSelector: (value: boolean) => void; +}) { + if (filter.operator === "isEmpty" || filter.operator === "isNotEmpty") { + return ( +
+ ); + } + + switch (filter.variant) { + case "text": + case "number": + case "range": { + if ( + (filter.variant === "range" && filter.operator === "isBetween") || + filter.operator === "isBetween" + ) { + return ( + + ); + } + + const isNumber = + filter.variant === "number" || filter.variant === "range"; + + return ( + + onFilterUpdate(filter.filterId, { value: event.target.value }) + } + /> + ); + } + + case "boolean": { + const inputListboxId = `${inputId}-listbox`; + + return ( + + ); + } + + case "select": + case "multiSelect": { + const inputListboxId = `${inputId}-listbox`; + + const options = column.columnDef.meta?.options ?? []; + const selectedValues = Array.isArray(filter.value) + ? filter.value + : [filter.value]; + + const selectedOptions = options.filter((option) => + selectedValues.includes(option.value), + ); + + return ( + + + + + + + + + No options found. + + {options.map((option) => ( + { + const value = + filter.variant === "multiSelect" + ? selectedValues.includes(option.value) + ? selectedValues.filter((v) => v !== option.value) + : [...selectedValues, option.value] + : option.value; + onFilterUpdate(filter.filterId, { value }); + }} + > + {option.icon && } + {option.label} + {filter.variant === "multiSelect" && ( + + )} + + ))} + + + + + + ); + } + + case "date": + case "dateRange": { + const inputListboxId = `${inputId}-listbox`; + + const dateValue = Array.isArray(filter.value) + ? filter.value.filter(Boolean) + : [filter.value, filter.value].filter(Boolean); + + const startDate = dateValue[0] + ? new Date(Number(dateValue[0])) + : undefined; + const endDate = dateValue[1] ? new Date(Number(dateValue[1])) : undefined; + + const isSameDate = + startDate && + endDate && + startDate.toDateString() === endDate.toDateString(); + + const displayValue = + filter.operator === "isBetween" && dateValue.length === 2 && !isSameDate + ? `${formatDate(startDate, { month: "short" })} - ${formatDate(endDate, { month: "short" })}` + : startDate + ? formatDate(startDate, { month: "short" }) + : "Pick date..."; + + return ( + + + + + + {filter.operator === "isBetween" ? ( + { + onFilterUpdate(filter.filterId, { + value: date + ? [ + (date.from?.getTime() ?? "").toString(), + (date.to?.getTime() ?? "").toString(), + ] + : [], + }); + }} + /> + ) : ( + { + onFilterUpdate(filter.filterId, { + value: (date?.getTime() ?? "").toString(), + }); + }} + /> + )} + + + ); + } + + default: + return null; + } +} diff --git a/apps/web/components/ui/data-table-pagination.tsx b/apps/web/components/ui/data-table-pagination.tsx new file mode 100644 index 0000000..5dab166 --- /dev/null +++ b/apps/web/components/ui/data-table-pagination.tsx @@ -0,0 +1,108 @@ +import type { Table } from "@tanstack/react-table"; +import { + ChevronLeft, + ChevronRight, + ChevronsLeft, + ChevronsRight, +} from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { cn } from "@/lib/utils"; + +interface DataTablePaginationProps extends React.ComponentProps<"div"> { + table: Table; + pageSizeOptions?: number[]; +} + +export function DataTablePagination({ + table, + pageSizeOptions = [10, 20, 30, 40, 50], + className, + ...props +}: DataTablePaginationProps) { + return ( +
+
+
+

Rows per page

+ +
+
+ Page {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount()} +
+
+ + + + +
+
+
+ ); +} diff --git a/apps/web/components/ui/data-table-range-filter.tsx b/apps/web/components/ui/data-table-range-filter.tsx new file mode 100644 index 0000000..4e497f4 --- /dev/null +++ b/apps/web/components/ui/data-table-range-filter.tsx @@ -0,0 +1,122 @@ +"use client"; + +import type { Column } from "@tanstack/react-table"; +import * as React from "react"; + +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; +import type { ExtendedColumnFilter } from "@/types/data-table"; + +interface DataTableRangeFilterProps extends React.ComponentProps<"div"> { + filter: ExtendedColumnFilter; + column: Column; + inputId: string; + onFilterUpdate: ( + filterId: string, + updates: Partial, "filterId">>, + ) => void; +} + +export function DataTableRangeFilter({ + filter, + column, + inputId, + onFilterUpdate, + className, + ...props +}: DataTableRangeFilterProps) { + const meta = column.columnDef.meta; + + const [min, max] = React.useMemo(() => { + const range = column.columnDef.meta?.range; + if (range) return range; + + const values = column.getFacetedMinMaxValues(); + if (!values) return [0, 100]; + + return [values[0], values[1]]; + }, [column]); + + const formatValue = React.useCallback( + (value: string | number | undefined) => { + if (value === undefined || value === "") return ""; + const numValue = Number(value); + return Number.isNaN(numValue) + ? "" + : numValue.toLocaleString(undefined, { + maximumFractionDigits: 0, + }); + }, + [], + ); + + const value = React.useMemo(() => { + if (Array.isArray(filter.value)) return filter.value.map(formatValue); + return [formatValue(filter.value), ""]; + }, [filter.value, formatValue]); + + const onRangeValueChange = React.useCallback( + (value: string, isMin?: boolean) => { + const numValue = Number(value); + const currentValues = Array.isArray(filter.value) + ? filter.value + : ["", ""]; + const otherValue = isMin + ? (currentValues[1] ?? "") + : (currentValues[0] ?? ""); + + if ( + value === "" || + (!Number.isNaN(numValue) && + (isMin + ? numValue >= min && numValue <= (Number(otherValue) || max) + : numValue <= max && numValue >= (Number(otherValue) || min))) + ) { + onFilterUpdate(filter.filterId, { + value: isMin ? [value, otherValue] : [otherValue, value], + }); + } + }, + [filter.filterId, filter.value, min, max, onFilterUpdate], + ); + + return ( +
+ onRangeValueChange(event.target.value, true)} + /> + to + onRangeValueChange(event.target.value)} + /> +
+ ); +} diff --git a/apps/web/components/ui/data-table-skeleton.tsx b/apps/web/components/ui/data-table-skeleton.tsx new file mode 100644 index 0000000..df678e2 --- /dev/null +++ b/apps/web/components/ui/data-table-skeleton.tsx @@ -0,0 +1,115 @@ +import { Skeleton } from "@/components/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { cn } from "@/lib/utils"; + +interface DataTableSkeletonProps extends React.ComponentProps<"div"> { + columnCount: number; + rowCount?: number; + filterCount?: number; + cellWidths?: string[]; + withViewOptions?: boolean; + withPagination?: boolean; + shrinkZero?: boolean; +} + +export function DataTableSkeleton({ + columnCount, + rowCount = 10, + filterCount = 0, + cellWidths = ["auto"], + withViewOptions = true, + withPagination = true, + shrinkZero = false, + className, + ...props +}: DataTableSkeletonProps) { + const cozyCellWidths = Array.from( + { length: columnCount }, + (_, index) => cellWidths[index % cellWidths.length] ?? "auto", + ); + + return ( +
+
+
+ {filterCount > 0 + ? Array.from({ length: filterCount }).map((_, i) => ( + + )) + : null} +
+ {withViewOptions ? ( + + ) : null} +
+
+ + + {Array.from({ length: 1 }).map((_, i) => ( + + {Array.from({ length: columnCount }).map((_, j) => ( + + + + ))} + + ))} + + + {Array.from({ length: rowCount }).map((_, i) => ( + + {Array.from({ length: columnCount }).map((_, j) => ( + + + + ))} + + ))} + +
+
+ {withPagination ? ( +
+ +
+
+ + +
+
+ +
+
+ + + + +
+
+
+ ) : null} +
+ ); +} diff --git a/apps/web/components/ui/data-table-slider-filter.tsx b/apps/web/components/ui/data-table-slider-filter.tsx new file mode 100644 index 0000000..8177a7c --- /dev/null +++ b/apps/web/components/ui/data-table-slider-filter.tsx @@ -0,0 +1,256 @@ +"use client"; + +import type { Column } from "@tanstack/react-table"; +import { PlusCircle, XCircle } from "lucide-react"; +import * as React from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Separator } from "@/components/ui/separator"; +import { Slider } from "@/components/ui/slider"; +import { cn } from "@/lib/utils"; + +interface Range { + min: number; + max: number; +} + +type RangeValue = [number, number]; + +function getIsValidRange(value: unknown): value is RangeValue { + return ( + Array.isArray(value) && + value.length === 2 && + typeof value[0] === "number" && + typeof value[1] === "number" + ); +} + +function parseValuesAsNumbers(value: unknown): RangeValue | undefined { + if ( + Array.isArray(value) && + value.length === 2 && + value.every( + (v) => + (typeof v === "string" || typeof v === "number") && !Number.isNaN(v), + ) + ) { + return [Number(value[0]), Number(value[1])]; + } + + return undefined; +} + +interface DataTableSliderFilterProps { + column: Column; + title?: string; +} + +export function DataTableSliderFilter({ + column, + title, +}: DataTableSliderFilterProps) { + const id = React.useId(); + + const columnFilterValue = parseValuesAsNumbers(column.getFilterValue()); + + const defaultRange = column.columnDef.meta?.range; + const unit = column.columnDef.meta?.unit; + + const { min, max, step } = React.useMemo(() => { + let minValue = 0; + let maxValue = 100; + + if (defaultRange && getIsValidRange(defaultRange)) { + [minValue, maxValue] = defaultRange; + } else { + const values = column.getFacetedMinMaxValues(); + if (values && Array.isArray(values) && values.length === 2) { + const [facetMinValue, facetMaxValue] = values; + if ( + typeof facetMinValue === "number" && + typeof facetMaxValue === "number" + ) { + minValue = facetMinValue; + maxValue = facetMaxValue; + } + } + } + + const rangeSize = maxValue - minValue; + const step = + rangeSize <= 20 + ? 1 + : rangeSize <= 100 + ? Math.ceil(rangeSize / 20) + : Math.ceil(rangeSize / 50); + + return { min: minValue, max: maxValue, step }; + }, [column, defaultRange]); + + const range = React.useMemo((): RangeValue => { + return columnFilterValue ?? [min, max]; + }, [columnFilterValue, min, max]); + + const formatValue = React.useCallback((value: number) => { + return value.toLocaleString(undefined, { maximumFractionDigits: 0 }); + }, []); + + const onFromInputChange = React.useCallback( + (event: React.ChangeEvent) => { + const numValue = Number(event.target.value); + if (!Number.isNaN(numValue) && numValue >= min && numValue <= range[1]) { + column.setFilterValue([numValue, range[1]]); + } + }, + [column, min, range], + ); + + const onToInputChange = React.useCallback( + (event: React.ChangeEvent) => { + const numValue = Number(event.target.value); + if (!Number.isNaN(numValue) && numValue <= max && numValue >= range[0]) { + column.setFilterValue([range[0], numValue]); + } + }, + [column, max, range], + ); + + const onSliderValueChange = React.useCallback( + (value: RangeValue) => { + if (Array.isArray(value) && value.length === 2) { + column.setFilterValue(value); + } + }, + [column], + ); + + const onReset = React.useCallback( + (event: React.MouseEvent) => { + if (event.target instanceof HTMLDivElement) { + event.stopPropagation(); + } + column.setFilterValue(undefined); + }, + [column], + ); + + return ( + + + + + +
+

+ {title} +

+
+ +
+ + {unit && ( + + {unit} + + )} +
+ +
+ + {unit && ( + + {unit} + + )} +
+
+ + +
+ +
+
+ ); +} diff --git a/apps/web/components/ui/data-table-sort-list.tsx b/apps/web/components/ui/data-table-sort-list.tsx new file mode 100644 index 0000000..4a95620 --- /dev/null +++ b/apps/web/components/ui/data-table-sort-list.tsx @@ -0,0 +1,405 @@ +"use client"; + +import type { ColumnSort, SortDirection, Table } from "@tanstack/react-table"; +import { + ArrowDownUp, + ChevronsUpDown, + GripVertical, + Trash2, +} from "lucide-react"; +import * as React from "react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Sortable, + SortableContent, + SortableItem, + SortableItemHandle, + SortableOverlay, +} from "@/components/ui/sortable"; +import { dataTableConfig } from "@/config/data-table"; +import { cn } from "@/lib/utils"; + +const SORT_SHORTCUT_KEY = "s"; +const REMOVE_SORT_SHORTCUTS = ["backspace", "delete"]; + +interface DataTableSortListProps + extends React.ComponentProps { + table: Table; + disabled?: boolean; +} + +export function DataTableSortList({ + table, + disabled, + ...props +}: DataTableSortListProps) { + const id = React.useId(); + const labelId = React.useId(); + const descriptionId = React.useId(); + const [open, setOpen] = React.useState(false); + const addButtonRef = React.useRef(null); + + const sorting = table.getState().sorting; + const onSortingChange = table.setSorting; + + const { columnLabels, columns } = React.useMemo(() => { + const labels = new Map(); + const sortingIds = new Set(sorting.map((s) => s.id)); + const availableColumns: { id: string; label: string }[] = []; + + for (const column of table.getAllColumns()) { + if (!column.getCanSort()) continue; + + const label = column.columnDef.meta?.label ?? column.id; + labels.set(column.id, label); + + if (!sortingIds.has(column.id)) { + availableColumns.push({ id: column.id, label }); + } + } + + return { + columnLabels: labels, + columns: availableColumns, + }; + }, [sorting, table]); + + const onSortAdd = React.useCallback(() => { + const firstColumn = columns[0]; + if (!firstColumn) return; + + onSortingChange((prevSorting) => [ + ...prevSorting, + { id: firstColumn.id, desc: false }, + ]); + }, [columns, onSortingChange]); + + const onSortUpdate = React.useCallback( + (sortId: string, updates: Partial) => { + onSortingChange((prevSorting) => { + if (!prevSorting) return prevSorting; + return prevSorting.map((sort) => + sort.id === sortId ? { ...sort, ...updates } : sort, + ); + }); + }, + [onSortingChange], + ); + + const onSortRemove = React.useCallback( + (sortId: string) => { + onSortingChange((prevSorting) => + prevSorting.filter((item) => item.id !== sortId), + ); + }, + [onSortingChange], + ); + + const onSortingReset = React.useCallback( + () => onSortingChange(table.initialState.sorting), + [onSortingChange, table.initialState.sorting], + ); + + React.useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + if ( + event.target instanceof HTMLInputElement || + event.target instanceof HTMLTextAreaElement || + (event.target instanceof HTMLElement && + event.target.contentEditable === "true") + ) { + return; + } + + if ( + event.key.toLowerCase() === SORT_SHORTCUT_KEY && + (event.ctrlKey || event.metaKey) && + event.shiftKey + ) { + event.preventDefault(); + setOpen((prev) => !prev); + } + } + + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, []); + + const onTriggerKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if ( + REMOVE_SORT_SHORTCUTS.includes(event.key.toLowerCase()) && + sorting.length > 0 + ) { + event.preventDefault(); + onSortingReset(); + } + }, + [sorting.length, onSortingReset], + ); + + return ( + item.id} + > + + + + + +
+

+ {sorting.length > 0 ? "Sort by" : "No sorting applied"} +

+

0 && "sr-only", + )} + > + {sorting.length > 0 + ? "Modify sorting to organize your rows." + : "Add sorting to organize your rows."} +

+
+ {sorting.length > 0 && ( + +
+ {sorting.map((sort) => ( + + ))} +
+
+ )} +
+ + {sorting.length > 0 && ( + + )} +
+
+
+ +
+
+
+
+
+
+ + + ); +} + +interface DataTableSortItemProps { + sort: ColumnSort; + sortItemId: string; + columns: { id: string; label: string }[]; + columnLabels: Map; + onSortUpdate: (sortId: string, updates: Partial) => void; + onSortRemove: (sortId: string) => void; +} + +function DataTableSortItem({ + sort, + sortItemId, + columns, + columnLabels, + onSortUpdate, + onSortRemove, +}: DataTableSortItemProps) { + const fieldListboxId = `${sortItemId}-field-listbox`; + const fieldTriggerId = `${sortItemId}-field-trigger`; + const directionListboxId = `${sortItemId}-direction-listbox`; + + const [showFieldSelector, setShowFieldSelector] = React.useState(false); + const [showDirectionSelector, setShowDirectionSelector] = + React.useState(false); + + const onItemKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if ( + event.target instanceof HTMLInputElement || + event.target instanceof HTMLTextAreaElement + ) { + return; + } + + if (showFieldSelector || showDirectionSelector) { + return; + } + + if (REMOVE_SORT_SHORTCUTS.includes(event.key.toLowerCase())) { + event.preventDefault(); + onSortRemove(sort.id); + } + }, + [sort.id, showFieldSelector, showDirectionSelector, onSortRemove], + ); + + return ( + +
+ + + + + + + + + No fields found. + + {columns.map((column) => ( + onSortUpdate(sort.id, { id: value })} + > + {column.label} + + ))} + + + + + + + + + + +
+
+ ); +} diff --git a/apps/web/components/ui/data-table-toolbar.tsx b/apps/web/components/ui/data-table-toolbar.tsx new file mode 100644 index 0000000..99bb716 --- /dev/null +++ b/apps/web/components/ui/data-table-toolbar.tsx @@ -0,0 +1,148 @@ +"use client"; + +import type { Column, Table } from "@tanstack/react-table"; +import { X } from "lucide-react"; +import * as React from "react"; +import { DataTableFacetedFilter } from "@/components/ui/data-table-faceted-filter"; +import { DataTableSliderFilter } from "@/components/ui/data-table-slider-filter"; +import { DataTableViewOptions } from "@/components/ui/data-table-view-options"; +import { Button } from "@/components/ui/button"; +import { DataTableDateFilter } from "@/components/ui/data-table-date-filter"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; + +interface DataTableToolbarProps extends React.ComponentProps<"div"> { + table: Table; +} + +export function DataTableToolbar({ + table, + children, + className, + ...props +}: DataTableToolbarProps) { + const isFiltered = table.getState().columnFilters.length > 0; + + const columns = React.useMemo( + () => table.getAllColumns().filter((column) => column.getCanFilter()), + [table] + ); + + const onReset = React.useCallback(() => { + table.resetColumnFilters(); + }, [table]); + + return ( +
+
+ {columns.map((column) => ( + + ))} + {isFiltered && ( + + )} +
+
+ {children} + +
+
+ ); +} +interface DataTableToolbarFilterProps { + column: Column; +} + +function DataTableToolbarFilter({ + column, +}: DataTableToolbarFilterProps) { + { + const columnMeta = column.columnDef.meta; + + const onFilterRender = React.useCallback(() => { + if (!columnMeta?.variant) return null; + + switch (columnMeta.variant) { + case "text": + return ( + column.setFilterValue(event.target.value)} + placeholder={columnMeta.placeholder ?? columnMeta.label} + value={(column.getFilterValue() as string) ?? ""} + /> + ); + + case "number": + return ( +
+ column.setFilterValue(event.target.value)} + placeholder={columnMeta.placeholder ?? columnMeta.label} + type="number" + value={(column.getFilterValue() as string) ?? ""} + /> + {columnMeta.unit && ( + + {columnMeta.unit} + + )} +
+ ); + + case "range": + return ( + + ); + + case "date": + case "dateRange": + return ( + + ); + + case "select": + case "multiSelect": + return ( + + ); + + default: + return null; + } + }, [column, columnMeta]); + + return onFilterRender(); + } +} diff --git a/apps/web/components/ui/data-table-view-options.tsx b/apps/web/components/ui/data-table-view-options.tsx new file mode 100644 index 0000000..8935d15 --- /dev/null +++ b/apps/web/components/ui/data-table-view-options.tsx @@ -0,0 +1,89 @@ +"use client"; + +import type { Table } from "@tanstack/react-table"; +import { Check, Settings2 } from "lucide-react"; +import * as React from "react"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; + +interface DataTableViewOptionsProps + extends React.ComponentProps { + table: Table; + disabled?: boolean; +} + +export function DataTableViewOptions({ + table, + disabled, + ...props +}: DataTableViewOptionsProps) { + const columns = React.useMemo( + () => + table + .getAllColumns() + .filter( + (column) => + typeof column.accessorFn !== "undefined" && column.getCanHide(), + ), + [table], + ); + + return ( + + + + + + + + + No columns found. + + {columns.map((column) => ( + + column.toggleVisibility(!column.getIsVisible()) + } + > + + {column.columnDef.meta?.label ?? column.id} + + + + ))} + + + + + + ); +} diff --git a/apps/web/components/ui/data-table.tsx b/apps/web/components/ui/data-table.tsx new file mode 100644 index 0000000..d40523c --- /dev/null +++ b/apps/web/components/ui/data-table.tsx @@ -0,0 +1,103 @@ +import { flexRender, type Table as TanstackTable } from "@tanstack/react-table"; +import type * as React from "react"; + +import { DataTablePagination } from "@/components/ui/data-table-pagination"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { getColumnPinningStyles } from "@/lib/data-table"; // [!code ++] +import { cn } from "@/lib/utils"; + +interface DataTableProps extends React.ComponentProps<"div"> { + table: TanstackTable; + actionBar?: React.ReactNode; +} + +export function DataTable({ + table, + actionBar, + children, + className, + ...props +}: DataTableProps) { + return ( +
+ {children} +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+
+ +
+ {actionBar && + table.getFilteredSelectedRowModel().rows.length > 0 && + actionBar} +
+
+
+ ); +} diff --git a/apps/web/components/ui/faceted.tsx b/apps/web/components/ui/faceted.tsx new file mode 100644 index 0000000..a7c116c --- /dev/null +++ b/apps/web/components/ui/faceted.tsx @@ -0,0 +1,283 @@ +"use client"; + +import { Check, ChevronsUpDown } from "lucide-react"; +import * as React from "react"; + +import { Badge } from "@/components/ui/badge"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; + +type FacetedValue = Multiple extends true + ? string[] + : string; + +interface FacetedContextValue { + value?: FacetedValue; + onItemSelect?: (value: string) => void; + multiple?: Multiple; +} + +const FacetedContext = React.createContext | null>( + null, +); + +function useFacetedContext(name: string) { + const context = React.useContext(FacetedContext); + if (!context) { + throw new Error(`\`${name}\` must be within Faceted`); + } + return context; +} + +interface FacetedProps + extends React.ComponentProps { + value?: FacetedValue; + onValueChange?: (value: FacetedValue | undefined) => void; + children?: React.ReactNode; + multiple?: Multiple; +} + +function Faceted( + props: FacetedProps, +) { + const { + open: openProp, + onOpenChange: onOpenChangeProp, + value, + onValueChange, + children, + multiple = false, + ...facetedProps + } = props; + + const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false); + const isControlled = openProp !== undefined; + const open = isControlled ? openProp : uncontrolledOpen; + + const onOpenChange = React.useCallback( + (newOpen: boolean) => { + if (!isControlled) { + setUncontrolledOpen(newOpen); + } + onOpenChangeProp?.(newOpen); + }, + [isControlled, onOpenChangeProp], + ); + + const onItemSelect = React.useCallback( + (selectedValue: string) => { + if (!onValueChange) return; + + if (multiple) { + const currentValue = (Array.isArray(value) ? value : []) as string[]; + const newValue = currentValue.includes(selectedValue) + ? currentValue.filter((v) => v !== selectedValue) + : [...currentValue, selectedValue]; + onValueChange(newValue as FacetedValue); + } else { + if (value === selectedValue) { + onValueChange(undefined); + } else { + onValueChange(selectedValue as FacetedValue); + } + + requestAnimationFrame(() => onOpenChange(false)); + } + }, + [multiple, value, onValueChange, onOpenChange], + ); + + const contextValue = React.useMemo>( + () => ({ value, onItemSelect, multiple }), + [value, onItemSelect, multiple], + ); + + return ( + + + {children} + + + ); +} + +function FacetedTrigger(props: React.ComponentProps) { + const { className, children, ...triggerProps } = props; + + return ( + + {children} + + ); +} + +interface FacetedBadgeListProps extends React.ComponentProps<"div"> { + options?: { label: string; value: string }[]; + max?: number; + badgeClassName?: string; + placeholder?: string; +} + +function FacetedBadgeList(props: FacetedBadgeListProps) { + const { + options = [], + max = 2, + placeholder = "Select options...", + className, + badgeClassName, + ...badgeListProps + } = props; + + const context = useFacetedContext("FacetedBadgeList"); + const values = Array.isArray(context.value) + ? context.value + : ([context.value].filter(Boolean) as string[]); + + const getLabel = React.useCallback( + (value: string) => { + const option = options.find((opt) => opt.value === value); + return option?.label ?? value; + }, + [options], + ); + + if (!values || values.length === 0) { + return ( +
+ {placeholder} + +
+ ); + } + + return ( +
+ {values.length > max ? ( + + {values.length} selected + + ) : ( + values.map((value) => ( + + {getLabel(value)} + + )) + )} +
+ ); +} + +function FacetedContent(props: React.ComponentProps) { + const { className, children, ...contentProps } = props; + + return ( + + {children} + + ); +} + +const FacetedInput = CommandInput; + +const FacetedList = CommandList; + +const FacetedEmpty = CommandEmpty; + +const FacetedGroup = CommandGroup; + +interface FacetedItemProps extends React.ComponentProps { + value: string; +} + +function FacetedItem(props: FacetedItemProps) { + const { value, onSelect, className, children, ...itemProps } = props; + const context = useFacetedContext("FacetedItem"); + + const isSelected = context.multiple + ? Array.isArray(context.value) && context.value.includes(value) + : context.value === value; + + const onItemSelect = React.useCallback( + (currentValue: string) => { + if (onSelect) { + onSelect(currentValue); + } else if (context.onItemSelect) { + context.onItemSelect(currentValue); + } + }, + [onSelect, context], + ); + + return ( + onItemSelect(value)} + {...itemProps} + > + + + + {children} + + ); +} + +const FacetedSeparator = CommandSeparator; + +export { + Faceted, + FacetedBadgeList, + FacetedContent, + FacetedEmpty, + FacetedGroup, + FacetedInput, + FacetedItem, + FacetedList, + FacetedSeparator, + FacetedTrigger, +}; diff --git a/apps/web/components/ui/service-order-status-badge.tsx b/apps/web/components/ui/service-order-status-badge.tsx new file mode 100644 index 0000000..a3e72f0 --- /dev/null +++ b/apps/web/components/ui/service-order-status-badge.tsx @@ -0,0 +1,50 @@ +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-md border px-2 py-0.5 font-medium text-xs transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + parts_pending: "bg-blue-100 text-blue-900", + analysis: "bg-amber-100 text-amber-900 [a&]:hover:bg-amber-100", + finished: "bg-emerald-100 text-emerald-900 [a&]:hover:bg-emerald-100", + canceled: "bg-rose-100 text-rose-900 [a&]:hover:bg-rose-100", + quote_pending: "bg-yellow-100 text-yellow-900 [a&]:hover:bg-yellow-100", + approval_pending: "bg-yellow-100 text-yellow-900 [a&]:hover:bg-yellow-100", + in_progress: "bg-blue-100 text-blue-900 [a&]:hover:bg-blue-100", + ready_for_pickup: "bg-green-100 text-green-900 [a&]:hover:bg-green-100", + contacted: "bg-gray-100 text-gray-900 [a&]:hover:bg-gray-100", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + +function ServiceOrderStatusBadge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span"; + + return ( + + ); +} + +export { ServiceOrderStatusBadge, badgeVariants }; + diff --git a/apps/web/components/ui/slider.tsx b/apps/web/components/ui/slider.tsx new file mode 100644 index 0000000..46ebc4b --- /dev/null +++ b/apps/web/components/ui/slider.tsx @@ -0,0 +1,63 @@ +"use client" + +import * as React from "react" +import { Slider as SliderPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Slider({ + className, + defaultValue, + value, + min = 0, + max = 100, + ...props +}: React.ComponentProps) { + const _values = React.useMemo( + () => + Array.isArray(value) + ? value + : Array.isArray(defaultValue) + ? defaultValue + : [min, max], + [value, defaultValue, min, max] + ) + + return ( + + + + + {Array.from({ length: _values.length }, (_, index) => ( + + ))} + + ) +} + +export { Slider } diff --git a/apps/web/components/ui/sortable.tsx b/apps/web/components/ui/sortable.tsx new file mode 100644 index 0000000..68213d9 --- /dev/null +++ b/apps/web/components/ui/sortable.tsx @@ -0,0 +1,581 @@ +"use client"; + +import { + type Announcements, + closestCenter, + closestCorners, + DndContext, + type DndContextProps, + type DragEndEvent, + type DraggableAttributes, + type DraggableSyntheticListeners, + DragOverlay, + type DragStartEvent, + type DropAnimation, + defaultDropAnimationSideEffects, + KeyboardSensor, + MouseSensor, + type ScreenReaderInstructions, + TouchSensor, + type UniqueIdentifier, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + restrictToHorizontalAxis, + restrictToParentElement, + restrictToVerticalAxis, +} from "@dnd-kit/modifiers"; +import { + arrayMove, + horizontalListSortingStrategy, + SortableContext, + type SortableContextProps, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { Slot } from "@radix-ui/react-slot"; +import * as React from "react"; +import * as ReactDOM from "react-dom"; +import { useComposedRefs } from "@/lib/compose-refs"; +import { cn } from "@/lib/utils"; + +const orientationConfig = { + vertical: { + modifiers: [restrictToVerticalAxis, restrictToParentElement], + strategy: verticalListSortingStrategy, + collisionDetection: closestCenter, + }, + horizontal: { + modifiers: [restrictToHorizontalAxis, restrictToParentElement], + strategy: horizontalListSortingStrategy, + collisionDetection: closestCenter, + }, + mixed: { + modifiers: [restrictToParentElement], + strategy: undefined, + collisionDetection: closestCorners, + }, +}; + +const ROOT_NAME = "Sortable"; +const CONTENT_NAME = "SortableContent"; +const ITEM_NAME = "SortableItem"; +const ITEM_HANDLE_NAME = "SortableItemHandle"; +const OVERLAY_NAME = "SortableOverlay"; + +interface SortableRootContextValue { + id: string; + items: UniqueIdentifier[]; + modifiers: DndContextProps["modifiers"]; + strategy: SortableContextProps["strategy"]; + activeId: UniqueIdentifier | null; + setActiveId: (id: UniqueIdentifier | null) => void; + getItemValue: (item: T) => UniqueIdentifier; + flatCursor: boolean; +} + +const SortableRootContext = + React.createContext | null>(null); + +function useSortableContext(consumerName: string) { + const context = React.useContext(SortableRootContext); + if (!context) { + throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``); + } + return context; +} + +interface GetItemValue { + /** + * Callback that returns a unique identifier for each sortable item. Required for array of objects. + * @example getItemValue={(item) => item.id} + */ + getItemValue: (item: T) => UniqueIdentifier; +} + +type SortableRootProps = DndContextProps & + (T extends object ? GetItemValue : Partial>) & { + value: T[]; + onValueChange?: (items: T[]) => void; + onMove?: ( + event: DragEndEvent & { activeIndex: number; overIndex: number }, + ) => void; + strategy?: SortableContextProps["strategy"]; + orientation?: "vertical" | "horizontal" | "mixed"; + flatCursor?: boolean; + }; + +function SortableRoot(props: SortableRootProps) { + const { + value, + onValueChange, + collisionDetection, + modifiers, + strategy, + onMove, + orientation = "vertical", + flatCursor = false, + getItemValue: getItemValueProp, + accessibility, + onDragStart: onDragStartProp, + onDragEnd: onDragEndProp, + onDragCancel: onDragCancelProp, + ...sortableProps + } = props; + + const id = React.useId(); + const [activeId, setActiveId] = React.useState(null); + + const sensors = useSensors( + useSensor(MouseSensor), + useSensor(TouchSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + const config = React.useMemo( + () => orientationConfig[orientation], + [orientation], + ); + + const getItemValue = React.useCallback( + (item: T): UniqueIdentifier => { + if (typeof item === "object" && !getItemValueProp) { + throw new Error("getItemValue is required when using array of objects"); + } + return getItemValueProp + ? getItemValueProp(item) + : (item as UniqueIdentifier); + }, + [getItemValueProp], + ); + + const items = React.useMemo(() => { + return value.map((item) => getItemValue(item)); + }, [value, getItemValue]); + + const onDragStart = React.useCallback( + (event: DragStartEvent) => { + onDragStartProp?.(event); + + if (event.activatorEvent.defaultPrevented) return; + + setActiveId(event.active.id); + }, + [onDragStartProp], + ); + + const onDragEnd = React.useCallback( + (event: DragEndEvent) => { + onDragEndProp?.(event); + + if (event.activatorEvent.defaultPrevented) return; + + const { active, over } = event; + if (over && active.id !== over?.id) { + const activeIndex = value.findIndex( + (item) => getItemValue(item) === active.id, + ); + const overIndex = value.findIndex( + (item) => getItemValue(item) === over.id, + ); + + if (onMove) { + onMove({ ...event, activeIndex, overIndex }); + } else { + onValueChange?.(arrayMove(value, activeIndex, overIndex)); + } + } + setActiveId(null); + }, + [value, onValueChange, onMove, getItemValue, onDragEndProp], + ); + + const onDragCancel = React.useCallback( + (event: DragEndEvent) => { + onDragCancelProp?.(event); + + if (event.activatorEvent.defaultPrevented) return; + + setActiveId(null); + }, + [onDragCancelProp], + ); + + const announcements: Announcements = React.useMemo( + () => ({ + onDragStart({ active }) { + const activeValue = active.id.toString(); + return `Grabbed sortable item "${activeValue}". Current position is ${active.data.current?.sortable.index + 1} of ${value.length}. Use arrow keys to move, space to drop.`; + }, + onDragOver({ active, over }) { + if (over) { + const overIndex = over.data.current?.sortable.index ?? 0; + const activeIndex = active.data.current?.sortable.index ?? 0; + const moveDirection = overIndex > activeIndex ? "down" : "up"; + const activeValue = active.id.toString(); + return `Sortable item "${activeValue}" moved ${moveDirection} to position ${overIndex + 1} of ${value.length}.`; + } + return "Sortable item is no longer over a droppable area. Press escape to cancel."; + }, + onDragEnd({ active, over }) { + const activeValue = active.id.toString(); + if (over) { + const overIndex = over.data.current?.sortable.index ?? 0; + return `Sortable item "${activeValue}" dropped at position ${overIndex + 1} of ${value.length}.`; + } + return `Sortable item "${activeValue}" dropped. No changes were made.`; + }, + onDragCancel({ active }) { + const activeIndex = active.data.current?.sortable.index ?? 0; + const activeValue = active.id.toString(); + return `Sorting cancelled. Sortable item "${activeValue}" returned to position ${activeIndex + 1} of ${value.length}.`; + }, + onDragMove({ active, over }) { + if (over) { + const overIndex = over.data.current?.sortable.index ?? 0; + const activeIndex = active.data.current?.sortable.index ?? 0; + const moveDirection = overIndex > activeIndex ? "down" : "up"; + const activeValue = active.id.toString(); + return `Sortable item "${activeValue}" is moving ${moveDirection} to position ${overIndex + 1} of ${value.length}.`; + } + return "Sortable item is no longer over a droppable area. Press escape to cancel."; + }, + }), + [value], + ); + + const screenReaderInstructions: ScreenReaderInstructions = React.useMemo( + () => ({ + draggable: ` + To pick up a sortable item, press space or enter. + While dragging, use the ${orientation === "vertical" ? "up and down" : orientation === "horizontal" ? "left and right" : "arrow"} keys to move the item. + Press space or enter again to drop the item in its new position, or press escape to cancel. + `, + }), + [orientation], + ); + + const contextValue = React.useMemo( + () => ({ + id, + items, + modifiers: modifiers ?? config.modifiers, + strategy: strategy ?? config.strategy, + activeId, + setActiveId, + getItemValue, + flatCursor, + }), + [ + id, + items, + modifiers, + strategy, + config.modifiers, + config.strategy, + activeId, + getItemValue, + flatCursor, + ], + ); + + return ( + } + > + + + ); +} + +const SortableContentContext = React.createContext(false); + +interface SortableContentProps extends React.ComponentProps<"div"> { + strategy?: SortableContextProps["strategy"]; + children: React.ReactNode; + asChild?: boolean; + withoutSlot?: boolean; +} + +function SortableContent(props: SortableContentProps) { + const { + strategy: strategyProp, + asChild, + withoutSlot, + children, + ref, + ...contentProps + } = props; + + const context = useSortableContext(CONTENT_NAME); + + const ContentPrimitive = asChild ? Slot : "div"; + + return ( + + + {withoutSlot ? ( + children + ) : ( + + {children} + + )} + + + ); +} + +interface SortableItemContextValue { + id: string; + attributes: DraggableAttributes; + listeners: DraggableSyntheticListeners | undefined; + setActivatorNodeRef: (node: HTMLElement | null) => void; + isDragging?: boolean; + disabled?: boolean; +} + +const SortableItemContext = + React.createContext(null); + +function useSortableItemContext(consumerName: string) { + const context = React.useContext(SortableItemContext); + if (!context) { + throw new Error(`\`${consumerName}\` must be used within \`${ITEM_NAME}\``); + } + return context; +} + +interface SortableItemProps extends React.ComponentProps<"div"> { + value: UniqueIdentifier; + asHandle?: boolean; + asChild?: boolean; + disabled?: boolean; +} + +function SortableItem(props: SortableItemProps) { + const { + value, + style, + asHandle, + asChild, + disabled, + className, + ref, + ...itemProps + } = props; + + const inSortableContent = React.useContext(SortableContentContext); + const inSortableOverlay = React.useContext(SortableOverlayContext); + + if (!inSortableContent && !inSortableOverlay) { + throw new Error( + `\`${ITEM_NAME}\` must be used within \`${CONTENT_NAME}\` or \`${OVERLAY_NAME}\``, + ); + } + + if (value === "") { + throw new Error(`\`${ITEM_NAME}\` value cannot be an empty string`); + } + + const context = useSortableContext(ITEM_NAME); + const id = React.useId(); + const { + attributes, + listeners, + setNodeRef, + setActivatorNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: value, disabled }); + + const composedRef = useComposedRefs(ref, (node) => { + if (disabled) return; + setNodeRef(node); + if (asHandle) setActivatorNodeRef(node); + }); + + const composedStyle = React.useMemo(() => { + return { + transform: CSS.Translate.toString(transform), + transition, + ...style, + }; + }, [transform, transition, style]); + + const itemContext = React.useMemo( + () => ({ + id, + attributes, + listeners, + setActivatorNodeRef, + isDragging, + disabled, + }), + [id, attributes, listeners, setActivatorNodeRef, isDragging, disabled], + ); + + const ItemPrimitive = asChild ? Slot : "div"; + + return ( + + + + ); +} + +interface SortableItemHandleProps extends React.ComponentProps<"button"> { + asChild?: boolean; +} + +function SortableItemHandle(props: SortableItemHandleProps) { + const { asChild, disabled, className, ref, ...itemHandleProps } = props; + + const context = useSortableContext(ITEM_HANDLE_NAME); + const itemContext = useSortableItemContext(ITEM_HANDLE_NAME); + + const isDisabled = disabled ?? itemContext.disabled; + + const composedRef = useComposedRefs(ref, (node) => { + if (!isDisabled) return; + itemContext.setActivatorNodeRef(node); + }); + + const HandlePrimitive = asChild ? Slot : "button"; + + return ( + + ); +} + +const SortableOverlayContext = React.createContext(false); + +const dropAnimation: DropAnimation = { + sideEffects: defaultDropAnimationSideEffects({ + styles: { + active: { + opacity: "0.4", + }, + }, + }), +}; + +interface SortableOverlayProps + extends Omit, "children"> { + container?: Element | DocumentFragment | null; + children?: + | ((params: { value: UniqueIdentifier }) => React.ReactNode) + | React.ReactNode; +} + +function SortableOverlay(props: SortableOverlayProps) { + const { container: containerProp, children, ...overlayProps } = props; + + const context = useSortableContext(OVERLAY_NAME); + + const [mounted, setMounted] = React.useState(false); + React.useLayoutEffect(() => setMounted(true), []); + + const container = + containerProp ?? (mounted ? globalThis.document?.body : null); + + if (!container) return null; + + return ReactDOM.createPortal( + + + {context.activeId + ? typeof children === "function" + ? children({ value: context.activeId }) + : children + : null} + + , + container, + ); +} + +export { + SortableRoot as Sortable, + SortableContent, + SortableItem, + SortableItemHandle, + SortableOverlay, + // + SortableRoot as Root, + SortableContent as Content, + SortableItem as Item, + SortableItemHandle as ItemHandle, + SortableOverlay as Overlay, +}; diff --git a/apps/web/components/ui/timeline.tsx b/apps/web/components/ui/timeline.tsx new file mode 100644 index 0000000..31ed9f9 --- /dev/null +++ b/apps/web/components/ui/timeline.tsx @@ -0,0 +1,712 @@ +"use client"; + +import { cva } from "class-variance-authority"; +import { + Direction as DirectionPrimitive, + Slot as SlotPrimitive, +} from "radix-ui"; +import * as React from "react"; +import { useComposedRefs } from "@/lib/compose-refs"; +import { cn } from "@/lib/utils"; +import { useIsomorphicLayoutEffect } from "@/hooks/use-isomorphic-layout-effect"; +import { useLazyRef } from "@/hooks/use-lazy-ref"; + +type Direction = "ltr" | "rtl"; +type Orientation = "vertical" | "horizontal"; +type Variant = "default" | "alternate"; +type Status = "completed" | "active" | "pending"; + +interface DivProps extends React.ComponentProps<"div"> { + asChild?: boolean; +} + +type ItemElement = React.ComponentRef; + +const ROOT_NAME = "Timeline"; +const ITEM_NAME = "TimelineItem"; +const DOT_NAME = "TimelineDot"; +const CONNECTOR_NAME = "TimelineConnector"; +const CONTENT_NAME = "TimelineContent"; + +function getItemStatus(itemIndex: number, activeIndex?: number): Status { + if (activeIndex === undefined) return "pending"; + if (itemIndex < activeIndex) return "completed"; + if (itemIndex === activeIndex) return "active"; + return "pending"; +} + +function getSortedEntries( + entries: [string, React.RefObject][], +) { + return entries.sort((a, b) => { + const elementA = a[1].current; + const elementB = b[1].current; + if (!elementA || !elementB) return 0; + const position = elementA.compareDocumentPosition(elementB); + if (position & Node.DOCUMENT_POSITION_FOLLOWING) return -1; + if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1; + return 0; + }); +} + +function useStore(selector: (store: Store) => T): T { + const store = React.useContext(StoreContext); + if (!store) { + throw new Error(`\`useStore\` must be used within \`${ROOT_NAME}\``); + } + + const getSnapshot = React.useCallback( + () => selector(store), + [store, selector], + ); + + return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot); +} + +interface StoreState { + items: Map>; +} + +interface Store { + subscribe: (callback: () => void) => () => void; + getState: () => StoreState; + notify: () => void; + onItemRegister: ( + id: string, + ref: React.RefObject, + ) => void; + onItemUnregister: (id: string) => void; + getNextItemStatus: (id: string, activeIndex?: number) => Status | undefined; + getItemIndex: (id: string) => number; +} + +const StoreContext = React.createContext(null); + +function useStoreContext(consumerName: string) { + const context = React.useContext(StoreContext); + if (!context) { + throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``); + } + return context; +} + +interface TimelineContextValue { + dir: Direction; + orientation: Orientation; + variant: Variant; + activeIndex?: number; +} + +const TimelineContext = React.createContext(null); + +function useTimelineContext(consumerName: string) { + const context = React.useContext(TimelineContext); + if (!context) { + throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``); + } + return context; +} + +const timelineVariants = cva( + "relative flex [--timeline-connector-thickness:0.125rem] [--timeline-dot-size:0.875rem]", + { + variants: { + orientation: { + vertical: "flex-col", + horizontal: "flex-row items-start", + }, + variant: { + default: "", + alternate: "", + }, + }, + compoundVariants: [ + { + orientation: "vertical", + variant: "default", + class: "gap-6", + }, + { + orientation: "horizontal", + variant: "default", + class: "gap-8", + }, + { + orientation: "vertical", + variant: "alternate", + class: "relative w-full gap-3", + }, + { + orientation: "horizontal", + variant: "alternate", + class: "items-center gap-4", + }, + ], + defaultVariants: { + orientation: "vertical", + variant: "default", + }, + }, +); + +interface TimelineProps extends DivProps { + dir?: Direction; + orientation?: Orientation; + variant?: Variant; + activeIndex?: number; +} + +function Timeline(props: TimelineProps) { + const { + orientation = "vertical", + variant = "default", + dir: dirProp, + activeIndex, + asChild, + className, + ...rootProps + } = props; + + const dir = DirectionPrimitive.useDirection(dirProp); + + const listenersRef = useLazyRef(() => new Set<() => void>()); + const stateRef = useLazyRef(() => ({ + items: new Map(), + })); + + const store = React.useMemo(() => { + return { + subscribe: (cb) => { + listenersRef.current.add(cb); + return () => listenersRef.current.delete(cb); + }, + getState: () => stateRef.current, + notify: () => { + for (const cb of listenersRef.current) { + cb(); + } + }, + onItemRegister: ( + id: string, + ref: React.RefObject, + ) => { + stateRef.current.items.set(id, ref); + store.notify(); + }, + onItemUnregister: (id: string) => { + stateRef.current.items.delete(id); + store.notify(); + }, + getNextItemStatus: (id: string, activeIndex?: number) => { + const entries = Array.from(stateRef.current.items.entries()); + const sortedEntries = getSortedEntries(entries); + + const currentIndex = sortedEntries.findIndex(([key]) => key === id); + if (currentIndex === -1 || currentIndex === sortedEntries.length - 1) { + return undefined; + } + + const nextItemIndex = currentIndex + 1; + return getItemStatus(nextItemIndex, activeIndex); + }, + getItemIndex: (id: string) => { + const entries = Array.from(stateRef.current.items.entries()); + const sortedEntries = getSortedEntries(entries); + return sortedEntries.findIndex(([key]) => key === id); + }, + }; + }, [listenersRef, stateRef]); + + const contextValue = React.useMemo( + () => ({ + dir, + orientation, + variant, + activeIndex, + }), + [dir, orientation, variant, activeIndex], + ); + + const RootPrimitive = asChild ? SlotPrimitive.Slot : "div"; + + return ( + + + + + + ); +} + +interface TimelineItemContextValue { + id: string; + status: Status; + isAlternateRight: boolean; +} + +const TimelineItemContext = + React.createContext(null); + +function useTimelineItemContext(consumerName: string) { + const context = React.useContext(TimelineItemContext); + if (!context) { + throw new Error(`\`${consumerName}\` must be used within \`${ITEM_NAME}\``); + } + return context; +} + +const timelineItemVariants = cva("relative flex", { + variants: { + orientation: { + vertical: "", + horizontal: "", + }, + variant: { + default: "", + alternate: "", + }, + isAlternateRight: { + true: "", + false: "", + }, + }, + compoundVariants: [ + { + orientation: "vertical", + variant: "default", + class: "gap-3 pb-8 last:pb-0", + }, + { + orientation: "horizontal", + variant: "default", + class: "flex-col gap-3", + }, + { + orientation: "vertical", + variant: "alternate", + isAlternateRight: false, + class: "w-1/2 gap-3 pr-6 pb-12 last:pb-0", + }, + { + orientation: "vertical", + variant: "alternate", + isAlternateRight: true, + class: "ml-auto w-1/2 flex-row-reverse gap-3 pb-12 pl-6 last:pb-0", + }, + { + orientation: "horizontal", + variant: "alternate", + class: "grid min-w-0 grid-rows-[1fr_auto_1fr] gap-3", + }, + ], + defaultVariants: { + orientation: "vertical", + variant: "default", + isAlternateRight: false, + }, +}); + +function TimelineItem(props: DivProps) { + const { asChild, className, id, ref, ...itemProps } = props; + + const { dir, orientation, variant, activeIndex } = + useTimelineContext(ITEM_NAME); + const store = useStoreContext(ITEM_NAME); + + const instanceId = React.useId(); + const itemId = id ?? instanceId; + const itemRef = React.useRef(null); + const composedRef = useComposedRefs(ref, itemRef); + + const itemIndex = useStore((state) => state.getItemIndex(itemId)); + + const status = React.useMemo(() => { + return getItemStatus(itemIndex, activeIndex); + }, [activeIndex, itemIndex]); + + useIsomorphicLayoutEffect(() => { + store.onItemRegister(itemId, itemRef); + return () => { + store.onItemUnregister(itemId); + }; + }, [id, store]); + + const isAlternateRight = variant === "alternate" && itemIndex % 2 === 1; + + const itemContextValue = React.useMemo( + () => ({ id: itemId, status, isAlternateRight }), + [itemId, status, isAlternateRight], + ); + + const ItemPrimitive = asChild ? SlotPrimitive.Slot : "div"; + + return ( + + + + ); +} + +const timelineContentVariants = cva("flex-1", { + variants: { + orientation: { + vertical: "", + horizontal: "", + }, + variant: { + default: "", + alternate: "", + }, + isAlternateRight: { + true: "", + false: "", + }, + }, + compoundVariants: [ + { + variant: "alternate", + orientation: "vertical", + isAlternateRight: false, + class: "text-right", + }, + { + variant: "alternate", + orientation: "horizontal", + isAlternateRight: false, + class: "row-start-3 pt-2", + }, + { + variant: "alternate", + orientation: "horizontal", + isAlternateRight: true, + class: "row-start-1 pb-2", + }, + ], + defaultVariants: { + orientation: "vertical", + variant: "default", + isAlternateRight: false, + }, +}); + +function TimelineContent(props: DivProps) { + const { asChild, className, ...contentProps } = props; + + const { variant, orientation } = useTimelineContext(CONTENT_NAME); + const { status, isAlternateRight } = useTimelineItemContext(CONTENT_NAME); + + const ContentPrimitive = asChild ? SlotPrimitive.Slot : "div"; + + return ( + + ); +} + +const timelineDotVariants = cva( + "relative z-10 flex size-[var(--timeline-dot-size)] shrink-0 items-center justify-center rounded-full border-2 bg-background", + { + variants: { + status: { + completed: "border-primary", + active: "border-primary", + pending: "border-border", + }, + orientation: { + vertical: "", + horizontal: "", + }, + variant: { + default: "", + alternate: "", + }, + isAlternateRight: { + true: "", + false: "", + }, + }, + compoundVariants: [ + { + variant: "alternate", + orientation: "vertical", + isAlternateRight: false, + class: + "absolute -right-[calc(var(--timeline-dot-size)/2-var(--timeline-connector-thickness)/2)] bg-background", + }, + { + variant: "alternate", + orientation: "vertical", + isAlternateRight: true, + class: + "absolute -left-[calc(var(--timeline-dot-size)/2-var(--timeline-connector-thickness)/2)] bg-background", + }, + { + variant: "alternate", + orientation: "horizontal", + class: "row-start-2 bg-background", + }, + { + variant: "alternate", + status: "completed", + class: "bg-background", + }, + { + variant: "alternate", + status: "active", + class: "bg-background", + }, + ], + defaultVariants: { + status: "pending", + orientation: "vertical", + variant: "default", + isAlternateRight: false, + }, + }, +); + +function TimelineDot(props: DivProps) { + const { asChild, className, ...dotProps } = props; + + const { orientation, variant } = useTimelineContext(DOT_NAME); + const { status, isAlternateRight } = useTimelineItemContext(DOT_NAME); + + const DotPrimitive = asChild ? SlotPrimitive.Slot : "div"; + + return ( + + ); +} + +const timelineConnectorVariants = cva("absolute z-0", { + variants: { + isCompleted: { + true: "bg-primary", + false: "bg-border", + }, + orientation: { + vertical: "", + horizontal: "", + }, + variant: { + default: "", + alternate: "", + }, + isAlternateRight: { + true: "", + false: "", + }, + }, + compoundVariants: [ + { + orientation: "vertical", + variant: "default", + class: + "start-[calc(var(--timeline-dot-size)/2-var(--timeline-connector-thickness)/2)] top-3 h-[calc(100%+0.5rem)] w-[var(--timeline-connector-thickness)]", + }, + { + orientation: "horizontal", + variant: "default", + class: + "start-3 top-[calc(var(--timeline-dot-size)/2-var(--timeline-connector-thickness)/2)] h-[var(--timeline-connector-thickness)] w-[calc(100%+0.5rem)]", + }, + { + orientation: "vertical", + variant: "alternate", + isAlternateRight: false, + class: + "top-2 -right-[calc(var(--timeline-connector-thickness)/2)] h-full w-[var(--timeline-connector-thickness)]", + }, + { + orientation: "vertical", + variant: "alternate", + isAlternateRight: true, + class: + "top-2 -left-[calc(var(--timeline-connector-thickness)/2)] h-full w-[var(--timeline-connector-thickness)]", + }, + { + orientation: "horizontal", + variant: "alternate", + class: + "top-[calc(var(--timeline-dot-size)/2-var(--timeline-connector-thickness)/2)] left-3 row-start-2 h-[var(--timeline-connector-thickness)] w-[calc(100%+0.5rem)]", + }, + ], + defaultVariants: { + isCompleted: false, + orientation: "vertical", + variant: "default", + isAlternateRight: false, + }, +}); + +interface TimelineConnectorProps extends DivProps { + forceMount?: boolean; +} + +function TimelineConnector(props: TimelineConnectorProps) { + const { asChild, forceMount, className, ...connectorProps } = props; + + const { orientation, variant, activeIndex } = + useTimelineContext(CONNECTOR_NAME); + const { id, status, isAlternateRight } = + useTimelineItemContext(CONNECTOR_NAME); + + const nextItemStatus = useStore((state) => + state.getNextItemStatus(id, activeIndex), + ); + + const isLastItem = nextItemStatus === undefined; + + if (!forceMount && isLastItem) return null; + + const isConnectorCompleted = + nextItemStatus === "completed" || nextItemStatus === "active"; + + const ConnectorPrimitive = asChild ? SlotPrimitive.Slot : "div"; + + return ( +