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:
parent
446c226c77
commit
0d053d9bd4
3 changed files with 248 additions and 0 deletions
53
App/pelagia-portal/app/(portal)/admin/products/actions.ts
Normal file
53
App/pelagia-portal/app/(portal)/admin/products/actions.ts
Normal 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 };
|
||||||
|
}
|
||||||
97
App/pelagia-portal/app/(portal)/admin/products/page.tsx
Normal file
97
App/pelagia-portal/app/(portal)/admin/products/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue