Merge pull request 'feat(po): catalogue line items on approval + move /inventory/{items,vendors} ? /catalogue' (#108) from feat/catalog-on-approval into master
All checks were successful
Refresh staging / refresh (push) Successful in 8s
All checks were successful
Refresh staging / refresh (push) Successful in 8s
Reviewed-on: #108
This commit is contained in:
commit
c710fe5d73
23 changed files with 202 additions and 141 deletions
|
|
@ -137,6 +137,10 @@ The pipeline (no mailto attachment — `mailto:` can't carry files): `prepareVen
|
|||
|
||||
Inventory (`ItemInventory`, keyed by `productId` + `siteId`) is **incremented at PO approval** — not on close — for the ordered quantities, when the PO has a `siteId`. The whole inventory surface (site stock, consumption) is gated by `NEXT_PUBLIC_INVENTORY_ENABLED` (see `lib/feature-flags.ts`); the vendor/product catalogue used for PO creation stays available regardless.
|
||||
|
||||
### Product catalogue sync (`lib/product-catalog.ts`)
|
||||
|
||||
`syncProductCatalog(poId, lineItems, vendorId, actorId)` registers a PO's line items as reusable **`Product`s** (the `/catalogue/items` catalogue): a line item with no `productId` is matched to an existing product by name (case-insensitive) or a new product is created, then the line item is linked back; `lastPrice`/`lastVendorId` and the per-vendor `ProductVendorPrice` are upserted. It runs **at approval** (`approvePo`) so an approved PO's items are immediately reusable in further POs, **and again at full payment** (`markPaid`) to refresh prices on the final figures. Idempotent — re-running matches the same product. (Import takes its own auto-create path.)
|
||||
|
||||
### Import → Closed
|
||||
|
||||
`/po/import` parses a Pelagia-format Excel PO and saves it **directly as `CLOSED`** (historical record, bypasses approval). It auto-detects the company (by header/code), auto-matches the vessel by code, **auto-creates the vendor and any unknown products**, and upserts per-vendor prices.
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { formatCurrency, formatDate } from "@/lib/utils";
|
|||
import { distanceKm, formatDistance } from "@/lib/geo";
|
||||
import { ToggleProductButton, EditProductButton } from "../product-form";
|
||||
import { AddToCartButton } from "@/components/inventory/add-to-cart-button";
|
||||
import { ItemPriceChart } from "@/app/(portal)/inventory/items/[id]/item-price-chart";
|
||||
import { ItemPriceChart } from "@/app/(portal)/catalogue/items/[id]/item-price-chart";
|
||||
import { SiteSelect } from "@/components/inventory/site-select";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ function ProductActionsMenu({ product }: { product: ProductRow }) {
|
|||
export function ProductsTable({
|
||||
products,
|
||||
canManage,
|
||||
detailBase = "/inventory/items",
|
||||
detailBase = "/catalogue/items",
|
||||
}: {
|
||||
products: ProductRow[];
|
||||
canManage: boolean;
|
||||
|
|
|
|||
4
App/app/(portal)/admin/vendors/actions.ts
vendored
4
App/app/(portal)/admin/vendors/actions.ts
vendored
|
|
@ -95,7 +95,7 @@ export async function createVendor(formData: FormData): Promise<ActionResult> {
|
|||
});
|
||||
|
||||
revalidatePath("/admin/vendors");
|
||||
revalidatePath("/inventory/vendors");
|
||||
revalidatePath("/catalogue/vendors");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
|
|
@ -108,7 +108,7 @@ export async function verifyVendor(vendorId: string): Promise<ActionResult> {
|
|||
|
||||
await db.vendor.update({ where: { id: vendorId }, data: { isVerified: true } });
|
||||
revalidatePath("/admin/vendors");
|
||||
revalidatePath("/inventory/vendors");
|
||||
revalidatePath("/catalogue/vendors");
|
||||
revalidatePath(`/admin/vendors/${vendorId}`);
|
||||
return { ok: true };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { auth } from "@/auth";
|
|||
import { db } from "@/lib/db";
|
||||
import { canPerformAction } from "@/lib/po-state-machine";
|
||||
import { approvePoSchema } from "@/lib/validations/po";
|
||||
import { syncProductCatalog } from "@/lib/product-catalog";
|
||||
import { notify } from "@/lib/notifier";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
|
|
@ -84,6 +85,12 @@ export async function approvePo({
|
|||
revalidatePath(`/admin/sites/${siteId}`);
|
||||
}
|
||||
|
||||
// Register the line items in the product catalogue (/catalogue/items) on
|
||||
// approval, so an approved PO's items are immediately reusable in further POs.
|
||||
// Idempotent; payment re-syncs to refresh prices on the final figures.
|
||||
await syncProductCatalog(poId, po.lineItems, po.vendorId, session.user.id);
|
||||
revalidatePath("/catalogue/items");
|
||||
|
||||
const accounts = await db.user.findMany({ where: { role: "ACCOUNTS", isActive: true } });
|
||||
await notify({
|
||||
event: withNote ? "PO_APPROVED_WITH_NOTE" : "PO_APPROVED",
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export default async function ItemDetailPage({ params, searchParams }: Props) {
|
|||
|
||||
const { id } = await params;
|
||||
const { site: siteId } = await searchParams;
|
||||
const baseHref = `/inventory/items/${id}`;
|
||||
const baseHref = `/catalogue/items/${id}`;
|
||||
|
||||
const [product, sites] = await Promise.all([
|
||||
db.product.findUnique({
|
||||
|
|
@ -85,7 +85,7 @@ export default async function ItemDetailPage({ params, searchParams }: Props) {
|
|||
<div className="max-w-6xl space-y-6">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-sm text-neutral-500">
|
||||
<Link href="/inventory/items" className="hover:text-neutral-700">Items</Link>
|
||||
<Link href="/catalogue/items" className="hover:text-neutral-700">Items</Link>
|
||||
<span>/</span>
|
||||
<span className="text-neutral-900 font-medium">{product.name}</span>
|
||||
</div>
|
||||
|
|
@ -108,7 +108,7 @@ export function ItemsTable({
|
|||
value={currentSiteId ?? ""}
|
||||
onChange={(e) => {
|
||||
const id = e.target.value;
|
||||
router.push(id ? `/inventory/items?siteId=${id}` : "/inventory/items");
|
||||
router.push(id ? `/catalogue/items?siteId=${id}` : "/catalogue/items");
|
||||
}}
|
||||
className="flex-1 max-w-xs rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-1.5 text-sm text-neutral-700 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
|
||||
>
|
||||
|
|
@ -254,7 +254,7 @@ export function ItemsTable({
|
|||
<td className="px-12 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={`/inventory/vendors/${vendor.vendorId}`}
|
||||
href={`/catalogue/vendors/${vendor.vendorId}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="font-medium text-neutral-800 hover:text-primary-600 hover:underline"
|
||||
>
|
||||
|
|
@ -20,7 +20,7 @@ export default async function InventoryItemsPage() {
|
|||
},
|
||||
});
|
||||
|
||||
// canManage lets managers/admins see the Edit/Delete controls even from /inventory/items
|
||||
// canManage lets managers/admins see the Edit/Delete controls even from /catalogue/items
|
||||
const canManage = hasPermission(session.user.role, "manage_products");
|
||||
|
||||
return (
|
||||
|
|
@ -48,7 +48,7 @@ export default async function InventoryVendorDetailPage({ params }: Props) {
|
|||
<div className="max-w-5xl space-y-6">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-sm text-neutral-500">
|
||||
<Link href="/inventory/vendors" className="hover:text-neutral-700">Vendors</Link>
|
||||
<Link href="/catalogue/vendors" className="hover:text-neutral-700">Vendors</Link>
|
||||
<span>/</span>
|
||||
<span className="text-neutral-900 font-medium">{vendor.name}</span>
|
||||
</div>
|
||||
|
|
@ -68,7 +68,7 @@ export function VendorsTable({
|
|||
value={currentSiteId ?? ""}
|
||||
onChange={(e) => {
|
||||
const id = e.target.value;
|
||||
router.push(id ? `/inventory/vendors?siteId=${id}` : "/inventory/vendors");
|
||||
router.push(id ? `/catalogue/vendors?siteId=${id}` : "/catalogue/vendors");
|
||||
}}
|
||||
className="flex-1 max-w-xs rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-1.5 text-sm text-neutral-700 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
|
||||
>
|
||||
|
|
@ -149,7 +149,7 @@ export function VendorsTable({
|
|||
<tr key={vendor.id} className="hover:bg-neutral-50">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href={`/inventory/vendors/${vendor.id}`} className="font-medium text-neutral-900 hover:text-primary-600 hover:underline">
|
||||
<Link href={`/catalogue/vendors/${vendor.id}`} className="font-medium text-neutral-900 hover:text-primary-600 hover:underline">
|
||||
{vendor.name}
|
||||
</Link>
|
||||
{vendor.vendorId && (
|
||||
|
|
@ -46,8 +46,8 @@ export function CartView() {
|
|||
<p className="text-neutral-500 font-medium">Your cart is empty</p>
|
||||
<p className="text-sm text-neutral-400 mt-1 mb-6">Browse Items or Vendors to add line items</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<Link href="/inventory/items" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">Browse Items</Link>
|
||||
<Link href="/inventory/vendors" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">Browse Vendors</Link>
|
||||
<Link href="/catalogue/items" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">Browse Items</Link>
|
||||
<Link href="/catalogue/vendors" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">Browse Vendors</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -108,7 +108,7 @@ export function CartView() {
|
|||
<div className="flex items-center justify-between">
|
||||
<button onClick={() => { clearCart(); setItems([]); }} className="text-sm text-danger-600 hover:underline">Clear cart</button>
|
||||
<div className="flex gap-3">
|
||||
<Link href="/inventory/items" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">
|
||||
<Link href="/catalogue/items" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">
|
||||
+ Add more items
|
||||
</Link>
|
||||
<button onClick={createPO} className="rounded-lg bg-primary-600 px-5 py-2 text-sm font-semibold text-white hover:bg-primary-700">
|
||||
|
|
|
|||
|
|
@ -4,107 +4,12 @@ import { auth } from "@/auth";
|
|||
import { db } from "@/lib/db";
|
||||
import { canPerformAction } from "@/lib/po-state-machine";
|
||||
import { processPaymentSchema } from "@/lib/validations/po";
|
||||
import { syncProductCatalog } from "@/lib/product-catalog";
|
||||
import { notify } from "@/lib/notifier";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
type ActionResult = { ok: true } | { error: string };
|
||||
|
||||
function nameToCode(name: string): string {
|
||||
const slug = name.toUpperCase()
|
||||
.replace(/[^A-Z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
.substring(0, 20);
|
||||
return `${slug}-${Date.now().toString(36).toUpperCase().slice(-5)}`;
|
||||
}
|
||||
|
||||
// Sync product catalog after payment is confirmed:
|
||||
// - Auto-create products for unlinked line items (matched by name or brand new)
|
||||
// - Upsert per-vendor prices for all items
|
||||
async function syncProductCatalog(
|
||||
poId: string,
|
||||
lineItems: { id: string; name: string; unitPrice: { toNumber(): number } | number; productId: string | null }[],
|
||||
vendorId: string | null,
|
||||
actorId: string
|
||||
) {
|
||||
const updatedProductIds: string[] = [];
|
||||
|
||||
for (const li of lineItems) {
|
||||
const unitPrice = typeof li.unitPrice === "number" ? li.unitPrice : li.unitPrice.toNumber();
|
||||
let productId = li.productId;
|
||||
let priceChanged = false;
|
||||
|
||||
if (!productId) {
|
||||
// Try to find an existing product by name (case-insensitive)
|
||||
const existing = await db.product.findFirst({
|
||||
where: { name: { equals: li.name, mode: "insensitive" }, isActive: true },
|
||||
select: { id: true, lastPrice: true },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
productId = existing.id;
|
||||
priceChanged = Number(existing.lastPrice ?? 0) !== unitPrice;
|
||||
} else {
|
||||
// Create a new product — first-time registration, not a price update
|
||||
const code = nameToCode(li.name);
|
||||
try {
|
||||
const created = await db.product.create({
|
||||
data: { code, name: li.name, lastPrice: unitPrice, lastVendorId: vendorId },
|
||||
});
|
||||
productId = created.id;
|
||||
} catch {
|
||||
// Code collision (extremely unlikely) — add extra entropy
|
||||
const created = await db.product.create({
|
||||
data: {
|
||||
code: `${code}-${Math.random().toString(36).slice(2, 5).toUpperCase()}`,
|
||||
name: li.name,
|
||||
lastPrice: unitPrice,
|
||||
lastVendorId: vendorId,
|
||||
},
|
||||
});
|
||||
productId = created.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Link the line item to the product for future reference
|
||||
await db.pOLineItem.update({ where: { id: li.id }, data: { productId } });
|
||||
} else {
|
||||
const current = await db.product.findUnique({
|
||||
where: { id: productId },
|
||||
select: { lastPrice: true },
|
||||
});
|
||||
priceChanged = !current || Number(current.lastPrice ?? 0) !== unitPrice;
|
||||
}
|
||||
|
||||
// Always update lastPrice / lastVendorId on the product
|
||||
await db.product.update({
|
||||
where: { id: productId },
|
||||
data: { lastPrice: unitPrice, lastVendorId: vendorId ?? undefined },
|
||||
});
|
||||
|
||||
// Upsert per-vendor price if PO has a vendor
|
||||
if (vendorId) {
|
||||
await db.productVendorPrice.upsert({
|
||||
where: { productId_vendorId: { productId, vendorId } },
|
||||
update: { price: unitPrice },
|
||||
create: { productId, vendorId, price: unitPrice },
|
||||
});
|
||||
}
|
||||
|
||||
if (priceChanged) updatedProductIds.push(productId);
|
||||
}
|
||||
|
||||
if (updatedProductIds.length > 0) {
|
||||
await db.pOAction.create({
|
||||
data: {
|
||||
actionType: "PRODUCT_PRICE_UPDATED",
|
||||
actorId,
|
||||
poId,
|
||||
metadata: { updatedProductIds },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1: Accounts picks up the PO — MGR_APPROVED → SENT_FOR_PAYMENT
|
||||
export async function processPayment({ poId }: { poId: string }): Promise<ActionResult> {
|
||||
const session = await auth();
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@ export async function confirmReceipt({
|
|||
if (newStatus === "CLOSED" && po.vendorId) {
|
||||
await db.vendor.update({ where: { id: po.vendorId }, data: { isVerified: true } });
|
||||
revalidatePath("/admin/vendors");
|
||||
revalidatePath("/inventory/vendors");
|
||||
revalidatePath("/catalogue/vendors");
|
||||
}
|
||||
|
||||
const managers = await db.user.findMany({ where: { role: "MANAGER", isActive: true } });
|
||||
|
|
|
|||
|
|
@ -189,7 +189,7 @@ export async function importPo(
|
|||
if (resolvedVendorId) {
|
||||
await db.vendor.update({ where: { id: resolvedVendorId }, data: { isVerified: true } });
|
||||
revalidatePath("/admin/vendors");
|
||||
revalidatePath("/inventory/vendors");
|
||||
revalidatePath("/catalogue/vendors");
|
||||
}
|
||||
|
||||
revalidatePath("/history");
|
||||
|
|
|
|||
|
|
@ -69,16 +69,16 @@ const NAV_ITEMS: NavItem[] = [
|
|||
// ── Purchasing section ────────────────────────────────────────────────────────
|
||||
// Staff browsing items (product catalogue + cart for PO creation)
|
||||
const PURCHASING_STAFF: NavItem[] = [
|
||||
{ href: "/inventory/items", label: "Items", icon: Package, roles: ["TECHNICAL", "MANNING", "SUPERUSER"] },
|
||||
{ href: "/inventory/vendors", label: "Vendors", icon: Store, roles: ["TECHNICAL", "MANNING", "SUPERUSER"] },
|
||||
{ href: "/catalogue/items", label: "Items", icon: Package, roles: ["TECHNICAL", "MANNING", "SUPERUSER"] },
|
||||
{ href: "/catalogue/vendors", label: "Vendors", icon: Store, roles: ["TECHNICAL", "MANNING", "SUPERUSER"] },
|
||||
{ href: "/inventory/cart", label: "Cart", icon: ShoppingCart, roles: ["TECHNICAL", "MANNING", "SUPERUSER", "MANAGER"] },
|
||||
];
|
||||
|
||||
// Manager catalogue management — Sites conditionally shown
|
||||
// Admin does not use Purchasing; their links live under Administration
|
||||
const PURCHASING_MGMT: NavItem[] = [
|
||||
{ href: "/inventory/vendors", label: "Vendors", icon: Store, roles: ["MANAGER"] },
|
||||
{ href: "/inventory/items", label: "Items", icon: Package, roles: ["MANAGER"] },
|
||||
{ href: "/catalogue/vendors", label: "Vendors", icon: Store, roles: ["MANAGER"] },
|
||||
{ href: "/catalogue/items", label: "Items", icon: Package, roles: ["MANAGER"] },
|
||||
{ href: "/admin/vessels", label: "Cost Centres", icon: Ship, roles: ["MANAGER"] },
|
||||
...(INVENTORY_ENABLED
|
||||
? [{ href: "/admin/sites", label: "Sites", icon: MapPin, roles: ["MANAGER"] as Role[] }]
|
||||
|
|
|
|||
105
App/lib/product-catalog.ts
Normal file
105
App/lib/product-catalog.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { db } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Product catalogue sync — registers a PO's line items as reusable `Product`s
|
||||
* (the `/catalogue/items` catalogue) and keeps last/per-vendor prices fresh:
|
||||
* - line items with no `productId` are matched to an existing product by name,
|
||||
* or a brand-new product is created, and the line item is linked back;
|
||||
* - `lastPrice`/`lastVendorId` and the per-vendor price are upserted.
|
||||
*
|
||||
* Called at **approval** (so approved items are immediately reusable in further
|
||||
* POs) and again at **payment** (to refresh prices on the final figures). The
|
||||
* function is idempotent — re-running matches the same product by name/id.
|
||||
*/
|
||||
function nameToCode(name: string): string {
|
||||
const slug = name.toUpperCase()
|
||||
.replace(/[^A-Z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
.substring(0, 20);
|
||||
return `${slug}-${Date.now().toString(36).toUpperCase().slice(-5)}`;
|
||||
}
|
||||
|
||||
export async function syncProductCatalog(
|
||||
poId: string,
|
||||
lineItems: { id: string; name: string; unitPrice: { toNumber(): number } | number; productId: string | null }[],
|
||||
vendorId: string | null,
|
||||
actorId: string
|
||||
) {
|
||||
const updatedProductIds: string[] = [];
|
||||
|
||||
for (const li of lineItems) {
|
||||
const unitPrice = typeof li.unitPrice === "number" ? li.unitPrice : li.unitPrice.toNumber();
|
||||
let productId = li.productId;
|
||||
let priceChanged = false;
|
||||
|
||||
if (!productId) {
|
||||
// Try to find an existing product by name (case-insensitive)
|
||||
const existing = await db.product.findFirst({
|
||||
where: { name: { equals: li.name, mode: "insensitive" }, isActive: true },
|
||||
select: { id: true, lastPrice: true },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
productId = existing.id;
|
||||
priceChanged = Number(existing.lastPrice ?? 0) !== unitPrice;
|
||||
} else {
|
||||
// Create a new product — first-time registration, not a price update
|
||||
const code = nameToCode(li.name);
|
||||
try {
|
||||
const created = await db.product.create({
|
||||
data: { code, name: li.name, lastPrice: unitPrice, lastVendorId: vendorId },
|
||||
});
|
||||
productId = created.id;
|
||||
} catch {
|
||||
// Code collision (extremely unlikely) — add extra entropy
|
||||
const created = await db.product.create({
|
||||
data: {
|
||||
code: `${code}-${Math.random().toString(36).slice(2, 5).toUpperCase()}`,
|
||||
name: li.name,
|
||||
lastPrice: unitPrice,
|
||||
lastVendorId: vendorId,
|
||||
},
|
||||
});
|
||||
productId = created.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Link the line item to the product for future reference
|
||||
await db.pOLineItem.update({ where: { id: li.id }, data: { productId } });
|
||||
} else {
|
||||
const current = await db.product.findUnique({
|
||||
where: { id: productId },
|
||||
select: { lastPrice: true },
|
||||
});
|
||||
priceChanged = !current || Number(current.lastPrice ?? 0) !== unitPrice;
|
||||
}
|
||||
|
||||
// Always update lastPrice / lastVendorId on the product
|
||||
await db.product.update({
|
||||
where: { id: productId },
|
||||
data: { lastPrice: unitPrice, lastVendorId: vendorId ?? undefined },
|
||||
});
|
||||
|
||||
// Upsert per-vendor price if PO has a vendor
|
||||
if (vendorId) {
|
||||
await db.productVendorPrice.upsert({
|
||||
where: { productId_vendorId: { productId, vendorId } },
|
||||
update: { price: unitPrice },
|
||||
create: { productId, vendorId, price: unitPrice },
|
||||
});
|
||||
}
|
||||
|
||||
if (priceChanged) updatedProductIds.push(productId);
|
||||
}
|
||||
|
||||
if (updatedProductIds.length > 0) {
|
||||
await db.pOAction.create({
|
||||
data: {
|
||||
actionType: "PRODUCT_PRICE_UPDATED",
|
||||
actorId,
|
||||
poId,
|
||||
metadata: { updatedProductIds },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -4,9 +4,9 @@
|
|||
* - After adding an item to the cart, the badge count on the cart icon increases
|
||||
*
|
||||
* Feature 15 — Inventory item & vendor detail pages
|
||||
* - Clicking an item on /inventory/items navigates to /inventory/items/[id]
|
||||
* - Clicking an item on /catalogue/items navigates to /catalogue/items/[id]
|
||||
* - The item detail shows name, price, vendor info
|
||||
* - /inventory/vendors/[id] shows vendor details
|
||||
* - /catalogue/vendors/[id] shows vendor details
|
||||
*
|
||||
* Created: 2026-05-17
|
||||
*/
|
||||
|
|
@ -52,7 +52,7 @@ test.describe("Feature 14 — Cart header icon with badge", () => {
|
|||
await login(page, USERS.TECH);
|
||||
|
||||
// Navigate to inventory items
|
||||
await page.goto("/inventory/items");
|
||||
await page.goto("/catalogue/items");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const rows = page.locator("tbody tr");
|
||||
|
|
@ -97,15 +97,15 @@ test.describe("Feature 14 — Cart header icon with badge", () => {
|
|||
});
|
||||
|
||||
test.describe("Feature 15 — Inventory item & vendor detail pages", () => {
|
||||
test("US-15a: clicking an item row navigates to /inventory/items/[id]", async ({
|
||||
test("US-15a: clicking an item row navigates to /catalogue/items/[id]", async ({
|
||||
page,
|
||||
}) => {
|
||||
await login(page, USERS.TECH);
|
||||
await page.goto("/inventory/items");
|
||||
await page.goto("/catalogue/items");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Look for a direct link to an item detail page
|
||||
const itemLink = page.locator("a[href*='/inventory/items/']").first();
|
||||
const itemLink = page.locator("a[href*='/catalogue/items/']").first();
|
||||
if (await itemLink.isVisible()) {
|
||||
await itemLink.click();
|
||||
await expect(page).toHaveURL(/\/inventory\/items\/.+/);
|
||||
|
|
@ -150,14 +150,14 @@ test.describe("Feature 15 — Inventory item & vendor detail pages", () => {
|
|||
console.log(`✓ Item detail page loaded: ${page.url()}`);
|
||||
});
|
||||
|
||||
test("US-15b: /inventory/vendors/[id] shows vendor details for TECHNICAL user", async ({
|
||||
test("US-15b: /catalogue/vendors/[id] shows vendor details for TECHNICAL user", async ({
|
||||
page,
|
||||
}) => {
|
||||
await login(page, USERS.TECH);
|
||||
await page.goto("/inventory/vendors");
|
||||
await page.goto("/catalogue/vendors");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const vendorLink = page.locator("a[href*='/inventory/vendors/']").first();
|
||||
const vendorLink = page.locator("a[href*='/catalogue/vendors/']").first();
|
||||
if (await vendorLink.isVisible()) {
|
||||
await vendorLink.click();
|
||||
await expect(page).toHaveURL(/\/inventory\/vendors\/.+/);
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* User stories covered: Feature 12 — Cheapest & Closest tags
|
||||
* - TECHNICAL user on /inventory/items sees Cheapest or Closest tags on item rows
|
||||
* - TECHNICAL user on /catalogue/items sees Cheapest or Closest tags on item rows
|
||||
* when a site is selected (tags are independent of sort order)
|
||||
*
|
||||
* Feature 13 — Auto-sort by distance when site selected
|
||||
|
|
@ -17,11 +17,11 @@ import { test, expect } from "@playwright/test";
|
|||
import { login, USERS } from "../helpers/login";
|
||||
|
||||
test.describe("Feature 12 — Cheapest & Closest item tags", () => {
|
||||
test("US-12a: /inventory/items page loads for TECHNICAL user", async ({
|
||||
test("US-12a: /catalogue/items page loads for TECHNICAL user", async ({
|
||||
page,
|
||||
}) => {
|
||||
await login(page, USERS.TECH);
|
||||
await page.goto("/inventory/items");
|
||||
await page.goto("/catalogue/items");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Page should show some items (table rows or empty state)
|
||||
|
|
@ -41,7 +41,7 @@ test.describe("Feature 12 — Cheapest & Closest item tags", () => {
|
|||
page,
|
||||
}) => {
|
||||
await login(page, USERS.TECH);
|
||||
await page.goto("/inventory/items");
|
||||
await page.goto("/catalogue/items");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Select a site to enable distance computation
|
||||
|
|
@ -54,7 +54,7 @@ test.describe("Feature 12 — Cheapest & Closest item tags", () => {
|
|||
}
|
||||
|
||||
// Navigate to items with site selected (wait for URL param)
|
||||
const navPromise = page.waitForURL("**/inventory/items?siteId=**", {
|
||||
const navPromise = page.waitForURL("**/catalogue/items?siteId=**", {
|
||||
timeout: 10_000,
|
||||
});
|
||||
await siteSelect.selectOption({ index: 1 });
|
||||
|
|
@ -106,7 +106,7 @@ test.describe("Feature 12 — Cheapest & Closest item tags", () => {
|
|||
page,
|
||||
}) => {
|
||||
await login(page, USERS.TECH);
|
||||
await page.goto("/inventory/items");
|
||||
await page.goto("/catalogue/items");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const siteSelect = page.locator("select").first();
|
||||
|
|
@ -116,7 +116,7 @@ test.describe("Feature 12 — Cheapest & Closest item tags", () => {
|
|||
return;
|
||||
}
|
||||
|
||||
const navPromise = page.waitForURL("**/inventory/items?siteId=**", {
|
||||
const navPromise = page.waitForURL("**/catalogue/items?siteId=**", {
|
||||
timeout: 10_000,
|
||||
});
|
||||
await siteSelect.selectOption({ index: 1 });
|
||||
|
|
@ -148,7 +148,7 @@ test.describe("Feature 13 — Auto-sort by distance when site selected", () => {
|
|||
page,
|
||||
}) => {
|
||||
await login(page, USERS.TECH);
|
||||
await page.goto("/inventory/items");
|
||||
await page.goto("/catalogue/items");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const siteSelect = page.locator("select").first();
|
||||
|
|
@ -158,7 +158,7 @@ test.describe("Feature 13 — Auto-sort by distance when site selected", () => {
|
|||
return;
|
||||
}
|
||||
|
||||
const navPromise = page.waitForURL("**/inventory/items?siteId=**", {
|
||||
const navPromise = page.waitForURL("**/catalogue/items?siteId=**", {
|
||||
timeout: 10_000,
|
||||
});
|
||||
await siteSelect.selectOption({ index: 1 });
|
||||
|
|
@ -176,7 +176,7 @@ test.describe("Feature 13 — Auto-sort by distance when site selected", () => {
|
|||
page,
|
||||
}) => {
|
||||
await login(page, USERS.TECH);
|
||||
await page.goto("/inventory/items");
|
||||
await page.goto("/catalogue/items");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Expand a row to reveal sort toggle
|
||||
|
|
@ -196,7 +196,7 @@ test.describe("Feature 13 — Auto-sort by distance when site selected", () => {
|
|||
}
|
||||
|
||||
// Select a site — row stays expanded (preserved React state through soft nav)
|
||||
const navPromise = page.waitForURL("**/inventory/items?siteId=**", {
|
||||
const navPromise = page.waitForURL("**/catalogue/items?siteId=**", {
|
||||
timeout: 10_000,
|
||||
});
|
||||
await siteSelect.selectOption({ index: 1 });
|
||||
|
|
@ -223,7 +223,7 @@ test.describe("Feature 13 — Auto-sort by distance when site selected", () => {
|
|||
page,
|
||||
}) => {
|
||||
await login(page, USERS.TECH);
|
||||
await page.goto("/inventory/items");
|
||||
await page.goto("/catalogue/items");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const siteSelect = page.locator("select").first();
|
||||
|
|
@ -234,7 +234,7 @@ test.describe("Feature 13 — Auto-sort by distance when site selected", () => {
|
|||
}
|
||||
|
||||
// Select a site
|
||||
const nav1 = page.waitForURL("**/inventory/items?siteId=**", {
|
||||
const nav1 = page.waitForURL("**/catalogue/items?siteId=**", {
|
||||
timeout: 10_000,
|
||||
});
|
||||
await siteSelect.selectOption({ index: 1 });
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
* Integration tests for manager approval server actions.
|
||||
* Covers: M-02 (approve / approve+note), M-03 (reject), M-04 (request edits, vendor ID), S-06 (provide vendor ID), S-07 (resubmit after edits).
|
||||
*/
|
||||
import { vi, describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
|
||||
import { vi, describe, it, expect, beforeAll, beforeEach, afterEach, afterAll } from "vitest";
|
||||
|
||||
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
|
|
@ -47,6 +47,12 @@ afterEach(async () => {
|
|||
await deletePosByTitle(PREFIX);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Products auto-created by the catalogue-on-approval test.
|
||||
await db.productVendorPrice.deleteMany({ where: { product: { name: { startsWith: PREFIX } } } });
|
||||
await db.product.deleteMany({ where: { name: { startsWith: PREFIX } } });
|
||||
});
|
||||
|
||||
// Helper: create a PO in MGR_REVIEW state
|
||||
async function createSubmittedPo(title: string): Promise<string> {
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
|
|
@ -159,6 +165,40 @@ describe("issue #92 — advance payment on approval", () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ── Product catalogue registered on approval (so items are reusable) ─────────
|
||||
|
||||
describe("product catalogue on approval", () => {
|
||||
it("creates a catalogue product for a free-text line item and links it", async () => {
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
const itemName = `${PREFIX}Starter VPS hosting`;
|
||||
const form = makePoForm({
|
||||
title: `${PREFIX}CatApprove`,
|
||||
vesselId,
|
||||
accountId,
|
||||
intent: "submit",
|
||||
lineItems: [{ description: itemName, quantity: 1, unit: "pc", unitPrice: 459.95 }],
|
||||
});
|
||||
const { id: poId } = (await createPo(form)) as { id: string };
|
||||
await db.purchaseOrder.update({ where: { id: poId }, data: { vendorId } });
|
||||
|
||||
// No catalogue product exists for this name before approval.
|
||||
expect(await db.product.findFirst({ where: { name: { equals: itemName, mode: "insensitive" } } })).toBeNull();
|
||||
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
expect(await approvePo({ poId })).toEqual({ ok: true });
|
||||
|
||||
const product = await db.product.findFirst({ where: { name: { equals: itemName, mode: "insensitive" } } });
|
||||
expect(product).not.toBeNull();
|
||||
expect(Number(product!.lastPrice)).toBe(459.95);
|
||||
|
||||
// The line item is linked back to the new product, and a per-vendor price is recorded.
|
||||
const li = await db.pOLineItem.findFirstOrThrow({ where: { poId } });
|
||||
expect(li.productId).toBe(product!.id);
|
||||
const pvp = await db.productVendorPrice.findFirst({ where: { productId: product!.id, vendorId } });
|
||||
expect(pvp).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── M-03: Reject ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe("M-03 — reject PO", () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { VendorsTable } from "@/app/(portal)/inventory/vendors/vendors-table";
|
||||
import { VendorsTable } from "@/app/(portal)/catalogue/vendors/vendors-table";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue