diff --git a/backend/src/routes/ingredient.rs b/backend/src/routes/ingredient.rs index b716445..ca78c0f 100644 --- a/backend/src/routes/ingredient.rs +++ b/backend/src/routes/ingredient.rs @@ -8,8 +8,7 @@ use actix_web::{ HttpResponse, Responder, Scope, web::{Data, Json, Path, Query}, }; -use chrono::{DateTime, Utc}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use sqlx::PgPool; use uuid::Uuid; @@ -22,10 +21,10 @@ struct QueryParams { pub limit: Option, pub offset: Option, pub sort_order: Option, + pub sort_by: Option, pub lot: Option, - pub finished_before: Option>, - pub finished_after: Option>, pub ingredient_type: Option, + pub supplier: Option, } #[actix_web::post("")] @@ -45,6 +44,13 @@ async fn post(pool: Data, body: Json, user: User) -> impl Respon } } +#[derive(Serialize)] +struct IngredientListResponse { + 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,69 @@ 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() { + Some("date_opened") => "date_opened", + Some("date_finished") => "date_finished", + _ => "date_recieved", + }; - let mut query = sqlx::QueryBuilder::new(format!("SELECT * FROM {}", Main::TABLE_NAME)); - query.push(" WHERE 1=1"); + 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 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 ILIKE "); + count_query.push_bind(like); + } + + if let Some(it) = &ingredient_type { + count_query.push(" AND ingredient_type = "); + count_query.push_bind(it); + } - 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(sup) = &supplier { + count_query.push(" AND supplier = "); + count_query.push_bind(sup); + } + + 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) = &lot_like { + query.push(" AND lot ILIKE "); + query.push_bind(like); } - if let Some(ingredient_type) = &options.ingredient_type { + + if let Some(it) = &ingredient_type { query.push(" AND ingredient_type = "); - query.push_bind(ingredient_type); - } - if let Some(finished_before) = &options.finished_before { - query.push(" AND date_finished < "); - query.push_bind(finished_before); + query.push_bind(it); } - if let Some(finished_after) = &options.finished_after { - query.push(" AND date_finished > "); - query.push_bind(finished_after); + + if let Some(sup) = &supplier { + query.push(" AND supplier = "); + query.push_bind(sup); } - query.push(" ORDER BY date_recieved "); + query.push(format!(" ORDER BY {sort_by} ")); if sort_asc { query.push("ASC"); } else { @@ -93,12 +134,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(ingredient) => HttpResponse::Ok().json(IngredientListResponse { + rows: ingredient, + page_count, + total, + }), Err(err) => { tracing::error!("{err:?}"); HttpResponse::InternalServerError().finish() diff --git a/frontend/components/dashboard.tsx b/frontend/components/dashboard.tsx index 65eb5a3..22080b1 100644 --- a/frontend/components/dashboard.tsx +++ b/frontend/components/dashboard.tsx @@ -1,6 +1,6 @@ "use client" -import { useEffect, useMemo, useState, useSyncExternalStore } from "react" +import { useEffect, useMemo, useSyncExternalStore } from "react" import { useRouter } from "next/navigation" import { User } from "@/lib/session" import { UserPermissions } from "@/lib/userPermissions" @@ -119,8 +119,7 @@ function pageMap( case "flavors": return case "ingredients": - // return - break + return case "orders": return case "suppliers": diff --git a/frontend/components/forms/ingredient.tsx b/frontend/components/forms/ingredient.tsx new file mode 100644 index 0000000..2426e7f --- /dev/null +++ b/frontend/components/forms/ingredient.tsx @@ -0,0 +1,200 @@ +"use client" + +import { 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" + +const schema = z.object({ + ingredient_type: z.uuid(), + supplier: z.uuid(), + date_recieved: z + .date({ message: "Date Received is required." }) + .transform((date) => new Date(date)), + date_opened: z.date().optional(), + date_finished: z.date().optional(), + lot: z.string().min(1, { message: "Lot is required." }), +}) + +type FormValues = z.infer + +export interface Ingredient { + id: string + ingredient_type: string + supplier: string + date_recieved: Date + date_opened?: Date + date_finished?: Date + lot: string +} + +export 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.rows.map((type: { name: string; id: string }) => ({ + label: type.name, + value: type.id, + })) +} + +export async function getSuppliers( + query: string, + page: number +): Promise { + const res = await fetch(`/api/suppliers?query=${query}&page=${page}`) + const data = await res.json() + return data.rows.map((type: { name: string; id: string }) => ({ + label: type.name, + value: type.id, + })) +} + +export function IngredientForm({ + ingredientEdit, + setEditOpen, + refetchItems, +}: { + ingredientEdit: Ingredient | null + setEditOpen: React.Dispatch> + refetchItems: () => void +}) { + const isEditing = ingredientEdit !== null + const [serverError, setServerError] = useState(null) + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + control, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: ingredientEdit ?? { + ingredient_type: "", + supplier: "", + date_recieved: new Date(), + date_opened: undefined, + date_finished: undefined, + lot: "", + }, + }) + + async function onSubmit(values: FormValues) { + setServerError(null) + + const res = await fetch( + `/api/ingredients${isEditing ? `/${ingredientEdit.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 ( +
+ + + Ingredient Type + ( + + )} + /> + + + + + Supplier + ( + + )} + /> + + + + + Lot Number + + + + + + Date Received + ( + + )} + /> + + + + + {serverError && ( +

{serverError}

+ )} + +
+ + +
+
+
+ ) +} diff --git a/frontend/components/pages/ingredients.tsx b/frontend/components/pages/ingredients.tsx index 408b3fe..d8ab59f 100644 --- a/frontend/components/pages/ingredients.tsx +++ b/frontend/components/pages/ingredients.tsx @@ -1,3 +1,542 @@ -export function IngredientsPage() { - 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 ITCell({ itId }: { itId: string }) { + const { data: it, isLoading } = useQuery({ + queryKey: ["ingredient_type", itId], + queryFn: async () => { + const res = await fetch(`/api/ingredients_type/${itId}`) + return res.json() + }, + staleTime: 5 * 60 * 1000, + }) + + if (isLoading) return <span className="text-muted">Loading...</span> + if (!it) return <span className="text-red-500">Unknown</span> + + return <span>{it.name}</span> +} + +function SupplierCell({ supplierId }: { supplierId: string }) { + const { data: supplier, isLoading } = useQuery({ + queryKey: ["supplier", supplierId], + queryFn: async () => { + const res = await fetch(`/api/suppliers/${supplierId}`) + return res.json() + }, + staleTime: 5 * 60 * 1000, + }) + + if (isLoading) return <span className="text-muted">Loading...</span> + if (!supplier) return <span className="text-red-500">Unknown</span> + + return <span>{supplier.name}</span> +} + +const ENTITY_NAME = "Ingredient" +const ENTITY_PLURAL = "ingredients" +const API_PATH = "/api/ingredients" + +const SORTABLE_COLUMNS = ["invoice_number"] +const DEFAULT_SORT_BY = "invoice_number" + +const PERM_CREATE = UserPermissions.OrderCreate +const PERM_UPDATE = UserPermissions.OrderUpdate +const PERM_DELETE = UserPermissions.OrderDelete + +import { + IngredientForm as EntityForm, + Ingredient as Entity, + getIngredientTypes, + getSuppliers, +} from "../forms/ingredient" + +interface PaginatedResponse { + rows: Entity[] + total: number + page_count: number +} + +const DISPLAY_PROP: keyof Entity = "lot" + +interface Props { + userPerms: UserPermissions +} + +export function IngredientsPage({ 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: "supplier", + header: ({ column }) => ( + <SortableFilterableColumnHeader + column={column} + title="Supplier" + placeholder="Filter..." + get_options={getSuppliers} + /> + ), + size: 150, + enableSorting: false, + enableColumnFilter: true, + cell: ({ row }) => { + const supplierId = row.getValue("supplier") as string + return <SupplierCell supplierId={supplierId} /> + }, + }, + { + accessorKey: "lot", + header: ({ column }) => ( + <SortableFilterableColumnHeader + column={column} + title="Lot" + placeholder="Filter..." + /> + ), + size: 150, + enableSorting: false, + enableColumnFilter: true, + }, + { + accessorKey: "ingredient_type", + header: ({ column }) => ( + <SortableFilterableColumnHeader + column={column} + title="Ingredient Type" + placeholder="Filter..." + get_options={getIngredientTypes} + /> + ), + size: 150, + enableSorting: false, + enableColumnFilter: true, + cell: ({ row }) => { + const ingredientType = row.getValue("ingredient_type") as string + return <ITCell itId={ingredientType} /> + }, + }, + { + accessorKey: "date_recieved", + header: ({ column }) => ( + <SortableFilterableColumnHeader + column={column} + title="Date Recieved" + placeholder="Filter..." + /> + ), + size: 150, + enableSorting: true, + enableColumnFilter: false, + cell: ({ row }) => { + const dateRecieved = row.getValue("date_recieved") as Date + return <span>{new Date(dateRecieved).toLocaleDateString()}</span> + }, + }, + { + accessorKey: "date_opened", + header: ({ column }) => ( + <SortableFilterableColumnHeader + column={column} + title="Date Opened" + placeholder="Filter..." + /> + ), + size: 150, + enableSorting: true, + enableColumnFilter: false, + cell: ({ row }) => { + const dateOpened = row.getValue("date_opened") as Date | undefined + return ( + <span> + {dateOpened + ? new Date(dateOpened).toLocaleDateString() + : "Unopened"} + </span> + ) + }, + }, + { + accessorKey: "date_finished", + header: ({ column }) => ( + <SortableFilterableColumnHeader + column={column} + title="Date Finished" + placeholder="Filter..." + /> + ), + size: 150, + enableSorting: true, + enableColumnFilter: false, + cell: ({ row }) => { + const dateFinished = row.getValue("date_finished") as Date | undefined + const dateOpened = row.getValue("date_opened") as Date | undefined + return ( + <span> + {dateFinished + ? new Date(dateFinished).toLocaleDateString() + : dateOpened + ? "Unfinished" + : "Unopened"} + </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 /> + {item.date_opened ? null : ( + <DropdownMenuItem + onClick={() => { + fetch(`/api/ingredients/${item.id}`, { + method: "PATCH", + body: JSON.stringify({ + date_opened: new Date(), + }), + headers: { + "Content-Type": "application/json", + }, + }).then(() => { + refetch() + }) + }} + disabled={!userPerms.contains(PERM_UPDATE)} + className="cursor-pointer" + > + Mark opened + </DropdownMenuItem> + )} + {item.date_opened && !item.date_finished ? ( + <DropdownMenuItem + onClick={() => { + fetch(`/api/ingredients/${item.id}`, { + method: "PATCH", + body: JSON.stringify({ + date_finished: new Date(), + }), + headers: { + "Content-Type": "application/json", + }, + }).then(() => { + refetch() + }) + }} + disabled={!userPerms.contains(PERM_UPDATE)} + className="cursor-pointer" + > + Mark finished + </DropdownMenuItem> + ) : null} + <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, refetch] + ) + + // 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 + ingredientEdit={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/ui/calendar.tsx b/frontend/components/ui/calendar.tsx new file mode 100644 index 0000000..cb4859c --- /dev/null +++ b/frontend/components/ui/calendar.tsx @@ -0,0 +1,222 @@ +"use client" + +import * as React from "react" +import { + DayPicker, + getDefaultClassNames, + type DayButton, + type Locale, +} from "react-day-picker" + +import { cn } from "@/lib/utils" +import { Button, buttonVariants } from "@/components/ui/button" +import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from "lucide-react" + +function Calendar({ + className, + classNames, + showOutsideDays = true, + captionLayout = "label", + buttonVariant = "ghost", + locale, + formatters, + components, + ...props +}: React.ComponentProps<typeof DayPicker> & { + buttonVariant?: React.ComponentProps<typeof Button>["variant"] +}) { + const defaultClassNames = getDefaultClassNames() + + return ( + <DayPicker + showOutsideDays={showOutsideDays} + className={cn( + "group/calendar bg-background p-3 [--cell-radius:var(--radius-4xl)] [--cell-size:--spacing(8)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent", + String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`, + String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, + className + )} + captionLayout={captionLayout} + locale={locale} + formatters={{ + formatMonthDropdown: (date) => + date.toLocaleString(locale?.code, { month: "short" }), + ...formatters, + }} + classNames={{ + root: cn("w-fit", defaultClassNames.root), + months: cn( + "relative flex flex-col gap-4 md:flex-row", + defaultClassNames.months + ), + month: cn("flex w-full flex-col gap-4", defaultClassNames.month), + nav: cn( + "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", + defaultClassNames.nav + ), + button_previous: cn( + buttonVariants({ variant: buttonVariant }), + "size-(--cell-size) p-0 select-none aria-disabled:opacity-50", + defaultClassNames.button_previous + ), + button_next: cn( + buttonVariants({ variant: buttonVariant }), + "size-(--cell-size) p-0 select-none aria-disabled:opacity-50", + defaultClassNames.button_next + ), + month_caption: cn( + "flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)", + defaultClassNames.month_caption + ), + dropdowns: cn( + "flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium", + defaultClassNames.dropdowns + ), + dropdown_root: cn( + "relative rounded-(--cell-radius)", + defaultClassNames.dropdown_root + ), + dropdown: cn( + "absolute inset-0 bg-popover opacity-0", + defaultClassNames.dropdown + ), + caption_label: cn( + "font-medium select-none", + captionLayout === "label" + ? "text-sm" + : "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground", + defaultClassNames.caption_label + ), + month_grid: cn("w-full border-collapse", defaultClassNames.month_grid), + weekdays: cn("flex", defaultClassNames.weekdays), + weekday: cn( + "flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none", + defaultClassNames.weekday + ), + week: cn("mt-2 flex w-full", defaultClassNames.week), + week_number_header: cn( + "w-(--cell-size) select-none", + defaultClassNames.week_number_header + ), + week_number: cn( + "text-[0.8rem] text-muted-foreground select-none", + defaultClassNames.week_number + ), + day: cn( + "group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)", + props.showWeekNumber + ? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)" + : "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)", + defaultClassNames.day + ), + range_start: cn( + "relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted", + defaultClassNames.range_start + ), + range_middle: cn("rounded-none", defaultClassNames.range_middle), + range_end: cn( + "relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted", + defaultClassNames.range_end + ), + today: cn( + "rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none", + defaultClassNames.today + ), + outside: cn( + "text-muted-foreground aria-selected:text-muted-foreground", + defaultClassNames.outside + ), + disabled: cn( + "text-muted-foreground opacity-50", + defaultClassNames.disabled + ), + hidden: cn("invisible", defaultClassNames.hidden), + ...classNames, + }} + components={{ + Root: ({ className, rootRef, ...props }) => { + return ( + <div + data-slot="calendar" + ref={rootRef} + className={cn(className)} + {...props} + /> + ) + }, + Chevron: ({ className, orientation, ...props }) => { + if (orientation === "left") { + return ( + <ChevronLeftIcon className={cn("size-4", className)} {...props} /> + ) + } + + if (orientation === "right") { + return ( + <ChevronRightIcon className={cn("size-4", className)} {...props} /> + ) + } + + return ( + <ChevronDownIcon className={cn("size-4", className)} {...props} /> + ) + }, + DayButton: ({ ...props }) => ( + <CalendarDayButton locale={locale} {...props} /> + ), + WeekNumber: ({ children, ...props }) => { + return ( + <td {...props}> + <div className="flex size-(--cell-size) items-center justify-center text-center"> + {children} + </div> + </td> + ) + }, + ...components, + }} + {...props} + /> + ) +} + +function CalendarDayButton({ + className, + day, + modifiers, + locale, + ...props +}: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) { + const defaultClassNames = getDefaultClassNames() + + const ref = React.useRef<HTMLButtonElement>(null) + React.useEffect(() => { + if (modifiers.focused) ref.current?.focus() + }, [modifiers.focused]) + + return ( + <Button + ref={ref} + variant="ghost" + size="icon" + data-day={day.date.toLocaleDateString(locale?.code)} + data-selected-single={ + modifiers.selected && + !modifiers.range_start && + !modifiers.range_end && + !modifiers.range_middle + } + data-range-start={modifiers.range_start} + data-range-end={modifiers.range_end} + data-range-middle={modifiers.range_middle} + className={cn( + "relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70", + defaultClassNames.day, + className + )} + {...props} + /> + ) +} + +export { Calendar, CalendarDayButton } diff --git a/frontend/components/ui/combobox.tsx b/frontend/components/ui/combobox.tsx index e917628..7247379 100644 --- a/frontend/components/ui/combobox.tsx +++ b/frontend/components/ui/combobox.tsx @@ -81,7 +81,7 @@ export function AsyncCombobox(props: AsyncComboboxProps) { if (searchQuery === debouncedQuery) return const timer = setTimeout(() => setDebouncedQuery(searchQuery), 300) return () => clearTimeout(timer) - }, [searchQuery]) + }, [searchQuery, debouncedQuery]) // Fetch React.useEffect(() => { @@ -104,6 +104,7 @@ export function AsyncCombobox(props: AsyncComboboxProps) { setOptions((prev) => (page === 1 ? results : [...prev, ...results])) setHasMore(results.length === pageSize) + // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (error) { if (!cancelled) setHasMore(false) } finally { diff --git a/frontend/components/ui/date-picker.tsx b/frontend/components/ui/date-picker.tsx new file mode 100644 index 0000000..3c6ec14 --- /dev/null +++ b/frontend/components/ui/date-picker.tsx @@ -0,0 +1,43 @@ +"use client" + +import { format } from "date-fns" +import { Calendar as CalendarIcon } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { Calendar } from "@/components/ui/calendar" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" + +export function DatePicker({ + date, + onDateChange, +}: { + date: Date | undefined + onDateChange: (date: Date | undefined) => void +}) { + return ( + <Popover> + <PopoverTrigger asChild> + <Button + variant="outline" + data-empty={!date} + className="justify-start text-left font-normal data-[empty=true]:text-muted-foreground" + > + <CalendarIcon /> + {date ? format(date, "PPP") : <span>Pick a date</span>} + </Button> + </PopoverTrigger> + <PopoverContent className="w-auto p-0"> + <Calendar + mode="single" + selected={date} + onSelect={onDateChange} + required + /> + </PopoverContent> + </Popover> + ) +} diff --git a/frontend/components/ui/table.tsx b/frontend/components/ui/table.tsx index 7add071..ed586ed 100644 --- a/frontend/components/ui/table.tsx +++ b/frontend/components/ui/table.tsx @@ -75,7 +75,7 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) { <th data-slot="table-head" className={cn( - "h-12 px-3 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0", + "h-12 px-3 text-left align-middle font-medium whitespace-nowrap text-foreground has-[[role=checkbox]]:pr-0", className )} {...props} @@ -88,7 +88,7 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) { <td data-slot="table-cell" className={cn( - "p-3 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0", + "p-3 align-middle whitespace-nowrap has-[[role=checkbox]]:pr-0", className )} {...props} @@ -162,32 +162,35 @@ function SortableFilterableColumnHeader({ }) { const [showFilter, setShowFilter] = React.useState(false) const canSort = column.getCanSort() + const canFilter = column.getCanFilter() return ( <div className="space-y-1"> <div className="flex items-center justify-between gap-2"> {canSort && <SortableHeader column={column} title={title} />} {!canSort && <span>{title}</span>} - <Button - type="button" - variant="ghost" - size="icon" - className="h-6 w-6" - onClick={() => setShowFilter(!showFilter)} - > - <Filter - className={`h-3 w-3 ${showFilter ? "text-primary" : "text-muted-foreground"}`} - /> - </Button> + {canFilter && ( + <Button + type="button" + variant="ghost" + size="icon" + className="h-6 w-6" + onClick={() => setShowFilter(!showFilter)} + > + <Filter + className={`h-3 w-3 ${showFilter ? "text-primary" : "text-muted-foreground"}`} + /> + </Button> + )} </div> - {showFilter && !get_options && ( + {canFilter && showFilter && !get_options && ( <FilterInput value={(column.getFilterValue() as string) ?? ""} onChange={(value) => column.setFilterValue(value)} placeholder={placeholder} /> )} - {showFilter && get_options && ( + {canFilter && showFilter && get_options && ( <AsyncCombobox fetchOptions={get_options} value={(column.getFilterValue() as string) ?? ""} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0452643..981280a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,11 +14,13 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "date-fns": "^4.4.0", "lucide-react": "^1.22.0", "next": "16.2.6", "next-themes": "^0.4.6", "radix-ui": "^1.6.0", "react": "19.2.4", + "react-day-picker": "^10.0.1", "react-dom": "19.2.4", "react-hook-form": "^7.80.0", "shadcn": "^4.12.0", @@ -458,6 +460,12 @@ "node": ">=6.9.0" } }, + "node_modules/@date-fns/tz": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.5.0.tgz", + "integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==", + "license": "MIT" + }, "node_modules/@dotenvx/dotenvx": { "version": "1.75.1", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.75.1.tgz", @@ -5499,6 +5507,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", + "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debounce-fn": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-4.0.0.tgz", @@ -9677,6 +9695,32 @@ "node": ">=0.10.0" } }, + "node_modules/react-day-picker": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-10.0.1.tgz", + "integrity": "sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==", + "license": "MIT", + "dependencies": { + "@date-fns/tz": "^1.4.1", + "date-fns": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "@types/react": ">=16.8.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-dom": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index f8b900a..9471418 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,11 +18,13 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "date-fns": "^4.4.0", "lucide-react": "^1.22.0", "next": "16.2.6", "next-themes": "^0.4.6", "radix-ui": "^1.6.0", "react": "19.2.4", + "react-day-picker": "^10.0.1", "react-dom": "19.2.4", "react-hook-form": "^7.80.0", "shadcn": "^4.12.0",