mirror of
https://github.com/xCyanGrizzly/DragonsStash.git
synced 2026-05-11 14:21:15 +00:00
Add log usage buttons on main page and seperate page
This commit is contained in:
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user