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 (
+
+ );
+}
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" },