From 249328fbfadcd101561f48958cb296d5cc0da9de Mon Sep 17 00:00:00 2001 From: Samuel Shuert Date: Tue, 21 Jul 2026 22:31:39 +0000 Subject: [PATCH] feat(frontend): implement batch page --- frontend/components/dashboard.tsx | 3 +-- backend/src/models/batch.rs | 4 ++-- backend/src/routes/batch.rs | 88 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------- backend/src/routes/ingredient.rs | 4 ++-- frontend/components/forms/batch.tsx | 209 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ frontend/components/pages/batches.tsx | 418 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- frontend/components/pages/flavors.tsx | 18 ++++++++++++++---- 7 file(s) changed, 705 insertion(s)(+), 39 deletion(s)(-) diff --git a/frontend/components/dashboard.tsx b/frontend/components/dashboard.tsx --- a/frontend/components/dashboard.tsx +++ b/frontend/components/dashboard.tsx @@ -112,8 +112,7 @@ ): React.ReactNode { switch (page) { case "batches": - // return - break + return case "customers": return case "flavors": diff --git a/backend/src/models/batch.rs b/backend/src/models/batch.rs --- a/backend/src/models/batch.rs +++ b/backend/src/models/batch.rs @@ -26,7 +26,7 @@ pub struct BatchCreate { flavor: Uuid, lot_number: String, - ingredients: Vec, + ingredients: Vec, } impl Update for BatchUpdate { @@ -73,7 +73,7 @@ r#" INSERT INTO batch_ingredients (batch_id, ingredient_id) VALUES ($1, $2)"#, batch.id, - ingredient.id + ingredient ) .execute(&mut *conn) .await?; diff --git a/backend/src/routes/batch.rs b/backend/src/routes/batch.rs --- a/backend/src/routes/batch.rs +++ b/backend/src/routes/batch.rs @@ -8,7 +8,7 @@ HttpResponse, Responder, Scope, web::{Data, Json, Path, Query}, }; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use sqlx::PgPool; use sqlx::error::DatabaseError; use uuid::Uuid; @@ -22,10 +22,9 @@ pub limit: Option, pub offset: Option, pub sort_order: Option, - pub before: Option>, - pub after: Option>, + pub sort_by: Option, pub flavor: Option, - pub lot: Option, + pub lot_number: Option, } #[actix_web::post("")] @@ -45,6 +44,13 @@ } } +#[derive(Serialize)] +struct BatchListResponse { + 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) { @@ -54,34 +60,56 @@ )); } - 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() { + _ => "created_at", + }; - let mut query = sqlx::QueryBuilder::new(format!("SELECT * FROM {}", Main::TABLE_NAME)); + let lot_like = options.lot_number.as_ref().map(|n| format!("%{}%", n)); + let flavor = options.flavor.as_ref(); + + let mut count_query = + sqlx::QueryBuilder::new(&format!("SELECT COUNT(*) FROM {}", Main::TABLE_NAME)); + count_query.push(" WHERE 1=1"); + + if let Some(like) = &lot_like { + count_query.push(" AND lot_number ILIKE "); + count_query.push_bind(like); + } + + if let Some(it) = &flavor { + count_query.push(" AND flavor = "); + count_query.push_bind(it); + } + + 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(after) = options.after { - query.push(" AND created_at > "); - query.push_bind(after); - } - if let Some(before) = options.before { - query.push(" AND created_at < "); - query.push_bind(before); - } - if let Some(flavor) = options.flavor { - query.push(" AND flavor_id = "); - query.push_bind(flavor); - } - if let Some(lot) = &options.lot { - if !lot.is_empty() { - query.push(" AND name ILIKE "); - query.push_bind(format!("%{}%", lot)); - query.push(" ESCAPE '\\'"); - } + if let Some(like) = &lot_like { + query.push(" AND lot ILIKE "); + query.push_bind(like); } - query.push(" ORDER BY created_at "); + if let Some(it) = &flavor { + query.push(" AND ingredient_type = "); + query.push_bind(it); + } + + query.push(format!(" ORDER BY {sort_by} ")); if sort_asc { query.push("ASC"); } else { @@ -93,12 +121,18 @@ 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(batch) => HttpResponse::Ok().json(BatchListResponse { + rows: batch, + page_count, + total, + }), Err(err) => { tracing::error!("{err:?}"); HttpResponse::InternalServerError().finish() diff --git a/backend/src/routes/ingredient.rs b/backend/src/routes/ingredient.rs --- a/backend/src/routes/ingredient.rs +++ b/backend/src/routes/ingredient.rs @@ -70,8 +70,8 @@ }; let lot_like = options.lot.as_ref().map(|n| format!("%{}%", n)); - let ingredient_type = options.ingredient_type.as_ref().map(|n| format!("%{}%", n)); - let supplier = options.supplier.as_ref().map(|n| format!("%{}%", n)); + let ingredient_type = options.ingredient_type.as_ref(); + let supplier = options.supplier.as_ref(); let mut count_query = sqlx::QueryBuilder::new(&format!("SELECT COUNT(*) FROM {}", Main::TABLE_NAME)); diff --git a/frontend/components/forms/batch.tsx b/frontend/components/forms/batch.tsx new file mode 100644 --- /dev/null +++ b/frontend/components/forms/batch.tsx @@ -0,0 +1,209 @@ +"use client" + +import { useEffect, useState } from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { Controller, 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" +import { Option, AsyncCombobox } from "../ui/combobox" +import { DatePicker } from "../ui/date-picker" + +export async function getFlavors( + query: string, + page: number +): Promise { + const res = await fetch(`/api/flavors?query=${query}&page=${page}`) + const data = await res.json() + return data.rows.map((type: { name: string; id: string }) => ({ + label: type.name, + value: type.id, + })) +} + +async function getIngredientsForFlavor(flavorId: string): Promise { + const res = await fetch(`/api/flavors/${flavorId}/ingredients`) + const data = await res.json() + return data.map((ingredient: { name: string; id: string }) => ({ + label: ingredient.name, + value: ingredient.id, + })) +} + +function getOpenIngredientsByType( + ingredientTypeId: string +): (query: string, page: number) => Promise { + return async (query: string, page: number) => { + const res = await fetch( + `/api/ingredients?ingredient_type=${ingredientTypeId}&query=${query}&page=${page}` + ) + const data = await res.json() + return data.rows.map((ingredient: { lot: string; id: string }) => ({ + label: ingredient.lot, + value: ingredient.id, + })) + } +} + +const schema = z.object({ + lot_number: z.string().min(1, { message: "Lot Number is required." }), + flavor: z.uuid({ message: "Flavor is required." }), +}) + +type FormValues = z.infer + +export interface Batch { + id: string + lot_number: string + created_at: Date + flavor: string +} + +export function BatchForm({ + batchEdit, + setEditOpen, + refetchItems, +}: { + batchEdit: Batch | null + setEditOpen: React.Dispatch> + refetchItems: () => void +}) { + const isEditing = batchEdit !== null + const [serverError, setServerError] = useState(null) + const [requiredIngredients, setRequiredIngredients] = useState([]) + const [ingredients, setIngredients] = useState<[string, string[]][]>([]) + + useEffect(() => { + setIngredients([]) + }, [requiredIngredients]) + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + control, + watch, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: batchEdit ?? undefined, + }) + + // eslint-disable-next-line react-hooks/incompatible-library + const flavor = watch("flavor") + + useEffect(() => { + async function fetchIngredients() { + if (flavor) { + const ingredients = await getIngredientsForFlavor(flavor) + setRequiredIngredients(ingredients) + } + } + fetchIngredients() + }, [flavor]) + + async function onSubmit(values: FormValues) { + setServerError(null) + + const res = await fetch( + `/api/batches${isEditing ? `/${batchEdit.id}` : ""}`, + { + method: isEditing ? "PATCH" : "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ...values, + ingredients: ingredients.flatMap((i) => i[1]), + }), + } + ) + if (!res.ok) { + const data = await res.json() + setServerError(data.error || "An error occurred.") + return + } + setEditOpen(false) + refetchItems() + } + + return ( +
+ + + Lot Number + + + + + + Flavor + ( + + )} + /> + + + + + {requiredIngredients.map((ingredient, index) => ( + + + {ingredient.label} + + i[0] === ingredient.value)?.[1]} + onChange={(value) => + setIngredients((prev) => + prev + .filter((i) => i[0] !== ingredient.value) + .concat([[ingredient.value, value]]) + ) + } + /> + + ))} + + + {serverError && ( +

{serverError}

+ )} + +
+ + +
+
+
+ ) +} diff --git a/frontend/components/pages/batches.tsx b/frontend/components/pages/batches.tsx --- a/frontend/components/pages/batches.tsx +++ b/frontend/components/pages/batches.tsx @@ -1,3 +1,417 @@ -export function BatchesPage() { - 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" + +function FlavorCell({ flavorId }: { flavorId: string }) { + const { data: flavor, isLoading } = useQuery({ + queryKey: ["flavor", flavorId], + queryFn: async () => { + const res = await fetch(`/api/flavors/${flavorId}`) + return res.json() + }, + staleTime: 5 * 60 * 1000, + }) + + if (isLoading) return <span className="text-muted">Loading...</span> + if (!flavor) return <span className="text-red-500">Unknown</span> + + return <span>{flavor.name}</span> +} + +const ENTITY_NAME = "Batch" +const ENTITY_PLURAL = "batches" +const API_PATH = "/api/batches" + +const SORTABLE_COLUMNS = ["created_at"] +const DEFAULT_SORT_BY = "created_at" + +const PERM_CREATE = UserPermissions.BatchCreate +const PERM_UPDATE = UserPermissions.BatchUpdate +const PERM_DELETE = UserPermissions.BatchDelete + +import { + BatchForm as EntityForm, + Batch as Entity, + getFlavors, +} from "../forms/batch" + +interface PaginatedResponse { + rows: Entity[] + total: number + page_count: number +} + +const DISPLAY_PROP: keyof Entity = "lot_number" + +interface Props { + userPerms: UserPermissions +} + +export function BatchesPage({ 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: 250, + cell: ({ row }) => ( + <span + className="truncate font-mono text-xs" + title={row.getValue("id")} + > + {row.getValue("id")} + </span> + ), + }, + { + accessorKey: "lot_number", + header: ({ column }) => ( + <SortableFilterableColumnHeader + column={column} + title="Lot #" + placeholder="Filter..." + /> + ), + size: 250, + enableColumnFilter: true, + enableSorting: false, + }, + { + accessorKey: "flavor", + header: ({ column }) => ( + <SortableFilterableColumnHeader + column={column} + title="Flavor" + placeholder="Filter..." + get_options={getFlavors} + /> + ), + size: 250, + enableColumnFilter: true, + enableSorting: false, + cell: ({ row }) => { + const flavor = row.getValue("flavor") as string + return <FlavorCell flavorId={flavor} /> + }, + }, + { + accessorKey: "created_at", + header: ({ column }) => ( + <SortableFilterableColumnHeader + column={column} + title="Date Created" + placeholder="Filter..." + /> + ), + size: 150, + enableSorting: true, + enableColumnFilter: false, + cell: ({ row }) => { + const dateCreated = row.getValue("created_at") as Date + return <span>{new Date(dateCreated).toLocaleDateString()}</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] + ) + + // 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) 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-full 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={`${entity ? "Edit" : "Create"} ${entityTitle}`} + description={ + entity + ? `Make changes to this ${ENTITY_NAME.toLowerCase()}.` + : `Create a new ${ENTITY_NAME.toLowerCase()}.` + } + > + <EntityForm + batchEdit={entity} + setEditOpen={setEditOpen} + refetchItems={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> + </> + ) } diff --git a/frontend/components/pages/flavors.tsx b/frontend/components/pages/flavors.tsx --- a/frontend/components/pages/flavors.tsx +++ b/frontend/components/pages/flavors.tsx @@ -67,8 +67,12 @@ } function IngredientCell({ flavorId }: { flavorId: string }) { - const { data: flavor, isLoading } = useQuery({ - queryKey: ["flavor", flavorId], + const { + data: ingredients, + isLoading, + error, + } = useQuery({ + queryKey: ["flavor", flavorId, "ingredients"], queryFn: async () => { const res = await fetch(`/api/flavors/${flavorId}/ingredients`) return res.json() @@ -77,11 +81,17 @@ }) if (isLoading) return <span className="text-muted">Loading...</span> - if (!flavor) return <span className="text-red-500">Unknown</span> + if (!ingredients || !Array.isArray(ingredients)) { + console.log(ingredients) + + return ( + <span className="text-red-500">{error?.toString() || "Unknown"}</span> + ) + } return ( <div className="flex gap-1"> - {flavor.map((f_is: { id: string; name: string }) => ( + {ingredients.map((f_is: { id: string; name: string }) => ( <Badge key={f_is.id}>{f_is.name}</Badge> ))} </div> -- tangled.sh