mirror of
https://github.com/xCyanGrizzly/DragonsStash.git
synced 2026-05-10 22:01:16 +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 { 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>
|
||||
);
|
||||
|
||||
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,
|
||||
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 },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
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: "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" },
|
||||
|
||||
Reference in New Issue
Block a user