From a2434f6333ce550938e89f36e4249433af0d607f Mon Sep 17 00:00:00 2001 From: Samuel Shuert Date: Thu, 16 Jul 2026 20:54:48 +0000 Subject: [PATCH] chore(frontend): make template page for ease of use --- frontend/components/dashboard.tsx | 12 +- frontend/components/pages/orders.tsx | 3 - frontend/components/pages/template.tsx | 349 +++++++++++++++++++++++++ 3 files changed, 355 insertions(+), 9 deletions(-) create mode 100644 frontend/components/pages/template.tsx diff --git a/frontend/components/dashboard.tsx b/frontend/components/dashboard.tsx index 1abfcbd..c779520 100644 --- a/frontend/components/dashboard.tsx +++ b/frontend/components/dashboard.tsx @@ -40,7 +40,7 @@ import { BatchesPage } from "@/components/pages/batches" import { CustomersPage } from "@/components/pages/customers" import { FlavorsPage } from "@/components/pages/flavors" import { IngredientsPage } from "@/components/pages/ingredients" -import { OrdersPage } from "@/components/pages/orders" +import { OrdersPage } from "@/components/pages/template" import { SuppliersPage } from "@/components/pages/suppliers" import { UsersPage } from "@/components/pages/users" import { useTheme } from "next-themes" @@ -107,15 +107,15 @@ const NAV_ITEMS: { function pageMap(page: Page, userPerms: UserPermissions): React.ReactNode { switch (page) { case "batches": - return + return case "customers": - return + return case "flavors": - return + return case "ingredients": - return + return case "orders": - return + return case "suppliers": return case "users": diff --git a/frontend/components/pages/orders.tsx b/frontend/components/pages/orders.tsx index dd1bddb..e69de29 100644 --- a/frontend/components/pages/orders.tsx +++ b/frontend/components/pages/orders.tsx @@ -1,3 +0,0 @@ -export function OrdersPage() { - return -} diff --git a/frontend/components/pages/template.tsx b/frontend/components/pages/template.tsx new file mode 100644 index 0000000..d52cc8e --- /dev/null +++ b/frontend/components/pages/template.tsx @@ -0,0 +1,349 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import { + ColumnDef, + flexRender, + getCoreRowModel, + SortingState, + useReactTable, + PaginationState, + ColumnFiltersState, +} from "@tanstack/react-table" +import { + SortableHeader, + SortableFilterableColumnHeader, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + TableRowSkeleton, +} from "@/components/ui/table" +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Card, CardContent } from "@/components/ui/card" +import { Modal } from "@/components/modal" + +import { UserPermissions } from "@/lib/userPermissions" +import { MoreHorizontal, UserPlus2 } from "lucide-react" +import { useQuery } from "@tanstack/react-query" +import { useDebounce } from "use-debounce" +import { TablePaginator } from "../tablePages" + +const ENTITY_NAME = "Item" +const ENTITY_PLURAL = "items" +const API_PATH = "/api/items" + +const SORTABLE_COLUMNS = ["name"] as const +const DEFAULT_SORT_BY = "name" + +const PERM_CREATE = UserPermissions.SuperAdmin +const PERM_UPDATE = UserPermissions.SuperAdmin +const PERM_DELETE = UserPermissions.SuperAdmin + +import { EntityForm, Entity } from "../forms/item" + +interface PaginatedResponse { + rows: Entity[] + total: number + page_count: number +} + +const DISPLAY_PROP: keyof Entity = "name" + +interface Props { + userPerms: UserPermissions +} + +export function ItemsPage({ userPerms }: Props) { + const [editOpen, setEditOpen] = useState(false) + const [deleteOpen, setDeleteOpen] = useState(false) + const [deleteError, setDeleteError] = useState<string | null>(null) + const [entity, setEntity] = useState<Entity | null>(null) + + const [pagination, setPagination] = useState<PaginationState>({ + pageIndex: 0, + pageSize: 10, + }) + const [sorting, setSorting] = useState<SortingState>([]) + const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]) + + const [debouncedFilters] = useDebounce(columnFilters, 300) + + const nameFilter = + (debouncedFilters.find((f) => f.id === "name")?.value as string) ?? "" + + useEffect(() => { + setPagination((p) => ({ ...p, pageIndex: 0 })) + }, [debouncedFilters, sorting]) + + const limit = pagination.pageSize + const offset = pagination.pageIndex * pagination.pageSize + + const sort = sorting.find((s) => SORTABLE_COLUMNS.includes(s.id)) + const sortBy = sort?.id ?? DEFAULT_SORT_BY + const sortOrder = sorting.find((s) => SORTABLE_COLUMNS.includes(s.id))?.desc + ? "desc" + : "asc" + + const { data, isLoading, error, refetch } = useQuery<PaginatedResponse>({ + queryKey: [ + ENTITY_PLURAL, + limit, + offset, + sortBy, + sortOrder, + nameFilter, + debouncedFilters, + ], + queryFn: async () => { + const params = new URLSearchParams({ + limit: String(limit), + offset: String(offset), + sort_by: sortBy, + sort_order: sortOrder, + }) + + if (nameFilter) params.set("name", nameFilter) + + const res = await fetch(`${API_PATH}?${params.toString()}`) + if (!res.ok) throw new Error(`Failed to fetch ${ENTITY_PLURAL}`) + return res.json() + }, + }) + + const rows = data?.rows ?? [] + const pageCount = data?.page_count ?? 0 + + const columns = useMemo<ColumnDef<Entity>[]>( + () => [ + { + accessorKey: "id", + header: "ID", + size: 150, + cell: ({ row }) => ( + <span + className="truncate font-mono text-xs" + title={row.getValue("id")} + > + {row.getValue("id")} + </span> + ), + }, + { + accessorKey: "actions", + header: "Actions", + size: 60, + cell: ({ row }) => { + const item = row.original + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" className="h-8 w-8 p-0"> + <span className="sr-only">Open menu</span> + <MoreHorizontal className="h-4 w-4" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuLabel>Actions</DropdownMenuLabel> + <DropdownMenuSeparator /> + <DropdownMenuItem + onClick={() => { + setEditOpen(true) + setEntity(item) + }} + disabled={!userPerms.contains(PERM_UPDATE)} + className="cursor-pointer" + > + Edit + </DropdownMenuItem> + <DropdownMenuItem + onClick={() => { + setDeleteOpen(true) + setEntity(item) + }} + disabled={!userPerms.contains(PERM_DELETE)} + className="cursor-pointer" + > + Delete + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) + }, + }, + ], + [userPerms] + ) + + const table = useReactTable({ + data: rows, + columns, + pageCount, + state: { pagination, sorting, columnFilters }, + onPaginationChange: setPagination, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + getCoreRowModel: getCoreRowModel(), + manualPagination: true, + manualSorting: true, + manualFiltering: true, + }) + + useEffect(() => { + if (!editOpen && !deleteOpen) setEntity(null) + }, [editOpen, deleteOpen]) + + async function deleteEntity(id: string) { + const res = await fetch(`${API_PATH}/${id}`, { + method: "DELETE", + }) + if (res.ok) { + refetch() + } else { + const data = await res.json() + setDeleteError(data.error) + } + } + + const displayName = entity?.[DISPLAY_PROP] as string | undefined + const entityTitle = displayName ?? ENTITY_NAME + + return ( + <> + <Card> + <CardContent> + {error ? ( + <p className="text-sm text-destructive">{error.message}</p> + ) : ( + <div> + <div className="mb-4 flex justify-end"> + {deleteError && ( + <p className="text-sm text-destructive">{deleteError}</p> + )} + <Button + variant="outline" + size="sm" + disabled={!userPerms.contains(PERM_CREATE)} + onClick={() => setEditOpen(true)} + > + <UserPlus2 /> New {ENTITY_NAME} + </Button> + </div> + + <Table className="w-fit table-fixed"> + <TableHeader> + {table.getHeaderGroups().map((headerGroup) => ( + <TableRow key={headerGroup.id}> + {headerGroup.headers.map((header) => ( + <TableHead + key={header.id} + style={{ width: header.getSize() }} + onClick={() => + header.column.getToggleSortingHandler() + } + className={ + "flex flex-col " + + (header.column.getCanSort() + ? "cursor-pointer select-none" + : "") + } + > + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + </TableHead> + ))} + </TableRow> + ))} + </TableHeader> + <TableBody> + {isLoading ? ( + <TableRowSkeleton columns={columns.length} /> + ) : ( + table.getRowModel().rows.map((row) => ( + <TableRow key={row.id}> + {row.getVisibleCells().map((cell) => ( + <TableCell key={cell.id}> + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + </TableCell> + ))} + </TableRow> + )) + )} + </TableBody> + </Table> + + <div className="flex items-center justify-end space-x-2 py-4"> + <TablePaginator + page={pagination.pageIndex + 1} + pageCount={pageCount} + onPageChange={(p) => table.setPageIndex(p)} + pageSize={pagination.pageSize} + onPageSizeChange={(s) => table.setPageSize(s)} + /> + </div> + </div> + )} + </CardContent> + </Card> + + <Modal + open={editOpen} + onOpenChange={setEditOpen} + trigger={null} + title={`${entity ? "Edit" : "Create"} ${entityTitle}`} + description={ + entity + ? `Make changes to this ${ENTITY_NAME.toLowerCase()}.` + : `Create a new ${ENTITY_NAME.toLowerCase()}.` + } + > + <EntityForm + itemEdit={entity} + setEditOpen={setEditOpen} + refreshItems={refetch} + /> + </Modal> + + <Modal + open={deleteOpen} + onOpenChange={setDeleteOpen} + trigger={null} + title={`Delete ${entityTitle}`} + description="This action cannot be undone." + > + <div className="flex justify-end gap-2"> + <Button variant="outline" onClick={() => setDeleteOpen(false)}> + Cancel + </Button> + <Button + variant="destructive" + onClick={() => { + setDeleteError(null) + if (entity) void deleteEntity(entity.id) + setDeleteOpen(false) + }} + > + Delete + </Button> + </div> + </Modal> + </> + ) +} -- 2.51.2