Add log usage buttons on main page and seperate page

This commit is contained in:
xCyanGrizzly
2026-02-19 11:46:19 +01:00
parent 32683ecf5e
commit beb9cfb312
13 changed files with 919 additions and 40 deletions

View File

@@ -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;

View File

@@ -0,0 +1 @@
-- This is an empty migration.

View File

@@ -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 (
<>
<Card className="border-border">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Recent Usage</CardTitle>
<CardDescription>Latest consumption log entries</CardDescription>
</div>
<Button size="sm" onClick={() => setDialogOpen(true)}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
Log Usage
</Button>
</div>
</CardHeader>
<CardContent>
{recentUsage.length === 0 ? (
<p className="text-sm text-muted-foreground">No usage logged yet.</p>
) : (
<div className="space-y-3">
{recentUsage.map((log) => (
<div key={log.id} className="flex items-center gap-3">
<Badge variant="outline" className="text-[10px] shrink-0">
{log.itemType}
</Badge>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{log.itemName}</p>
<p className="text-xs text-muted-foreground">
{log.notes || "No notes"}
</p>
</div>
<div className="text-right shrink-0">
<p className="text-sm font-medium">
-{log.amount}{log.unit}
</p>
<p className="text-xs text-muted-foreground">
{new Date(log.createdAt).toLocaleDateString()}
</p>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
<QuickUsageDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
items={items}
/>
</>
);
}

View File

@@ -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 (
<div className="space-y-6">
{/* Stats Grid */}
@@ -33,7 +43,7 @@ export default async function DashboardPage() {
<StatCard
title="Inventory Value"
value={currencyFormatter.format(stats.inventoryValue)}
icon={DollarSign}
icon={Euro}
description="Total purchase cost"
/>
<StatCard
@@ -85,41 +95,7 @@ export default async function DashboardPage() {
</Card>
{/* Recent Usage */}
<Card className="border-border">
<CardHeader className="pb-3">
<CardTitle className="text-base">Recent Usage</CardTitle>
<CardDescription>Latest consumption log entries</CardDescription>
</CardHeader>
<CardContent>
{stats.recentUsage.length === 0 ? (
<p className="text-sm text-muted-foreground">No usage logged yet.</p>
) : (
<div className="space-y-3">
{stats.recentUsage.map((log) => (
<div key={log.id} className="flex items-center gap-3">
<Badge variant="outline" className="text-[10px] shrink-0">
{log.itemType}
</Badge>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{log.itemName}</p>
<p className="text-xs text-muted-foreground">
{log.notes || "No notes"}
</p>
</div>
<div className="text-right shrink-0">
<p className="text-sm font-medium">
-{log.amount}{log.unit}
</p>
<p className="text-xs text-muted-foreground">
{new Date(log.createdAt).toLocaleDateString()}
</p>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
<RecentUsageCard recentUsage={serializedUsage} items={items} />
</div>
</div>
);

View File

@@ -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<UsageLogRow, unknown>[] {
return [
{
accessorKey: "createdAt",
header: ({ column }) => <DataTableColumnHeader column={column} title="Date" />,
cell: ({ row }) => {
const date = new Date(row.original.createdAt);
return (
<div className="text-sm">
<p>{date.toLocaleDateString()}</p>
<p className="text-xs text-muted-foreground">{date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</p>
</div>
);
},
size: 130,
},
{
accessorKey: "itemType",
header: ({ column }) => <DataTableColumnHeader column={column} title="Type" />,
cell: ({ row }) => (
<Badge variant="outline" className="text-[10px]">
{row.original.itemType}
</Badge>
),
size: 100,
},
{
accessorKey: "itemName",
header: ({ column }) => <DataTableColumnHeader column={column} title="Item" />,
cell: ({ row }) => (
<p className="text-sm font-medium truncate max-w-[200px]">{row.original.itemName}</p>
),
enableSorting: false,
},
{
accessorKey: "amount",
header: ({ column }) => <DataTableColumnHeader column={column} title="Amount" />,
cell: ({ row }) => (
<span className="text-sm font-medium">
-{row.original.amount}{row.original.unit}
</span>
),
size: 100,
},
{
accessorKey: "notes",
header: "Notes",
cell: ({ row }) => (
<p className="text-sm text-muted-foreground truncate max-w-[250px]">
{row.original.notes || "\u2014"}
</p>
),
enableSorting: false,
},
];
}

View File

@@ -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<string>) => {
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 (
<div className="space-y-4">
<PageHeader title="Usage History" description="Track material consumption across all items">
<Button onClick={() => setDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Log Usage
</Button>
</PageHeader>
<div className="flex flex-wrap items-center gap-2">
<DataTableFacetedFilter
title="Item Type"
options={ITEM_TYPE_OPTIONS}
selectedValues={itemTypeFilter}
onSelectionChange={(values) => updateFilters("itemType", values)}
/>
</div>
<DataTable table={table} emptyMessage="No usage logged yet. Start tracking your consumption!" />
<DataTablePagination table={table} totalCount={totalCount} />
<QuickUsageDialog open={dialogOpen} onOpenChange={setDialogOpen} items={items} />
</div>
);
}

View File

@@ -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<ActionResult> {
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<string>();
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" };
}
}

View File

@@ -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<DataTableSearchParams>;
}
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 (
<UsageTable
data={JSON.parse(JSON.stringify(result.data))}
pageCount={result.pageCount}
totalCount={result.totalCount}
items={items}
/>
);
}

View File

@@ -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 },

View File

@@ -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 },

View File

@@ -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<UsageRow[]>([createEmptyRow()]);
const [isPending, startTransition] = useTransition();
function updateRow(id: string, updates: Partial<UsageRow>) {
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 (
<Dialog open={open} onOpenChange={(o) => (o ? onOpenChange(true) : resetAndClose())}>
<DialogContent className="sm:max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Log Usage</DialogTitle>
<DialogDescription>
Record material consumption for one or more items.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{rows.map((row, index) => {
const availableItems = getItemsForType(row.itemType);
const unit = getUnit(row);
return (
<div key={row.id} className="space-y-3 rounded-lg border border-border p-3">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">
Item {index + 1}
</span>
{rows.length > 1 && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => removeRow(row.id)}
>
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
)}
</div>
<div className="grid grid-cols-2 gap-3">
{/* Item Type */}
<div className="space-y-1.5">
<Label className="text-xs">Type</Label>
<Select
value={row.itemType}
onValueChange={(value) =>
updateRow(row.id, { itemType: value as ItemType })
}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
{ITEM_TYPES.map((type) => (
<SelectItem key={type} value={type}>
{type.charAt(0) + type.slice(1).toLowerCase()}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Item */}
<div className="space-y-1.5">
<Label className="text-xs">Item</Label>
<Select
value={row.itemId}
onValueChange={(value) => updateRow(row.id, { itemId: value })}
disabled={!row.itemType}
>
<SelectTrigger className="h-9">
<SelectValue
placeholder={
row.itemType ? "Select item" : "Select type first"
}
/>
</SelectTrigger>
<SelectContent>
{availableItems.length === 0 ? (
<SelectItem value="__empty" disabled>
No items available
</SelectItem>
) : (
availableItems.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.name}
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
{/* Amount */}
<div className="space-y-1.5">
<Label className="text-xs">
Amount{unit ? ` (${unit})` : ""}
</Label>
<Input
type="number"
step="0.1"
min="0"
placeholder={unit ? `Amount in ${unit}` : "Amount"}
value={row.amount}
onChange={(e) => updateRow(row.id, { amount: e.target.value })}
className="h-9"
/>
</div>
{/* Notes */}
<div className="space-y-1.5">
<Label className="text-xs">Notes (optional)</Label>
<Input
placeholder="What was this used for?"
value={row.notes}
onChange={(e) => updateRow(row.id, { notes: e.target.value })}
className="h-9"
maxLength={512}
/>
</div>
</div>
</div>
);
})}
{/* Add Row */}
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
onClick={addRow}
>
<Plus className="mr-2 h-3.5 w-3.5" />
Add Another Item
</Button>
{/* Actions */}
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
variant="outline"
onClick={resetAndClose}
disabled={isPending}
>
Cancel
</Button>
<Button
type="button"
onClick={handleSubmit}
disabled={isPending || !isValid()}
>
{isPending
? "Logging..."
: rows.length === 1
? "Log Usage"
: `Log ${rows.length} Items`}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

114
src/data/usage.queries.ts Normal file
View File

@@ -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<PickerItem[]> {
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,
};
}

View File

@@ -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" },