diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 24f0b77..e177f49 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -31,6 +31,7 @@ model User { filaments Filament[] resins Resin[] paints Paint[] + supplies Supply[] vendors Vendor[] locations Location[] usageLogs UsageLog[] @@ -92,6 +93,7 @@ model Vendor { filaments Filament[] resins Resin[] paints Paint[] + supplies Supply[] @@index([userId]) @@index([archived]) @@ -110,6 +112,7 @@ model Location { filaments Filament[] resins Resin[] paints Paint[] + supplies Supply[] @@index([userId]) @@index([archived]) @@ -230,6 +233,7 @@ model Tag { filaments TagOnFilament[] resins TagOnResin[] paints TagOnPaint[] + supplies TagOnSupply[] @@unique([name, userId]) @@index([userId]) @@ -265,12 +269,57 @@ model TagOnPaint { @@id([paintId, tagId]) } +model Supply { + id String @id @default(cuid()) + name String @db.VarChar(128) + brand String @db.VarChar(64) + category String @db.VarChar(32) + color String? @db.VarChar(64) + colorHex String? @db.VarChar(7) + totalAmount Float + usedAmount Float @default(0) + unit String @db.VarChar(16) + purchaseDate DateTime? + cost Float? + notes String? @db.Text + archived Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + userId String + vendorId String? + locationId String? + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + vendor Vendor? @relation(fields: [vendorId], references: [id], onDelete: SetNull) + location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull) + tags TagOnSupply[] + usageLogs UsageLog[] + + @@index([userId]) + @@index([vendorId]) + @@index([locationId]) + @@index([category]) + @@index([archived]) + @@index([brand]) +} + +model TagOnSupply { + supplyId String + tagId String + + supply Supply @relation(fields: [supplyId], references: [id], onDelete: Cascade) + tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) + + @@id([supplyId, tagId]) +} + model UsageLog { id String @id @default(cuid()) itemType String @db.VarChar(16) itemId String amount Float - unit String @db.VarChar(4) + unit String @db.VarChar(16) notes String? @db.Text createdAt DateTime @default(now()) userId String @@ -282,6 +331,8 @@ model UsageLog { resinId String? paint Paint? @relation(fields: [paintId], references: [id], onDelete: Cascade) paintId String? + supply Supply? @relation(fields: [supplyId], references: [id], onDelete: Cascade) + supplyId String? @@index([userId]) @@index([itemType, itemId]) diff --git a/src/app/(app)/supplies/_components/supply-columns.tsx b/src/app/(app)/supplies/_components/supply-columns.tsx new file mode 100644 index 0000000..ceba3e1 --- /dev/null +++ b/src/app/(app)/supplies/_components/supply-columns.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { type ColumnDef } from "@tanstack/react-table"; +import { MoreHorizontal, Pencil, Archive, Trash2, Package } from "lucide-react"; +import { DataTableColumnHeader } from "@/components/shared/data-table-column-header"; +import { StatusBadge, getStockStatus } from "@/components/shared/status-badge"; +import { ColorSwatch } from "@/components/shared/color-swatch"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +export interface SupplyRow { + id: string; + name: string; + brand: string; + category: string; + color: string | null; + colorHex: string | null; + totalAmount: number; + usedAmount: number; + unit: string; + cost: number | null; + purchaseDate: Date | null; + notes: string | null; + archived: boolean; + vendor: { id: string; name: string } | null; + location: { id: string; name: string } | null; +} + +interface SupplyColumnsProps { + onEdit: (supply: SupplyRow) => void; + onArchive: (id: string) => void; + onDelete: (id: string) => void; + onLogUsage: (supply: SupplyRow) => void; + lowStockThreshold: number; +} + +export function getSupplyColumns({ + onEdit, + onArchive, + onDelete, + onLogUsage, + lowStockThreshold, +}: SupplyColumnsProps): ColumnDef[] { + return [ + { + id: "color", + header: "", + cell: ({ row }) => + row.original.colorHex ? ( + + ) : null, + enableHiding: false, + size: 40, + }, + { + accessorKey: "name", + header: ({ column }) => , + cell: ({ row }) => { + const remaining = row.original.totalAmount - row.original.usedAmount; + const status = getStockStatus( + remaining, + row.original.totalAmount, + lowStockThreshold, + row.original.archived + ); + return ( +
+ {row.original.name} + +
+ ); + }, + enableHiding: false, + }, + { + accessorKey: "brand", + header: ({ column }) => , + cell: ({ row }) => ( + {row.original.brand} + ), + }, + { + accessorKey: "category", + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.category} + + ), + }, + { + id: "remaining", + header: ({ column }) => , + cell: ({ row }) => { + const remaining = row.original.totalAmount - row.original.usedAmount; + const percent = + row.original.totalAmount > 0 + ? (remaining / row.original.totalAmount) * 100 + : 0; + const isLow = percent <= lowStockThreshold; + return ( +
+
+
+
+ + {remaining.toFixed(1)} {row.original.unit} + +
+ ); + }, + }, + { + id: "location", + header: "Location", + cell: ({ row }) => ( + + {row.original.location?.name ?? "\u2014"} + + ), + }, + { + id: "vendor", + header: "Vendor", + cell: ({ row }) => ( + + {row.original.vendor?.name ?? "\u2014"} + + ), + }, + { + accessorKey: "cost", + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.cost != null ? `\u20AC${row.original.cost.toFixed(2)}` : "\u2014"} + + ), + }, + { + id: "actions", + cell: ({ row }) => ( + + + + + + onEdit(row.original)}> + + Edit + + onLogUsage(row.original)}> + + Log Usage + + onArchive(row.original.id)}> + + {row.original.archived ? "Unarchive" : "Archive"} + + + onDelete(row.original.id)} + className="text-destructive focus:text-destructive" + > + + Delete + + + + ), + enableHiding: false, + }, + ]; +} diff --git a/src/app/(app)/supplies/_components/supply-form.tsx b/src/app/(app)/supplies/_components/supply-form.tsx new file mode 100644 index 0000000..2949688 --- /dev/null +++ b/src/app/(app)/supplies/_components/supply-form.tsx @@ -0,0 +1,370 @@ +"use client"; + +import { useTransition } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { toast } from "sonner"; +import { supplySchema, type SupplyInput } from "@/schemas/supply.schema"; +import { SUPPLY_CATEGORIES, SUPPLY_UNITS, SUPPLY_CATEGORY_DEFAULTS } from "@/lib/constants"; +import { createSupply, updateSupply } from "../actions"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { ColorSwatch } from "@/components/shared/color-swatch"; + +interface SupplyFormProps { + supply?: { + id: string; + name: string; + brand: string; + category: string; + color: string | null; + colorHex: string | null; + totalAmount: number; + usedAmount: number; + unit: string; + cost: number | null; + purchaseDate: Date | null; + notes: string | null; + vendorId: string | null; + locationId: string | null; + }; + vendors: { id: string; name: string }[]; + locations: { id: string; name: string }[]; + onSuccess: () => void; +} + +export function SupplyForm({ supply, vendors, locations, onSuccess }: SupplyFormProps) { + const [isPending, startTransition] = useTransition(); + const isEditing = !!supply; + + const form = useForm({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + resolver: zodResolver(supplySchema) as any, + defaultValues: { + name: supply?.name ?? "", + brand: supply?.brand ?? "", + category: (supply?.category as SupplyInput["category"]) ?? "Glitter", + color: supply?.color ?? "", + colorHex: supply?.colorHex ?? "", + totalAmount: supply?.totalAmount ?? SUPPLY_CATEGORY_DEFAULTS["Glitter"].totalAmount, + usedAmount: supply?.usedAmount ?? 0, + unit: supply?.unit ?? SUPPLY_CATEGORY_DEFAULTS["Glitter"].unit, + cost: supply?.cost ?? undefined, + purchaseDate: supply?.purchaseDate + ? new Date(supply.purchaseDate).toISOString().split("T")[0] + : "", + notes: supply?.notes ?? "", + vendorId: supply?.vendorId ?? "", + locationId: supply?.locationId ?? "", + }, + }); + + // eslint-disable-next-line react-hooks/incompatible-library -- RHF watch is safe here + const watchColorHex = form.watch("colorHex"); + // eslint-disable-next-line react-hooks/incompatible-library + const watchUnit = form.watch("unit"); + + function onSubmit(values: SupplyInput) { + startTransition(async () => { + const result = isEditing + ? await updateSupply(supply!.id, values) + : await createSupply(values); + + if (!result.success) { + toast.error(result.error); + return; + } + + toast.success(isEditing ? "Supply updated" : "Supply created"); + form.reset(); + onSuccess(); + }); + } + + return ( +
+ +
+ ( + + Name + + + + + + )} + /> + + ( + + Brand + + + + + + )} + /> + + ( + + Category + + + + )} + /> + + ( + + Color (optional) + + + + + + )} + /> + + ( + + Color Hex (optional) +
+ + + + field.onChange(e.target.value)} + className="h-9 w-9 cursor-pointer rounded border border-border bg-transparent p-0.5" + /> + {watchColorHex && ( + + )} +
+ +
+ )} + /> + + ( + + Total Amount ({watchUnit || "units"}) + + + + + + )} + /> + + ( + + Used ({watchUnit || "units"}) + + + + + + )} + /> + + ( + + Unit + + + + )} + /> + + ( + + Vendor + + + + )} + /> + + ( + + Location + + + + )} + /> + + ( + + Cost + + + + + + )} + /> + + ( + + Purchase Date + + + + + + )} + /> + + ( + + Notes + +