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(null)
+ const [entity, setEntity] = useState(null)
+
+ const [pagination, setPagination] = useState({
+ pageIndex: 0,
+ pageSize: 10,
+ })
+ const [sorting, setSorting] = useState([])
+ const [columnFilters, setColumnFilters] = useState([])
+
+ 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({
+ 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[]>(
+ () => [
+ {
+ accessorKey: "id",
+ header: "ID",
+ size: 150,
+ cell: ({ row }) => (
+
+ {row.getValue("id")}
+
+ ),
+ },
+ {
+ accessorKey: "actions",
+ header: "Actions",
+ size: 60,
+ cell: ({ row }) => {
+ const item = row.original
+ return (
+
+
+
+
+
+ Actions
+
+ {
+ setEditOpen(true)
+ setEntity(item)
+ }}
+ disabled={!userPerms.contains(PERM_UPDATE)}
+ className="cursor-pointer"
+ >
+ Edit
+
+ {
+ setDeleteOpen(true)
+ setEntity(item)
+ }}
+ disabled={!userPerms.contains(PERM_DELETE)}
+ className="cursor-pointer"
+ >
+ Delete
+
+
+
+ )
+ },
+ },
+ ],
+ [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 (
+ <>
+
+
+ {error ? (
+ {error.message}
+ ) : (
+
+
+ {deleteError && (
+
{deleteError}
+ )}
+
+
+
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => (
+
+ header.column.getToggleSortingHandler()
+ }
+ className={
+ "flex flex-col " +
+ (header.column.getCanSort()
+ ? "cursor-pointer select-none"
+ : "")
+ }
+ >
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+
+ ))}
+
+ ))}
+
+
+ {isLoading ? (
+
+ ) : (
+ table.getRowModel().rows.map((row) => (
+
+ {row.getVisibleCells().map((cell) => (
+
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )}
+
+ ))}
+
+ ))
+ )}
+
+
+
+
+
table.setPageIndex(p)}
+ pageSize={pagination.pageSize}
+ onPageSizeChange={(s) => table.setPageSize(s)}
+ />
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )
+}