feat(products): admin product catalogue with auto price update on payment

Products: code, name, description; lastPrice and lastVendor are read-only.
On markPaid: for each line item linked to a product, Product.lastPrice and
lastVendorId are updated automatically and logged as PRODUCT_PRICE_UPDATED.
This commit is contained in:
Hardik 2026-05-06 00:15:47 +05:30
parent 446c226c77
commit 0d053d9bd4
3 changed files with 248 additions and 0 deletions

View file

@ -0,0 +1,53 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { z } from "zod";
import { revalidatePath } from "next/cache";
type ActionResult = { ok: true } | { error: string };
const productSchema = z.object({
code: z.string().min(1).max(50),
name: z.string().min(1).max(200),
description: z.string().optional(),
});
export async function createProduct(formData: FormData): Promise<ActionResult> {
const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "manage_products")) {
return { error: "Forbidden" };
}
const parsed = productSchema.safeParse({
code: formData.get("code"),
name: formData.get("name"),
description: formData.get("description") || undefined,
});
if (!parsed.success) return { error: parsed.error.errors[0].message };
const existing = await db.product.findUnique({ where: { code: parsed.data.code } });
if (existing) return { error: "A product with this code already exists." };
await db.product.create({ data: parsed.data });
revalidatePath("/admin/products");
return { ok: true };
}
export async function toggleProductActive(productId: string): Promise<ActionResult> {
const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "manage_products")) {
return { error: "Forbidden" };
}
const product = await db.product.findUnique({ where: { id: productId } });
if (!product) return { error: "Product not found" };
await db.product.update({
where: { id: productId },
data: { isActive: !product.isActive },
});
revalidatePath("/admin/products");
return { ok: true };
}

View file

@ -0,0 +1,97 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { redirect } from "next/navigation";
import { formatDate } from "@/lib/utils";
import { AddProductButton, ToggleProductButton } from "./product-form";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Product Catalogue" };
export default async function AdminProductsPage() {
const session = await auth();
if (!session?.user) redirect("/login");
if (!hasPermission(session.user.role, "manage_products")) redirect("/dashboard");
const products = await db.product.findMany({
orderBy: { code: "asc" },
include: { lastVendor: true },
});
return (
<div>
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-semibold text-neutral-900">Product Catalogue</h1>
<AddProductButton />
</div>
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-neutral-50 border-b border-neutral-200">
<tr>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Code</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Name</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Description</th>
<th className="px-4 py-3 text-right font-medium text-neutral-600">Last Price</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Last Vendor</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Updated</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Status</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
{products.length === 0 && (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-neutral-400">
No products yet. Add the first product to start building the catalogue.
</td>
</tr>
)}
{products.map((product) => (
<tr key={product.id} className="hover:bg-neutral-50">
<td className="px-4 py-3 font-mono text-xs text-neutral-600">{product.code}</td>
<td className="px-4 py-3 font-medium text-neutral-900">{product.name}</td>
<td className="px-4 py-3 text-neutral-500 max-w-xs truncate">
{product.description ?? <span className="italic"></span>}
</td>
<td className="px-4 py-3 text-right font-mono text-xs text-neutral-700">
{product.lastPrice !== null
? Number(product.lastPrice).toLocaleString("en-IN", {
style: "currency",
currency: "INR",
maximumFractionDigits: 2,
})
: <span className="text-neutral-400 italic"></span>}
</td>
<td className="px-4 py-3 text-neutral-600">
{product.lastVendor?.name ?? <span className="italic text-neutral-400"></span>}
</td>
<td className="px-4 py-3 text-neutral-500">{formatDate(product.updatedAt)}</td>
<td className="px-4 py-3">
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
product.isActive ? "bg-success-100 text-success-700" : "bg-neutral-100 text-neutral-500"
}`}>
{product.isActive ? "Active" : "Inactive"}
</span>
</td>
<td className="px-4 py-3">
<ToggleProductButton product={{
id: product.id,
code: product.code,
name: product.name,
description: product.description,
isActive: product.isActive,
}} />
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mt-3 text-xs text-neutral-400">
Last Price and Last Vendor are read-only updated automatically when a PO is marked as paid.
</p>
</div>
);
}

View file

@ -0,0 +1,98 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { AdminDialog } from "@/components/ui/admin-dialog";
import { createProduct, toggleProductActive } from "./actions";
type ProductRow = {
id: string;
code: string;
name: string;
description: string | null;
isActive: boolean;
};
function ProductFormFields({ product }: { product?: ProductRow }) {
return (
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Product Code *</label>
<input name="code" defaultValue={product?.code} required disabled={!!product}
className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 disabled:bg-neutral-50 disabled:text-neutral-400"
placeholder="e.g. FUEL-OIL-001" />
{product && <p className="mt-1 text-xs text-neutral-400">Product code cannot be changed after creation.</p>}
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Name *</label>
<input name="name" defaultValue={product?.name} required
className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20" />
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Description</label>
<textarea name="description" defaultValue={product?.description ?? ""} rows={2}
className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 resize-none" />
</div>
</div>
);
}
export function AddProductButton() {
const router = useRouter();
const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setPending(true);
setError("");
const result = await createProduct(new FormData(e.currentTarget));
if ("error" in result) { setError(result.error); setPending(false); }
else { setOpen(false); router.refresh(); }
}
return (
<>
<button onClick={() => setOpen(true)}
className="rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-primary-700 transition-colors">
+ Add Product
</button>
<AdminDialog title="Add Product" open={open} onClose={() => setOpen(false)}>
<form onSubmit={handleSubmit} className="space-y-4">
<ProductFormFields />
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
<div className="flex justify-end gap-3 pt-1">
<button type="button" onClick={() => setOpen(false)}
className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">
Cancel
</button>
<button type="submit" disabled={pending}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">
{pending ? "Creating…" : "Create Product"}
</button>
</div>
</form>
</AdminDialog>
</>
);
}
export function ToggleProductButton({ product }: { product: ProductRow }) {
const router = useRouter();
const [toggling, setToggling] = useState(false);
async function handleToggle() {
setToggling(true);
await toggleProductActive(product.id);
router.refresh();
setToggling(false);
}
return (
<button onClick={handleToggle} disabled={toggling}
className={`text-sm font-medium ${product.isActive ? "text-danger-600 hover:text-danger-700" : "text-success-600 hover:text-success-700"}`}>
{toggling ? "…" : product.isActive ? "Deactivate" : "Activate"}
</button>
);
}