From 3689e837c44fed2cfaa773e710341eb2be4a94b9 Mon Sep 17 00:00:00 2001 From: Samuel Shuert Date: Fri, 17 Jul 2026 00:44:28 +0000 Subject: [PATCH] feat(frontend): implement flavors page --- backend/src/models/ingredient.rs | 28 +- backend/src/routes/flavor.rs | 61 ++- backend/src/routes/ingredient_type.rs | 47 +- backend/src/routes/supplier.rs | 2 +- frontend/components/dashboard.tsx | 3 +- .../components/forms/all_ingredient_types.tsx | 170 +++++++ frontend/components/forms/flavor.tsx | 180 ++++++++ frontend/components/forms/ingredient_type.tsx | 89 ++++ frontend/components/pages/flavors.tsx | 430 +++++++++++++++++- frontend/components/pages/template | 3 +- 10 files changed, 968 insertions(+), 45 deletions(-) create mode 100644 frontend/components/forms/all_ingredient_types.tsx create mode 100644 frontend/components/forms/flavor.tsx create mode 100644 frontend/components/forms/ingredient_type.tsx diff --git a/backend/src/models/ingredient.rs b/backend/src/models/ingredient.rs index 996051d..a0211e6 100644 --- a/backend/src/models/ingredient.rs +++ b/backend/src/models/ingredient.rs @@ -86,15 +86,11 @@ impl CrudBackend for IngredientType { } async fn delete_inner(&self, conn: &mut PgConnection) -> Result<()> { - sqlx::query_as!( - IngredientType, - "DELETE FROM ingredients_type WHERE id = $1", - self.id - ) - .fetch_one(&mut *conn) - .await - .map_err(Into::into) - .map(|_| ()) + sqlx::query!("DELETE FROM ingredients_type WHERE id = $1", self.id) + .execute(&mut *conn) + .await + .map_err(Into::into) + .map(|_| ()) } } @@ -211,14 +207,10 @@ impl CrudBackend for Ingredient { } async fn delete_inner(&self, conn: &mut PgConnection) -> Result<()> { - sqlx::query_as!( - Ingredient, - "DELETE FROM ingredients WHERE id = $1 RETURNING *", - self.id - ) - .fetch_one(&mut *conn) - .await - .map_err(Into::into) - .map(|_| ()) + sqlx::query!("DELETE FROM ingredients WHERE id = $1", self.id) + .execute(&mut *conn) + .await + .map_err(Into::into) + .map(|_| ()) } } diff --git a/backend/src/routes/flavor.rs b/backend/src/routes/flavor.rs index 447c11c..9e86d52 100644 --- a/backend/src/routes/flavor.rs +++ b/backend/src/routes/flavor.rs @@ -9,7 +9,7 @@ use actix_web::{ HttpResponse, Responder, Scope, web::{Data, Json, Path, Query}, }; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use sqlx::{PgPool, error::DatabaseError}; use uuid::Uuid; @@ -22,6 +22,7 @@ struct QueryParams { pub limit: Option, pub offset: Option, pub sort_order: Option, + pub sort_by: Option, pub name: Option, } @@ -42,6 +43,13 @@ async fn post(pool: Data, body: Json, user: User) -> impl Respon } } +#[derive(Serialize)] +struct FlavorListResponse { + 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) { @@ -51,22 +59,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 { @@ -78,12 +109,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(supplier) => HttpResponse::Ok().json(FlavorListResponse { + rows: supplier, + page_count, + total, + }), Err(err) => { tracing::error!("{err:?}"); HttpResponse::InternalServerError().finish() diff --git a/backend/src/routes/ingredient_type.rs b/backend/src/routes/ingredient_type.rs index a410be0..7750502 100644 --- a/backend/src/routes/ingredient_type.rs +++ b/backend/src/routes/ingredient_type.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, } #[actix_web::post("")] @@ -40,6 +41,13 @@ async fn post(pool: Data, body: Json, user: User) -> impl Respon } } +#[derive(Serialize)] +struct ITListResponse { + 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) { @@ -49,16 +57,33 @@ 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 mut count_query = + sqlx::QueryBuilder::new(&format!("SELECT COUNT(*) FROM {}", Main::TABLE_NAME)); + count_query.push(" WHERE 1=1"); - // TODO: ADD more filters + 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(); + } + }; - query.push(" ORDER BY name "); + let mut query = sqlx::QueryBuilder::new(&format!("SELECT * FROM {}", Main::TABLE_NAME)); + query.push(" WHERE 1=1"); + + query.push(format!(" ORDER BY {sort_by} ")); if sort_asc { query.push("ASC"); } else { @@ -70,12 +95,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(supplier) => HttpResponse::Ok().json(ITListResponse { + rows: supplier, + page_count, + total, + }), Err(err) => { tracing::error!("{err:?}"); HttpResponse::InternalServerError().finish() diff --git a/backend/src/routes/supplier.rs b/backend/src/routes/supplier.rs index 5089317..b4510a9 100644 --- a/backend/src/routes/supplier.rs +++ b/backend/src/routes/supplier.rs @@ -69,7 +69,7 @@ async fn get_all(pool: Data, options: Query, user: User) -> let name_like = options.name.as_ref().map(|n| format!("%{}%", n)); let phone_like = options.phone.as_ref().map(|p| format!("%{}%", p)); - let address_like = options.phone.as_ref().map(|a| format!("%{}%", a)); + let address_like = options.address.as_ref().map(|a| format!("%{}%", a)); let mut count_query = sqlx::QueryBuilder::new(&format!("SELECT COUNT(*) FROM {}", Main::TABLE_NAME)); diff --git a/frontend/components/dashboard.tsx b/frontend/components/dashboard.tsx index 2a212bd..2082119 100644 --- a/frontend/components/dashboard.tsx +++ b/frontend/components/dashboard.tsx @@ -118,8 +118,7 @@ function pageMap( // return break case "flavors": - // return - break + return case "ingredients": // return break diff --git a/frontend/components/forms/all_ingredient_types.tsx b/frontend/components/forms/all_ingredient_types.tsx new file mode 100644 index 0000000..dfbddfe --- /dev/null +++ b/frontend/components/forms/all_ingredient_types.tsx @@ -0,0 +1,170 @@ +"use client" + +import { useMemo, useState } from "react" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + TableRowSkeleton, +} from "../ui/table" +import { + ColumnDef, + flexRender, + getCoreRowModel, + PaginationState, + useReactTable, +} from "@tanstack/react-table" +import { Button } from "../ui/button" +import { Trash } from "lucide-react" +import { useQuery } from "@tanstack/react-query" +import { TablePaginator } from "../tablePages" + +async function deleteIngredientType(id: string) { + const res = await fetch(`/api/ingredients_type/${id}`, { + method: "DELETE", + }) + if (!res.ok) { + const data = await res.json() + alert(data.error || "An error occurred.") + return + } +} + +interface PaginatedResponse { + rows: { id: string; name: string }[] + total: number + page_count: number +} + +export function AllIngredientTypes() { + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }) + const limit = pagination.pageSize + const offset = pagination.pageIndex * pagination.pageSize + + const { data, isLoading, refetch } = useQuery({ + queryKey: [limit, offset], + queryFn: async () => { + const params = new URLSearchParams({ + limit: String(limit), + offset: String(offset), + sort_by: "name", + sort_order: "asc", + }) + + const res = await fetch(`/api/ingredients_type?${params.toString()}`) + if (!res.ok) throw new Error(`Failed to fetch ingredient types`) + return res.json() + }, + }) + + const rows = data?.rows ?? [] + const pageCount = data?.page_count ?? 0 + + const columns = useMemo[]>( + () => [ + { + accessorKey: "name", + header: "Name", + size: 500, + enableSorting: true, + enableColumnFilter: true, + }, + { + accessorKey: "delete", + header: "Delete", + size: 50, + cell: ({ row }) => { + const item = row.original + return ( + + ) + }, + }, + ], + [refetch] + ) + + // eslint-disable-next-line react-hooks/incompatible-library + const table = useReactTable({ + data: rows, + columns, + pageCount, + state: { pagination }, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + manualPagination: true, + manualSorting: true, + manualFiltering: true, + }) + + return ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {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)} + /> +
+
+ ) +} diff --git a/frontend/components/forms/flavor.tsx b/frontend/components/forms/flavor.tsx new file mode 100644 index 0000000..49eabda --- /dev/null +++ b/frontend/components/forms/flavor.tsx @@ -0,0 +1,180 @@ +"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" + +const schema = z.object({ + name: z.string().min(1, { message: "Name is required." }), + short_code: z.string().min(1, { message: "Short Code is required." }), + ingredient_types: z.array( + z.string().min(1, { message: "Ingredient is required." }) + ), +}) + +type FormValues = z.infer + +export interface Flavor { + id: string + name: string + short_code: string +} + +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 }) => ingredient.name) +} + +async function getIngredientTypes( + query: string, + page: number +): Promise { + const res = await fetch(`/api/ingredients_type?query=${query}&page=${page}`) + const data = await res.json() + return data.map((type: { name: string; id: string }) => ({ + label: type.name, + value: type.id, + })) +} + +export function FlavorForm({ + flavorEdit, + setEditOpen, + refetchItems, +}: { + flavorEdit: Flavor | null + setEditOpen: React.Dispatch> + refetchItems: () => void +}) { + const isEditing = flavorEdit !== null + const [serverError, setServerError] = useState(null) + + const [ingredients, setIngredients] = useState([]) + + useEffect(() => { + async function fetchIngredients() { + if (flavorEdit) { + const ingredients = await getIngredientsForFlavor(flavorEdit.id) + setIngredients(ingredients ?? []) + } + } + fetchIngredients() + }, [flavorEdit]) + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + control, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: flavorEdit + ? { + ...flavorEdit, + ingredient_types: ingredients, + } + : { + ingredient_types: [], + }, + }) + + async function onSubmit(values: FormValues) { + setServerError(null) + + const res = await fetch( + `/api/flavors${isEditing ? `/${flavorEdit.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 + + + + + + Short code + + + + + + Ingredients + ( + + )} + /> + + + + + {serverError && ( +

{serverError}

+ )} + +
+ + +
+
+
+ ) +} diff --git a/frontend/components/forms/ingredient_type.tsx b/frontend/components/forms/ingredient_type.tsx new file mode 100644 index 0000000..0e3a2b6 --- /dev/null +++ b/frontend/components/forms/ingredient_type.tsx @@ -0,0 +1,89 @@ +"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." }), +}) + +type FormValues = z.infer + +export function IngredientTypeForm({ + setEditOpen, +}: { + setEditOpen: React.Dispatch> +}) { + 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/ingredients_type`, { + method: "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) + } + + return ( +
+ + + Name + + + + + + {serverError && ( +

{serverError}

+ )} + +
+ + +
+
+
+ ) +} diff --git a/frontend/components/pages/flavors.tsx b/frontend/components/pages/flavors.tsx index 23f0e69..63fdf51 100644 --- a/frontend/components/pages/flavors.tsx +++ b/frontend/components/pages/flavors.tsx @@ -1,3 +1,429 @@ -export function FlavorsPage() { - 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" + +const ENTITY_NAME = "Flavor" +const ENTITY_PLURAL = "flavors" +const API_PATH = "/api/flavors" + +const SORTABLE_COLUMNS = ["name"] +const DEFAULT_SORT_BY = "name" + +const PERM_CREATE = UserPermissions.SuperAdmin +const PERM_UPDATE = UserPermissions.SuperAdmin +const PERM_DELETE = UserPermissions.SuperAdmin + +import { FlavorForm as EntityForm, Flavor as Entity } from "../forms/flavor" +import { IngredientTypeForm } from "../forms/ingredient_type" +import { AllIngredientTypes } from "../forms/all_ingredient_types" + +interface PaginatedResponse { + rows: Entity[] + total: number + page_count: number +} + +const DISPLAY_PROP: keyof Entity = "name" + +interface Props { + userPerms: UserPermissions +} + +function IngredientCell({ flavorId }: { flavorId: string }) { + const { data: flavor, isLoading } = useQuery({ + queryKey: ["flavor", flavorId], + queryFn: async () => { + const res = await fetch(`/api/flavors/${flavorId}/ingredients`) + 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> +} + +export function FlavorsPage({ userPerms }: Props) { + const [editOpen, setEditOpen] = useState(false) + const [newIngredientTypeOpen, setNewIngredientTypeOpen] = useState(false) + const [ingredientTypeOpen, setIngredientTypeOpen] = 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: "name", + header: ({ column }) => ( + <SortableFilterableColumnHeader + column={column} + title="Name" + placeholder="Filter..." + /> + ), + size: 150, + enableSorting: true, + enableColumnFilter: true, + }, + { + accessorKey: "short_code", + header: "Short Code", + size: 150, + }, + { + accessorKey: "ingredients", + header: "Ingredients", + size: 150, + cell: ({ row }) => { + const flavorId = row.getValue("id") as string + return <IngredientCell flavorId={flavorId} /> + }, + }, + { + 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 gap-2"> + {deleteError && ( + <p className="text-sm text-destructive">{deleteError}</p> + )} + <Button + variant="outline" + size="sm" + disabled={!userPerms.contains(PERM_CREATE)} + onClick={() => setIngredientTypeOpen(true)} + > + <UserPlus2 /> All Ingredient Types + </Button> + <Button + variant="outline" + size="sm" + disabled={!userPerms.contains(PERM_CREATE)} + onClick={() => setNewIngredientTypeOpen(true)} + > + <UserPlus2 /> New Ingredient Type + </Button> + <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" + : undefined + } + > + {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 + flavorEdit={entity} + setEditOpen={setEditOpen} + refetchItems={refetch} + /> + </Modal> + + <Modal + open={ingredientTypeOpen} + onOpenChange={setIngredientTypeOpen} + trigger={null} + title="All Ingredient Types" + description="View all ingredient types." + > + <AllIngredientTypes /> + </Modal> + + <Modal + open={newIngredientTypeOpen} + onOpenChange={setNewIngredientTypeOpen} + trigger={null} + title="Create Ingredient Type" + description="Create a new ingredient type." + > + <IngredientTypeForm setEditOpen={setNewIngredientTypeOpen} /> + </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/template b/frontend/components/pages/template index 4a5aca2..7aeb634 100644 --- a/frontend/components/pages/template +++ b/frontend/components/pages/template @@ -11,7 +11,6 @@ import { ColumnFiltersState, } from "@tanstack/react-table" import { - SortableHeader, SortableFilterableColumnHeader, Table, TableBody, @@ -315,7 +314,7 @@ export function ItemsPage({ userPerms }: Props) { <EntityForm itemEdit={entity} setEditOpen={setEditOpen} - refreshItems={refetch} + refetchItems={refetch} /> </Modal> -- 2.51.2