From beb9cfb31222c5e9b96806a68785ea247353415b Mon Sep 17 00:00:00 2001 From: xCyanGrizzly Date: Thu, 19 Feb 2026 11:46:19 +0100 Subject: [PATCH] Add log usage buttons on main page and seperate page --- .../migration.sql | 71 +++++ .../migration.sql | 1 + .../_components/recent-usage-card.tsx | 82 +++++ src/app/(app)/dashboard/page.tsx | 54 +--- .../(app)/usage/_components/usage-columns.tsx | 63 ++++ .../(app)/usage/_components/usage-table.tsx | 77 +++++ src/app/(app)/usage/actions.ts | 160 ++++++++++ src/app/(app)/usage/page.tsx | 29 ++ src/components/layout/mobile-sidebar.tsx | 6 +- src/components/layout/sidebar.tsx | 3 + src/components/shared/quick-usage-dialog.tsx | 298 ++++++++++++++++++ src/data/usage.queries.ts | 114 +++++++ src/lib/constants.ts | 1 + 13 files changed, 919 insertions(+), 40 deletions(-) create mode 100644 prisma/migrations/20260219074341_add_supply_tables/migration.sql create mode 100644 prisma/migrations/20260219074408_add_supply_tables/migration.sql create mode 100644 src/app/(app)/dashboard/_components/recent-usage-card.tsx create mode 100644 src/app/(app)/usage/_components/usage-columns.tsx create mode 100644 src/app/(app)/usage/_components/usage-table.tsx create mode 100644 src/app/(app)/usage/actions.ts create mode 100644 src/app/(app)/usage/page.tsx create mode 100644 src/components/shared/quick-usage-dialog.tsx create mode 100644 src/data/usage.queries.ts diff --git a/prisma/migrations/20260219074341_add_supply_tables/migration.sql b/prisma/migrations/20260219074341_add_supply_tables/migration.sql new file mode 100644 index 0000000..58e6726 --- /dev/null +++ b/prisma/migrations/20260219074341_add_supply_tables/migration.sql @@ -0,0 +1,71 @@ +-- AlterTable +ALTER TABLE "UsageLog" ADD COLUMN "supplyId" TEXT, +ALTER COLUMN "unit" SET DATA TYPE VARCHAR(16); + +-- CreateTable +CREATE TABLE "Supply" ( + "id" TEXT NOT NULL, + "name" VARCHAR(128) NOT NULL, + "brand" VARCHAR(64) NOT NULL, + "category" VARCHAR(32) NOT NULL, + "color" VARCHAR(64), + "colorHex" VARCHAR(7), + "totalAmount" DOUBLE PRECISION NOT NULL, + "usedAmount" DOUBLE PRECISION NOT NULL DEFAULT 0, + "unit" VARCHAR(16) NOT NULL, + "purchaseDate" TIMESTAMP(3), + "cost" DOUBLE PRECISION, + "notes" TEXT, + "archived" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "userId" TEXT NOT NULL, + "vendorId" TEXT, + "locationId" TEXT, + + CONSTRAINT "Supply_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TagOnSupply" ( + "supplyId" TEXT NOT NULL, + "tagId" TEXT NOT NULL, + + CONSTRAINT "TagOnSupply_pkey" PRIMARY KEY ("supplyId","tagId") +); + +-- CreateIndex +CREATE INDEX "Supply_userId_idx" ON "Supply"("userId"); + +-- CreateIndex +CREATE INDEX "Supply_vendorId_idx" ON "Supply"("vendorId"); + +-- CreateIndex +CREATE INDEX "Supply_locationId_idx" ON "Supply"("locationId"); + +-- CreateIndex +CREATE INDEX "Supply_category_idx" ON "Supply"("category"); + +-- CreateIndex +CREATE INDEX "Supply_archived_idx" ON "Supply"("archived"); + +-- CreateIndex +CREATE INDEX "Supply_brand_idx" ON "Supply"("brand"); + +-- AddForeignKey +ALTER TABLE "Supply" ADD CONSTRAINT "Supply_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Supply" ADD CONSTRAINT "Supply_vendorId_fkey" FOREIGN KEY ("vendorId") REFERENCES "Vendor"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Supply" ADD CONSTRAINT "Supply_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TagOnSupply" ADD CONSTRAINT "TagOnSupply_supplyId_fkey" FOREIGN KEY ("supplyId") REFERENCES "Supply"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TagOnSupply" ADD CONSTRAINT "TagOnSupply_tagId_fkey" FOREIGN KEY ("tagId") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UsageLog" ADD CONSTRAINT "UsageLog_supplyId_fkey" FOREIGN KEY ("supplyId") REFERENCES "Supply"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260219074408_add_supply_tables/migration.sql b/prisma/migrations/20260219074408_add_supply_tables/migration.sql new file mode 100644 index 0000000..af5102c --- /dev/null +++ b/prisma/migrations/20260219074408_add_supply_tables/migration.sql @@ -0,0 +1 @@ +-- This is an empty migration. \ No newline at end of file diff --git a/src/app/(app)/dashboard/_components/recent-usage-card.tsx b/src/app/(app)/dashboard/_components/recent-usage-card.tsx new file mode 100644 index 0000000..c2eac25 --- /dev/null +++ b/src/app/(app)/dashboard/_components/recent-usage-card.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { useState } from "react"; +import { Plus } from "lucide-react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { QuickUsageDialog } from "@/components/shared/quick-usage-dialog"; +import type { PickerItem } from "@/data/usage.queries"; + +interface RecentUsage { + id: string; + itemType: string; + amount: number; + unit: string; + notes: string | null; + createdAt: string; + itemName: string; +} + +interface RecentUsageCardProps { + recentUsage: RecentUsage[]; + items: PickerItem[]; +} + +export function RecentUsageCard({ recentUsage, items }: RecentUsageCardProps) { + const [dialogOpen, setDialogOpen] = useState(false); + + return ( + <> + + +
+
+ Recent Usage + Latest consumption log entries +
+ +
+
+ + {recentUsage.length === 0 ? ( +

No usage logged yet.

+ ) : ( +
+ {recentUsage.map((log) => ( +
+ + {log.itemType} + +
+

{log.itemName}

+

+ {log.notes || "No notes"} +

+
+
+

+ -{log.amount}{log.unit} +

+

+ {new Date(log.createdAt).toLocaleDateString()} +

+
+
+ ))} +
+ )} +
+
+ + + + ); +} diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx index 8b2d32a..e839193 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -1,25 +1,35 @@ import { auth } from "@/lib/auth"; import { redirect } from "next/navigation"; import { getDashboardStats } from "@/data/dashboard.queries"; +import { getAllUserItems } from "@/data/usage.queries"; import { getUserSettings } from "@/data/settings.queries"; -import { Package, DollarSign, AlertTriangle, Activity } from "lucide-react"; +import { Package, Euro, AlertTriangle, Activity } from "lucide-react"; import { StatCard } from "@/components/shared/stat-card"; import { ColorSwatch } from "@/components/shared/color-swatch"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; +import { RecentUsageCard } from "./_components/recent-usage-card"; export default async function DashboardPage() { const session = await auth(); if (!session?.user?.id) redirect("/login"); const settings = await getUserSettings(session.user.id); - const stats = await getDashboardStats(session.user.id, settings.lowStockThreshold); + const [stats, items] = await Promise.all([ + getDashboardStats(session.user.id, settings.lowStockThreshold), + getAllUserItems(session.user.id), + ]); const currencyFormatter = new Intl.NumberFormat("en-US", { style: "currency", currency: settings.currency, }); + // Serialize dates for the client component + const serializedUsage = stats.recentUsage.map((log) => ({ + ...log, + createdAt: log.createdAt.toISOString(), + })); + return (
{/* Stats Grid */} @@ -33,7 +43,7 @@ export default async function DashboardPage() { {/* Recent Usage */} - - - Recent Usage - Latest consumption log entries - - - {stats.recentUsage.length === 0 ? ( -

No usage logged yet.

- ) : ( -
- {stats.recentUsage.map((log) => ( -
- - {log.itemType} - -
-

{log.itemName}

-

- {log.notes || "No notes"} -

-
-
-

- -{log.amount}{log.unit} -

-

- {new Date(log.createdAt).toLocaleDateString()} -

-
-
- ))} -
- )} -
-
+
); diff --git a/src/app/(app)/usage/_components/usage-columns.tsx b/src/app/(app)/usage/_components/usage-columns.tsx new file mode 100644 index 0000000..e0b64b4 --- /dev/null +++ b/src/app/(app)/usage/_components/usage-columns.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { type ColumnDef } from "@tanstack/react-table"; +import { DataTableColumnHeader } from "@/components/shared/data-table-column-header"; +import { Badge } from "@/components/ui/badge"; +import type { UsageLogRow } from "@/data/usage.queries"; + +export function getUsageColumns(): ColumnDef[] { + return [ + { + accessorKey: "createdAt", + header: ({ column }) => , + cell: ({ row }) => { + const date = new Date(row.original.createdAt); + return ( +
+

{date.toLocaleDateString()}

+

{date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}

+
+ ); + }, + size: 130, + }, + { + accessorKey: "itemType", + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.itemType} + + ), + size: 100, + }, + { + accessorKey: "itemName", + header: ({ column }) => , + cell: ({ row }) => ( +

{row.original.itemName}

+ ), + enableSorting: false, + }, + { + accessorKey: "amount", + header: ({ column }) => , + cell: ({ row }) => ( + + -{row.original.amount}{row.original.unit} + + ), + size: 100, + }, + { + accessorKey: "notes", + header: "Notes", + cell: ({ row }) => ( +

+ {row.original.notes || "\u2014"} +

+ ), + enableSorting: false, + }, + ]; +} diff --git a/src/app/(app)/usage/_components/usage-table.tsx b/src/app/(app)/usage/_components/usage-table.tsx new file mode 100644 index 0000000..d014cdc --- /dev/null +++ b/src/app/(app)/usage/_components/usage-table.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { useRouter, usePathname, useSearchParams } from "next/navigation"; +import { Plus } from "lucide-react"; +import { useDataTable } from "@/hooks/use-data-table"; +import { getUsageColumns } from "./usage-columns"; +import { DataTable } from "@/components/shared/data-table"; +import { DataTablePagination } from "@/components/shared/data-table-pagination"; +import { DataTableFacetedFilter } from "@/components/shared/data-table-faceted-filter"; +import { QuickUsageDialog } from "@/components/shared/quick-usage-dialog"; +import { PageHeader } from "@/components/shared/page-header"; +import { Button } from "@/components/ui/button"; +import type { UsageLogRow } from "@/data/usage.queries"; +import type { PickerItem } from "@/data/usage.queries"; + +const ITEM_TYPE_OPTIONS = [ + { label: "Filament", value: "FILAMENT" }, + { label: "Resin", value: "RESIN" }, + { label: "Paint", value: "PAINT" }, + { label: "Supply", value: "SUPPLY" }, +]; + +interface UsageTableProps { + data: UsageLogRow[]; + pageCount: number; + totalCount: number; + items: PickerItem[]; +} + +export function UsageTable({ data, pageCount, totalCount, items }: UsageTableProps) { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const [dialogOpen, setDialogOpen] = useState(false); + + const itemTypeFilter = new Set(searchParams.getAll("itemType")); + + const updateFilters = useCallback( + (key: string, values: Set) => { + const params = new URLSearchParams(searchParams.toString()); + params.delete(key); + values.forEach((v) => params.append(key, v)); + params.set("page", "1"); + router.push(`${pathname}?${params.toString()}`, { scroll: false }); + }, + [router, pathname, searchParams] + ); + + const columns = getUsageColumns(); + const { table } = useDataTable({ data, columns, pageCount }); + + return ( +
+ + + + +
+ updateFilters("itemType", values)} + /> +
+ + + + + +
+ ); +} diff --git a/src/app/(app)/usage/actions.ts b/src/app/(app)/usage/actions.ts new file mode 100644 index 0000000..fc5eaa4 --- /dev/null +++ b/src/app/(app)/usage/actions.ts @@ -0,0 +1,160 @@ +"use server"; + +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { revalidatePath } from "next/cache"; +import { z } from "zod/v4"; +import type { ActionResult } from "@/types/api.types"; + +const batchEntrySchema = z.object({ + itemType: z.enum(["FILAMENT", "RESIN", "PAINT", "SUPPLY"]), + itemId: z.string().min(1), + amount: z.coerce.number().positive("Amount must be positive"), + notes: z.string().max(512).optional(), +}); + +const batchUsageSchema = z.object({ + entries: z.array(batchEntrySchema).min(1, "At least one entry is required"), +}); + +export async function logBatchUsage(input: unknown): Promise { + const session = await auth(); + if (!session?.user?.id) return { success: false, error: "Unauthorized" }; + + const parsed = batchUsageSchema.safeParse(input); + if (!parsed.success) { + return { success: false, error: "Validation failed" }; + } + + const { entries } = parsed.data; + const userId = session.user.id; + + try { + // Verify ownership of all items and build transaction operations + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const operations: any[] = []; + const affectedPaths = new Set(); + + for (const entry of entries) { + switch (entry.itemType) { + case "FILAMENT": { + const item = await prisma.filament.findFirst({ + where: { id: entry.itemId, userId }, + }); + if (!item) return { success: false, error: `Filament not found` }; + + operations.push( + prisma.usageLog.create({ + data: { + itemType: "FILAMENT", + itemId: entry.itemId, + filamentId: entry.itemId, + amount: entry.amount, + unit: "g", + notes: entry.notes || null, + userId, + }, + }), + prisma.filament.update({ + where: { id: entry.itemId }, + data: { usedWeight: { increment: entry.amount } }, + }) + ); + affectedPaths.add("/filaments"); + break; + } + case "RESIN": { + const item = await prisma.resin.findFirst({ + where: { id: entry.itemId, userId }, + }); + if (!item) return { success: false, error: `Resin not found` }; + + operations.push( + prisma.usageLog.create({ + data: { + itemType: "RESIN", + itemId: entry.itemId, + resinId: entry.itemId, + amount: entry.amount, + unit: "ml", + notes: entry.notes || null, + userId, + }, + }), + prisma.resin.update({ + where: { id: entry.itemId }, + data: { usedML: { increment: entry.amount } }, + }) + ); + affectedPaths.add("/resins"); + break; + } + case "PAINT": { + const item = await prisma.paint.findFirst({ + where: { id: entry.itemId, userId }, + }); + if (!item) return { success: false, error: `Paint not found` }; + + operations.push( + prisma.usageLog.create({ + data: { + itemType: "PAINT", + itemId: entry.itemId, + paintId: entry.itemId, + amount: entry.amount, + unit: "ml", + notes: entry.notes || null, + userId, + }, + }), + prisma.paint.update({ + where: { id: entry.itemId }, + data: { usedML: { increment: entry.amount } }, + }) + ); + affectedPaths.add("/paints"); + break; + } + case "SUPPLY": { + const item = await prisma.supply.findFirst({ + where: { id: entry.itemId, userId }, + }); + if (!item) return { success: false, error: `Supply not found` }; + + operations.push( + prisma.usageLog.create({ + data: { + itemType: "SUPPLY", + itemId: entry.itemId, + supplyId: entry.itemId, + amount: entry.amount, + unit: item.unit, + notes: entry.notes || null, + userId, + }, + }), + prisma.supply.update({ + where: { id: entry.itemId }, + data: { usedAmount: { increment: entry.amount } }, + }) + ); + affectedPaths.add("/supplies"); + break; + } + } + } + + await prisma.$transaction(operations); + + // Revalidate all affected pages + revalidatePath("/dashboard"); + revalidatePath("/usage"); + for (const path of affectedPaths) { + revalidatePath(path); + } + + return { success: true, data: undefined }; + } catch { + return { success: false, error: "Failed to log usage" }; + } +} diff --git a/src/app/(app)/usage/page.tsx b/src/app/(app)/usage/page.tsx new file mode 100644 index 0000000..f396318 --- /dev/null +++ b/src/app/(app)/usage/page.tsx @@ -0,0 +1,29 @@ +import { auth } from "@/lib/auth"; +import { redirect } from "next/navigation"; +import { getUsageLogs, getAllUserItems } from "@/data/usage.queries"; +import type { DataTableSearchParams } from "@/types/table.types"; +import { UsageTable } from "./_components/usage-table"; + +interface Props { + searchParams: Promise; +} + +export default async function UsagePage({ searchParams }: Props) { + const session = await auth(); + if (!session?.user?.id) redirect("/login"); + + const params = await searchParams; + const [result, items] = await Promise.all([ + getUsageLogs(session.user.id, params), + getAllUserItems(session.user.id), + ]); + + return ( + + ); +} diff --git a/src/components/layout/mobile-sidebar.tsx b/src/components/layout/mobile-sidebar.tsx index 9f35cc1..ea1068d 100644 --- a/src/components/layout/mobile-sidebar.tsx +++ b/src/components/layout/mobile-sidebar.tsx @@ -7,6 +7,8 @@ import { Cylinder, Droplets, Paintbrush, + Gem, + ClipboardList, Building2, MapPin, Settings, @@ -16,13 +18,15 @@ import { cn } from "@/lib/utils"; import { APP_NAME } from "@/lib/constants"; import { SheetHeader, SheetTitle } from "@/components/ui/sheet"; -const icons = { LayoutDashboard, Cylinder, Droplets, Paintbrush, Building2, MapPin, Settings }; +const icons = { LayoutDashboard, Cylinder, Droplets, Paintbrush, Gem, ClipboardList, Building2, MapPin, Settings }; const navItems = [ { label: "Dashboard", href: "/dashboard", icon: "LayoutDashboard" as const }, { label: "Filaments", href: "/filaments", icon: "Cylinder" as const }, { label: "Resins", href: "/resins", icon: "Droplets" as const }, { label: "Paints", href: "/paints", icon: "Paintbrush" as const }, + { label: "Supplies", href: "/supplies", icon: "Gem" as const }, + { label: "Usage", href: "/usage", icon: "ClipboardList" as const }, { label: "Vendors", href: "/vendors", icon: "Building2" as const }, { label: "Locations", href: "/locations", icon: "MapPin" as const }, { label: "Settings", href: "/settings", icon: "Settings" as const }, diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 2579870..826b5c5 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -9,6 +9,7 @@ import { Droplets, Paintbrush, Gem, + ClipboardList, Building2, MapPin, Settings, @@ -27,6 +28,7 @@ const icons = { Droplets, Paintbrush, Gem, + ClipboardList, Building2, MapPin, Settings, @@ -38,6 +40,7 @@ const navItems = [ { label: "Resins", href: "/resins", icon: "Droplets" as const }, { label: "Paints", href: "/paints", icon: "Paintbrush" as const }, { label: "Supplies", href: "/supplies", icon: "Gem" as const }, + { label: "Usage", href: "/usage", icon: "ClipboardList" as const }, { label: "Vendors", href: "/vendors", icon: "Building2" as const }, { label: "Locations", href: "/locations", icon: "MapPin" as const }, { label: "Settings", href: "/settings", icon: "Settings" as const }, diff --git a/src/components/shared/quick-usage-dialog.tsx b/src/components/shared/quick-usage-dialog.tsx new file mode 100644 index 0000000..02fe3d3 --- /dev/null +++ b/src/components/shared/quick-usage-dialog.tsx @@ -0,0 +1,298 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { Plus, Trash2 } from "lucide-react"; +import { toast } from "sonner"; +import { logBatchUsage } from "@/app/(app)/usage/actions"; +import type { PickerItem } from "@/data/usage.queries"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; + +const ITEM_TYPES = ["FILAMENT", "RESIN", "PAINT", "SUPPLY"] as const; +type ItemType = (typeof ITEM_TYPES)[number]; + +interface UsageRow { + id: string; + itemType: ItemType | ""; + itemId: string; + amount: string; + notes: string; +} + +function createEmptyRow(): UsageRow { + return { + id: crypto.randomUUID(), + itemType: "", + itemId: "", + amount: "", + notes: "", + }; +} + +interface QuickUsageDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + items: PickerItem[]; +} + +export function QuickUsageDialog({ open, onOpenChange, items }: QuickUsageDialogProps) { + const [rows, setRows] = useState([createEmptyRow()]); + const [isPending, startTransition] = useTransition(); + + function updateRow(id: string, updates: Partial) { + setRows((prev) => + prev.map((row) => { + if (row.id !== id) return row; + const updated = { ...row, ...updates }; + // Reset itemId when type changes + if (updates.itemType !== undefined && updates.itemType !== row.itemType) { + updated.itemId = ""; + } + return updated; + }) + ); + } + + function removeRow(id: string) { + setRows((prev) => { + if (prev.length <= 1) return prev; + return prev.filter((row) => row.id !== id); + }); + } + + function addRow() { + setRows((prev) => [...prev, createEmptyRow()]); + } + + function resetAndClose() { + setRows([createEmptyRow()]); + onOpenChange(false); + } + + function getItemsForType(type: ItemType | "") { + if (!type) return []; + return items.filter((item) => item.type === type); + } + + function getUnit(row: UsageRow): string { + if (!row.itemId) { + if (row.itemType === "FILAMENT") return "g"; + if (row.itemType === "RESIN" || row.itemType === "PAINT") return "ml"; + return ""; + } + const item = items.find((i) => i.id === row.itemId); + return item?.unit ?? ""; + } + + function isValid(): boolean { + return rows.every( + (row) => + row.itemType !== "" && + row.itemId !== "" && + row.amount !== "" && + Number(row.amount) > 0 + ); + } + + function handleSubmit() { + if (!isValid()) return; + + startTransition(async () => { + const entries = rows.map((row) => ({ + itemType: row.itemType as ItemType, + itemId: row.itemId, + amount: Number(row.amount), + notes: row.notes || undefined, + })); + + const result = await logBatchUsage({ entries }); + + if (!result.success) { + toast.error(result.error || "Failed to log usage"); + return; + } + + toast.success( + entries.length === 1 ? "Usage logged successfully" : `${entries.length} usage entries logged` + ); + resetAndClose(); + }); + } + + return ( + (o ? onOpenChange(true) : resetAndClose())}> + + + Log Usage + + Record material consumption for one or more items. + + + +
+ {rows.map((row, index) => { + const availableItems = getItemsForType(row.itemType); + const unit = getUnit(row); + + return ( +
+
+ + Item {index + 1} + + {rows.length > 1 && ( + + )} +
+ +
+ {/* Item Type */} +
+ + +
+ + {/* Item */} +
+ + +
+
+ +
+ {/* Amount */} +
+ + updateRow(row.id, { amount: e.target.value })} + className="h-9" + /> +
+ + {/* Notes */} +
+ + updateRow(row.id, { notes: e.target.value })} + className="h-9" + maxLength={512} + /> +
+
+
+ ); + })} + + {/* Add Row */} + + + {/* Actions */} +
+ + +
+
+
+
+ ); +} diff --git a/src/data/usage.queries.ts b/src/data/usage.queries.ts new file mode 100644 index 0000000..99132bc --- /dev/null +++ b/src/data/usage.queries.ts @@ -0,0 +1,114 @@ +import { prisma } from "@/lib/prisma"; +import { Prisma } from "@prisma/client"; +import type { DataTableSearchParams } from "@/types/table.types"; + +// ─── Item picker data ───────────────────────────────── + +export interface PickerItem { + id: string; + name: string; + type: "FILAMENT" | "RESIN" | "PAINT" | "SUPPLY"; + unit: string; +} + +export async function getAllUserItems(userId: string): Promise { + const [filaments, resins, paints, supplies] = await Promise.all([ + prisma.filament.findMany({ + where: { userId, archived: false }, + select: { id: true, name: true }, + orderBy: { name: "asc" }, + }), + prisma.resin.findMany({ + where: { userId, archived: false }, + select: { id: true, name: true }, + orderBy: { name: "asc" }, + }), + prisma.paint.findMany({ + where: { userId, archived: false }, + select: { id: true, name: true }, + orderBy: { name: "asc" }, + }), + prisma.supply.findMany({ + where: { userId, archived: false }, + select: { id: true, name: true, unit: true }, + orderBy: { name: "asc" }, + }), + ]); + + return [ + ...filaments.map((f) => ({ id: f.id, name: f.name, type: "FILAMENT" as const, unit: "g" })), + ...resins.map((r) => ({ id: r.id, name: r.name, type: "RESIN" as const, unit: "ml" })), + ...paints.map((p) => ({ id: p.id, name: p.name, type: "PAINT" as const, unit: "ml" })), + ...supplies.map((s) => ({ id: s.id, name: s.name, type: "SUPPLY" as const, unit: s.unit })), + ]; +} + +// ─── Usage log history ──────────────────────────────── + +export interface UsageLogRow { + id: string; + itemType: string; + itemName: string; + amount: number; + unit: string; + notes: string | null; + createdAt: Date; +} + +interface UsageSearchParams extends DataTableSearchParams { + itemType?: string | string[]; +} + +export async function getUsageLogs(userId: string, params: UsageSearchParams) { + const page = Number(params.page) || 1; + const perPage = Number(params.perPage) || 20; + const skip = (page - 1) * perPage; + + const itemTypes = Array.isArray(params.itemType) + ? params.itemType + : params.itemType + ? [params.itemType] + : []; + + const where: Prisma.UsageLogWhereInput = { + userId, + ...(itemTypes.length > 0 && { itemType: { in: itemTypes } }), + }; + + const sortField = params.sort || "createdAt"; + const sortOrder = params.order || "desc"; + + const [logs, totalCount] = await Promise.all([ + prisma.usageLog.findMany({ + where, + orderBy: { [sortField]: sortOrder }, + skip, + take: perPage, + include: { + filament: { select: { name: true } }, + resin: { select: { name: true } }, + paint: { select: { name: true } }, + supply: { select: { name: true } }, + }, + }), + prisma.usageLog.count({ where }), + ]); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data: UsageLogRow[] = logs.map((log: any) => ({ + id: log.id, + itemType: log.itemType, + itemName: + log.filament?.name ?? log.resin?.name ?? log.paint?.name ?? log.supply?.name ?? "Unknown", + amount: log.amount, + unit: log.unit, + notes: log.notes, + createdAt: log.createdAt, + })); + + return { + data, + pageCount: Math.ceil(totalCount / perPage), + totalCount, + }; +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 0e1d43f..9681b31 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -6,6 +6,7 @@ export const NAV_ITEMS = [ { label: "Resins", href: "/resins", icon: "Droplets" }, { label: "Paints", href: "/paints", icon: "Paintbrush" }, { label: "Supplies", href: "/supplies", icon: "Gem" }, + { label: "Usage", href: "/usage", icon: "ClipboardList" }, { label: "Vendors", href: "/vendors", icon: "Building2" }, { label: "Locations", href: "/locations", icon: "MapPin" }, { label: "Settings", href: "/settings", icon: "Settings" },