mirror of
https://github.com/xCyanGrizzly/DragonsStash.git
synced 2026-05-11 06:11:15 +00:00
Add log usage buttons on main page and seperate page
This commit is contained in:
@@ -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;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
-- This is an empty migration.
|
||||||
82
src/app/(app)/dashboard/_components/recent-usage-card.tsx
Normal file
82
src/app/(app)/dashboard/_components/recent-usage-card.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,25 +1,35 @@
|
|||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getDashboardStats } from "@/data/dashboard.queries";
|
import { getDashboardStats } from "@/data/dashboard.queries";
|
||||||
|
import { getAllUserItems } from "@/data/usage.queries";
|
||||||
import { getUserSettings } from "@/data/settings.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 { StatCard } from "@/components/shared/stat-card";
|
||||||
import { ColorSwatch } from "@/components/shared/color-swatch";
|
import { ColorSwatch } from "@/components/shared/color-swatch";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
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() {
|
export default async function DashboardPage() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.id) redirect("/login");
|
if (!session?.user?.id) redirect("/login");
|
||||||
|
|
||||||
const settings = await getUserSettings(session.user.id);
|
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", {
|
const currencyFormatter = new Intl.NumberFormat("en-US", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency: settings.currency,
|
currency: settings.currency,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Serialize dates for the client component
|
||||||
|
const serializedUsage = stats.recentUsage.map((log) => ({
|
||||||
|
...log,
|
||||||
|
createdAt: log.createdAt.toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Stats Grid */}
|
{/* Stats Grid */}
|
||||||
@@ -33,7 +43,7 @@ export default async function DashboardPage() {
|
|||||||
<StatCard
|
<StatCard
|
||||||
title="Inventory Value"
|
title="Inventory Value"
|
||||||
value={currencyFormatter.format(stats.inventoryValue)}
|
value={currencyFormatter.format(stats.inventoryValue)}
|
||||||
icon={DollarSign}
|
icon={Euro}
|
||||||
description="Total purchase cost"
|
description="Total purchase cost"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
@@ -85,41 +95,7 @@ export default async function DashboardPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Recent Usage */}
|
{/* Recent Usage */}
|
||||||
<Card className="border-border">
|
<RecentUsageCard recentUsage={serializedUsage} items={items} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
63
src/app/(app)/usage/_components/usage-columns.tsx
Normal file
63
src/app/(app)/usage/_components/usage-columns.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
77
src/app/(app)/usage/_components/usage-table.tsx
Normal file
77
src/app/(app)/usage/_components/usage-table.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
160
src/app/(app)/usage/actions.ts
Normal file
160
src/app/(app)/usage/actions.ts
Normal 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" };
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/app/(app)/usage/page.tsx
Normal file
29
src/app/(app)/usage/page.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
Cylinder,
|
Cylinder,
|
||||||
Droplets,
|
Droplets,
|
||||||
Paintbrush,
|
Paintbrush,
|
||||||
|
Gem,
|
||||||
|
ClipboardList,
|
||||||
Building2,
|
Building2,
|
||||||
MapPin,
|
MapPin,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -16,13 +18,15 @@ import { cn } from "@/lib/utils";
|
|||||||
import { APP_NAME } from "@/lib/constants";
|
import { APP_NAME } from "@/lib/constants";
|
||||||
import { SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
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 = [
|
const navItems = [
|
||||||
{ label: "Dashboard", href: "/dashboard", icon: "LayoutDashboard" as const },
|
{ label: "Dashboard", href: "/dashboard", icon: "LayoutDashboard" as const },
|
||||||
{ label: "Filaments", href: "/filaments", icon: "Cylinder" as const },
|
{ label: "Filaments", href: "/filaments", icon: "Cylinder" as const },
|
||||||
{ label: "Resins", href: "/resins", icon: "Droplets" as const },
|
{ label: "Resins", href: "/resins", icon: "Droplets" as const },
|
||||||
{ label: "Paints", href: "/paints", icon: "Paintbrush" 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: "Vendors", href: "/vendors", icon: "Building2" as const },
|
||||||
{ label: "Locations", href: "/locations", icon: "MapPin" as const },
|
{ label: "Locations", href: "/locations", icon: "MapPin" as const },
|
||||||
{ label: "Settings", href: "/settings", icon: "Settings" as const },
|
{ label: "Settings", href: "/settings", icon: "Settings" as const },
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Droplets,
|
Droplets,
|
||||||
Paintbrush,
|
Paintbrush,
|
||||||
Gem,
|
Gem,
|
||||||
|
ClipboardList,
|
||||||
Building2,
|
Building2,
|
||||||
MapPin,
|
MapPin,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -27,6 +28,7 @@ const icons = {
|
|||||||
Droplets,
|
Droplets,
|
||||||
Paintbrush,
|
Paintbrush,
|
||||||
Gem,
|
Gem,
|
||||||
|
ClipboardList,
|
||||||
Building2,
|
Building2,
|
||||||
MapPin,
|
MapPin,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -38,6 +40,7 @@ const navItems = [
|
|||||||
{ label: "Resins", href: "/resins", icon: "Droplets" as const },
|
{ label: "Resins", href: "/resins", icon: "Droplets" as const },
|
||||||
{ label: "Paints", href: "/paints", icon: "Paintbrush" as const },
|
{ label: "Paints", href: "/paints", icon: "Paintbrush" as const },
|
||||||
{ label: "Supplies", href: "/supplies", icon: "Gem" 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: "Vendors", href: "/vendors", icon: "Building2" as const },
|
||||||
{ label: "Locations", href: "/locations", icon: "MapPin" as const },
|
{ label: "Locations", href: "/locations", icon: "MapPin" as const },
|
||||||
{ label: "Settings", href: "/settings", icon: "Settings" as const },
|
{ label: "Settings", href: "/settings", icon: "Settings" as const },
|
||||||
|
|||||||
298
src/components/shared/quick-usage-dialog.tsx
Normal file
298
src/components/shared/quick-usage-dialog.tsx
Normal 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
114
src/data/usage.queries.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ export const NAV_ITEMS = [
|
|||||||
{ label: "Resins", href: "/resins", icon: "Droplets" },
|
{ label: "Resins", href: "/resins", icon: "Droplets" },
|
||||||
{ label: "Paints", href: "/paints", icon: "Paintbrush" },
|
{ label: "Paints", href: "/paints", icon: "Paintbrush" },
|
||||||
{ label: "Supplies", href: "/supplies", icon: "Gem" },
|
{ label: "Supplies", href: "/supplies", icon: "Gem" },
|
||||||
|
{ label: "Usage", href: "/usage", icon: "ClipboardList" },
|
||||||
{ label: "Vendors", href: "/vendors", icon: "Building2" },
|
{ label: "Vendors", href: "/vendors", icon: "Building2" },
|
||||||
{ label: "Locations", href: "/locations", icon: "MapPin" },
|
{ label: "Locations", href: "/locations", icon: "MapPin" },
|
||||||
{ label: "Settings", href: "/settings", icon: "Settings" },
|
{ label: "Settings", href: "/settings", icon: "Settings" },
|
||||||
|
|||||||
Reference in New Issue
Block a user