refactor(routes): move /inventory/{items,vendors} → /catalogue/{items,vendors}
All checks were successful
PR checks / checks (pull_request) Successful in 44s
PR checks / integration (pull_request) Successful in 31s

Renames the product-catalogue pages (items + vendors, incl. their [id] detail
pages) out of /inventory into /catalogue. /inventory/cart is unchanged. All
internal links, redirects, revalidatePath calls, sidebar nav, and tests are
updated; next.config redirects keep old /inventory/{items,vendors}[/...] URLs
working (permanent) so existing bookmarks don't 404.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hardik 2026-06-24 05:04:29 +05:30
parent 70f3230c36
commit d7b455ab7d
22 changed files with 56 additions and 48 deletions

View file

@ -134,7 +134,7 @@ Inventory (`ItemInventory`, keyed by `productId` + `siteId`) is **incremented at
### Product catalogue sync (`lib/product-catalog.ts`)
`syncProductCatalog(poId, lineItems, vendorId, actorId)` registers a PO's line items as reusable **`Product`s** (the `/inventory/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.)
`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

View file

@ -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";

View file

@ -67,7 +67,7 @@ function ProductActionsMenu({ product }: { product: ProductRow }) {
export function ProductsTable({
products,
canManage,
detailBase = "/inventory/items",
detailBase = "/catalogue/items",
}: {
products: ProductRow[];
canManage: boolean;

View file

@ -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 };
}

View file

@ -85,11 +85,11 @@ export async function approvePo({
revalidatePath(`/admin/sites/${siteId}`);
}
// Register the line items in the product catalogue (/inventory/items) on
// 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("/inventory/items");
revalidatePath("/catalogue/items");
const accounts = await db.user.findMany({ where: { role: "ACCOUNTS", isActive: true } });
await notify({

View file

@ -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>

View file

@ -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"
>

View file

@ -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 (

View file

@ -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>

View file

@ -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 && (

View file

@ -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">

View file

@ -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 } });

View file

@ -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");

View file

@ -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[] }]

View file

@ -2,7 +2,7 @@ import { db } from "@/lib/db";
/**
* Product catalogue sync registers a PO's line items as reusable `Product`s
* (the `/inventory/items` catalogue) and keeps last/per-vendor prices fresh:
* (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.

View file

@ -19,6 +19,14 @@ const nextConfig: NextConfig = {
: []),
],
},
// The product catalogue moved from /inventory/{items,vendors} to
// /catalogue/{items,vendors}; keep old links (bookmarks, emails) working.
async redirects() {
return [
{ source: "/inventory/items/:path*", destination: "/catalogue/items/:path*", permanent: true },
{ source: "/inventory/vendors/:path*", destination: "/catalogue/vendors/:path*", permanent: true },
];
},
};
export default nextConfig;

View file

@ -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\/.+/);

View file

@ -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 });

View file

@ -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() }),