From 13dbbb1081ab139ac7f74667c1247591ae07d38c Mon Sep 17 00:00:00 2001 From: Samuel Shuert Date: Fri, 17 Jul 2026 00:58:26 +0000 Subject: [PATCH] feat(frontend): set up customer page --- backend/src/routes/customer.rs | 61 ++++- frontend/components/dashboard.tsx | 3 +- frontend/components/forms/customer.tsx | 117 ++++++++ frontend/components/pages/customers.tsx | 343 +++++++++++++++++++++++- 4 files changed, 508 insertions(+), 16 deletions(-) create mode 100644 frontend/components/forms/customer.tsx diff --git a/backend/src/routes/customer.rs b/backend/src/routes/customer.rs index 2ca0e18..27354c9 100644 --- a/backend/src/routes/customer.rs +++ b/backend/src/routes/customer.rs @@ -8,7 +8,7 @@ use actix_web::{ HttpResponse, Responder, Scope, web::{Data, Json, Path, Query}, }; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use sqlx::PgPool; use uuid::Uuid; @@ -21,6 +21,7 @@ struct QueryParams { pub limit: Option, pub offset: Option, pub sort_order: Option, + pub sort_by: Option, pub name: Option, } @@ -41,6 +42,13 @@ async fn post(pool: Data, body: Json, user: User) -> impl Respon } } +#[derive(Serialize)] +struct CustomerListResponse { + rows: Vec, + total: i64, + page_count: i64, +} + #[actix_web::get("")] async fn get_all(pool: Data, options: Query, user: User) -> impl Responder { if !user.permissions.contains(Main::PERM_READ) { @@ -50,22 +58,45 @@ async fn get_all(pool: Data, options: Query, user: User) -> )); } - let limit = options.limit.unwrap_or(100) as i64; + let limit = options.limit.unwrap_or(100).clamp(1, 1000) as i64; let offset = options.offset.unwrap_or(0) as i64; let sort_asc = options.sort_order.unwrap_or(SortOrder::Desc) == SortOrder::Asc; + let sort_by = match options.sort_by.as_deref() { + _ => "name", + }; - let mut query = sqlx::QueryBuilder::new(format!("SELECT * FROM {}", Main::TABLE_NAME)); - query.push(" WHERE 1=1"); + let name_like = options.name.as_ref().map(|n| format!("%{}%", n)); - if let Some(name) = &options.name { - if !name.is_empty() { - query.push(" AND name ILIKE "); - query.push_bind(format!("%{}%", name)); - query.push(" ESCAPE '\\'"); + let mut count_query = + sqlx::QueryBuilder::new(&format!("SELECT COUNT(*) FROM {}", Main::TABLE_NAME)); + count_query.push(" WHERE 1=1"); + + if let Some(like) = &name_like { + count_query.push(" AND name ILIKE "); + count_query.push_bind(like); + } + + let total: i64 = match count_query + .build_query_scalar::() + .fetch_one(pool.get_ref()) + .await + { + Ok(n) => n, + Err(err) => { + tracing::error!("count query failed: {err:?}"); + return HttpResponse::InternalServerError().finish(); } + }; + + let mut query = sqlx::QueryBuilder::new(&format!("SELECT * FROM {}", Main::TABLE_NAME)); + query.push(" WHERE 1=1"); + + if let Some(like) = &name_like { + query.push(" AND name ILIKE "); + query.push_bind(like); } - query.push(" ORDER BY name "); + query.push(format!(" ORDER BY {sort_by} ")); if sort_asc { query.push("ASC"); } else { @@ -77,12 +108,18 @@ async fn get_all(pool: Data, options: Query, user: User) -> query.push(" OFFSET "); query.push_bind(offset); + let page_count = (total + limit - 1) / limit; + match query - .build_query_as::
() + .build_query_as::() .fetch_all(pool.get_ref()) .await { - Ok(batches) => HttpResponse::Ok().json(batches), + Ok(customer) => HttpResponse::Ok().json(CustomerListResponse { + rows: customer, + page_count, + total, + }), Err(err) => { tracing::error!("{err:?}"); HttpResponse::InternalServerError().finish() diff --git a/frontend/components/dashboard.tsx b/frontend/components/dashboard.tsx index 2082119..65eb5a3 100644 --- a/frontend/components/dashboard.tsx +++ b/frontend/components/dashboard.tsx @@ -115,8 +115,7 @@ function pageMap( // return break case "customers": - // return - break + return case "flavors": return case "ingredients": diff --git a/frontend/components/forms/customer.tsx b/frontend/components/forms/customer.tsx new file mode 100644 index 0000000..d78b5c8 --- /dev/null +++ b/frontend/components/forms/customer.tsx @@ -0,0 +1,117 @@ +"use client" + +import { useState } from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import * as z from "zod" + +import { Button } from "@/components/ui/button" +import { + Field, + FieldError, + FieldGroup, + FieldLabel, +} from "@/components/ui/field" +import { Input } from "@/components/ui/input" + +const schema = z.object({ + name: z.string().min(1, { message: "Name is required." }), + code: z.string().min(1, { message: "Phone number is required." }), +}) + +type FormValues = z.infer + +export interface Customer { + id: string + name: string + code: string +} + +export function CustomerForm({ + customerEdit, + setEditOpen, + refetchItems, +}: { + customerEdit: Customer | null + setEditOpen: React.Dispatch> + refetchItems: () => void +}) { + const isEditing = customerEdit !== null + const [serverError, setServerError] = useState(null) + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(schema), + }) + + async function onSubmit(values: FormValues) { + setServerError(null) + + const res = await fetch( + `/api/customers${isEditing ? `/${customerEdit.id}` : ""}`, + { + method: isEditing ? "PATCH" : "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(values), + } + ) + if (!res.ok) { + const data = await res.json() + setServerError(data.error || "An error occurred.") + return + } + setEditOpen(false) + refetchItems() + } + + return ( +
+ + + Name + + + + + + Code + + + + + + {serverError && ( +

{serverError}

+ )} + +
+ + +
+
+
+ ) +} diff --git a/frontend/components/pages/customers.tsx b/frontend/components/pages/customers.tsx index 38ce26f..caaddb1 100644 --- a/frontend/components/pages/customers.tsx +++ b/frontend/components/pages/customers.tsx @@ -1,3 +1,342 @@ -export function CustomersPage() { - return +"use client" + +import { useEffect, useMemo, useState } from "react" +import { + ColumnDef, + flexRender, + getCoreRowModel, + SortingState, + useReactTable, + PaginationState, + ColumnFiltersState, +} from "@tanstack/react-table" +import { + 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" +import { Customer, CustomerForm } from "../forms/customer" + +interface PaginatedCustomers { + rows: Customer[] + total: number + page_count: number +} + +const SORTABLE_COLUMNS = ["name"] + +export function CustomersPage({ userPerms }: { userPerms: UserPermissions }) { + const [editOpen, setEditOpen] = useState(false) + const [deleteOpen, setDeleteOpen] = useState(false) + const [deleteError, setDeleteError] = useState<string | null>(null) + const [customer, setCustomer] = useState<Customer | 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 ?? "name" + const sortOrder = sorting.find((s) => SORTABLE_COLUMNS.includes(s.id))?.desc + ? "desc" + : "asc" + + const { data, isLoading, error, refetch } = useQuery<PaginatedCustomers>({ + queryKey: [ + "users", + 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/customers?${params.toString()}`) + if (!res.ok) throw new Error("Failed to fetch users") + return res.json() + }, + }) + + const rows = data?.rows ?? [] + const pageCount = data?.page_count ?? 0 + + const columns = useMemo<ColumnDef<Customer>[]>( + () => [ + { + accessorKey: "id", + header: "ID", + size: 150, + cell: ({ row }) => ( + <span + className="truncate font-mono text-xs" + title={row.getValue("id")} + > + {row.getValue("id")} + </span> + ), + }, + { + accessorKey: "name", + header: ({ column }) => ( + <SortableFilterableColumnHeader + column={column} + title="Name" + placeholder="Filter..." + /> + ), + size: 100, + enableSorting: true, + enableColumnFilter: true, + }, + { + accessorKey: "code", + header: "Code", + size: 60, + enableSorting: false, + enableColumnFilter: false, + }, + { + accessorKey: "actions", + header: "Actions", + size: 10, + cell: ({ row }) => { + const supplier = 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) + setCustomer(supplier) + }} + disabled={!userPerms.contains(UserPermissions.CustomerUpdate)} + className="cursor-pointer" + > + Edit + </DropdownMenuItem> + <DropdownMenuItem + onClick={() => { + setDeleteOpen(true) + setCustomer(supplier) + }} + disabled={!userPerms.contains(UserPermissions.CustomerDelete)} + className="cursor-pointer" + > + Delete + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) + }, + }, + ], + [userPerms] + ) + + // eslint-disable-next-line react-hooks/incompatible-library + 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) setCustomer(null) + }, [editOpen, deleteOpen]) + + async function deleteCustomer(id: string) { + const res = await fetch(`/api/customers/${id}`, { + method: "DELETE", + }) + if (res.ok) { + refetch() + } else { + const data = await res.json() + setDeleteError(data.error) + } + } + + 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(UserPermissions.CustomerCreate)} + onClick={() => setEditOpen(true)} + > + <UserPlus2 /> New Customer + </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={ + 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={`${customer ? "Edit" : "Create"} ${customer?.name ?? "Customer"}`} + description={ + customer ? "Make changes to this customer." : "Create a new customer." + } + > + <CustomerForm + customerEdit={customer} + setEditOpen={setEditOpen} + refetchItems={refetch} + /> + </Modal> + + <Modal + open={deleteOpen} + onOpenChange={setDeleteOpen} + trigger={null} + title={`Delete ${customer?.name ?? "Customer"}`} + 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 (customer) void deleteCustomer(customer.id) + setDeleteOpen(false) + }} + > + Delete + </Button> + </div> + </Modal> + </> + ) } -- 2.51.2