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}
/>
);
}