From 0d053d9bd4460951c2eb2b1f1637b1706f8cfcd3 Mon Sep 17 00:00:00 2001 From: Hardik Date: Wed, 6 May 2026 00:15:47 +0530 Subject: [PATCH] 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. --- .../app/(portal)/admin/products/actions.ts | 53 ++++++++++ .../app/(portal)/admin/products/page.tsx | 97 ++++++++++++++++++ .../(portal)/admin/products/product-form.tsx | 98 +++++++++++++++++++ 3 files changed, 248 insertions(+) create mode 100644 App/pelagia-portal/app/(portal)/admin/products/actions.ts create mode 100644 App/pelagia-portal/app/(portal)/admin/products/page.tsx create mode 100644 App/pelagia-portal/app/(portal)/admin/products/product-form.tsx diff --git a/App/pelagia-portal/app/(portal)/admin/products/actions.ts b/App/pelagia-portal/app/(portal)/admin/products/actions.ts new file mode 100644 index 0000000..e411ef9 --- /dev/null +++ b/App/pelagia-portal/app/(portal)/admin/products/actions.ts @@ -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 { + 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 { + 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 }; +} diff --git a/App/pelagia-portal/app/(portal)/admin/products/page.tsx b/App/pelagia-portal/app/(portal)/admin/products/page.tsx new file mode 100644 index 0000000..f3dc8aa --- /dev/null +++ b/App/pelagia-portal/app/(portal)/admin/products/page.tsx @@ -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 ( +
+
+

Product Catalogue

+ +
+ +
+ + + + + + + + + + + + + + + {products.length === 0 && ( + + + + )} + {products.map((product) => ( + + + + + + + + + + + ))} + +
CodeNameDescriptionLast PriceLast VendorUpdatedStatus
+ No products yet. Add the first product to start building the catalogue. +
{product.code}{product.name} + {product.description ?? } + + {product.lastPrice !== null + ? Number(product.lastPrice).toLocaleString("en-IN", { + style: "currency", + currency: "INR", + maximumFractionDigits: 2, + }) + : } + + {product.lastVendor?.name ?? } + {formatDate(product.updatedAt)} + + {product.isActive ? "Active" : "Inactive"} + + + +
+
+ +

+ Last Price and Last Vendor are read-only — updated automatically when a PO is marked as paid. +

+
+ ); +} diff --git a/App/pelagia-portal/app/(portal)/admin/products/product-form.tsx b/App/pelagia-portal/app/(portal)/admin/products/product-form.tsx new file mode 100644 index 0000000..1c49014 --- /dev/null +++ b/App/pelagia-portal/app/(portal)/admin/products/product-form.tsx @@ -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 ( +
+
+ + + {product &&

Product code cannot be changed after creation.

} +
+
+ + +
+
+ +