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