feat(cart): header cart icon with badge + fix PO pre-population from cart

Cart icon:
- CartIcon component in header: listens to cart-updated events, shows
  item count badge, navigates to /inventory/cart on click
- Visible for TECHNICAL, MANNING, SUPERUSER, MANAGER roles only

PO pre-population:
- /po/new page reads ?cart= searchParam, parses CartItem[] into
  LineItemInput[], passes as initialLineItems prop to NewPoForm
- NewPoForm accepts initialLineItems and seeds useState from them
  instead of always starting with one blank row
- Cart is cleared immediately when "Create Purchase Order" is clicked
  so re-visiting cart doesn't re-submit the same items

Cart fixes:
- Empty state and "Add more items" links corrected from /admin/products
  to /inventory/items and /inventory/vendors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hardik 2026-05-15 23:56:38 +05:30
parent 8750a459f5
commit 2fcf35235a
5 changed files with 82 additions and 12 deletions

View file

@ -31,8 +31,9 @@ export function CartView() {
}
function createPO() {
// Encode cart into query params and navigate to new PO with prefill
const encoded = encodeURIComponent(JSON.stringify(items));
clearCart();
setItems([]);
router.push(`/po/new?cart=${encoded}`);
}
@ -45,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="/admin/products" 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="/admin/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="/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>
</div>
</div>
);
@ -107,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="/admin/products" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">
<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">
+ 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

@ -13,17 +13,20 @@ import { TC_DEFAULTS, TC_FIXED_LINE, TC_FIXED_LINE_2 } from "@/lib/validations/p
const INPUT_CLS =
"w-full rounded-lg border border-neutral-300 px-3 py-2.5 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
const EMPTY_LINE: LineItemInput = { name: "", description: "", quantity: 1, unit: "pc", size: "", unitPrice: 0, gstRate: 0.18 };
interface Props {
vessels: Vessel[];
accounts: Account[];
vendors: Vendor[];
initialLineItems?: LineItemInput[];
}
export function NewPoForm({ vessels, accounts, vendors }: Props) {
export function NewPoForm({ vessels, accounts, vendors, initialLineItems }: Props) {
const router = useRouter();
const [lineItems, setLineItems] = useState<LineItemInput[]>([
{ name: "", description: "", quantity: 1, unit: "pc", size: "", unitPrice: 0, gstRate: 0.18 },
]);
const [lineItems, setLineItems] = useState<LineItemInput[]>(
initialLineItems && initialLineItems.length > 0 ? initialLineItems : [EMPTY_LINE]
);
const [files, setFiles] = useState<File[]>([]);
const [submitting, setSubmitting] = useState<"draft" | "submit" | null>(null);
const [error, setError] = useState("");

View file

@ -4,10 +4,16 @@ import { hasPermission } from "@/lib/permissions";
import { redirect } from "next/navigation";
import { NewPoForm } from "./new-po-form";
import type { Metadata } from "next";
import type { LineItemInput } from "@/lib/validations/po";
import type { CartItem } from "@/lib/cart";
export const metadata: Metadata = { title: "New Purchase Order" };
export default async function NewPoPage() {
interface Props {
searchParams: Promise<{ cart?: string }>;
}
export default async function NewPoPage({ searchParams }: Props) {
const session = await auth();
if (!session?.user) redirect("/login");
@ -15,6 +21,29 @@ export default async function NewPoPage() {
redirect("/dashboard");
}
const { cart } = await searchParams;
let initialLineItems: LineItemInput[] | undefined;
if (cart) {
try {
const cartItems: CartItem[] = JSON.parse(decodeURIComponent(cart));
if (Array.isArray(cartItems) && cartItems.length > 0) {
initialLineItems = cartItems.map((item) => ({
name: item.name,
description: item.description ?? "",
quantity: item.quantity,
unit: item.unit,
size: "",
unitPrice: item.unitPrice,
gstRate: 0.18,
productId: item.productId,
}));
}
} catch {
// malformed cart param — ignore and start empty
}
}
const [vessels, accounts, vendors] = await Promise.all([
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
db.account.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
@ -29,7 +58,7 @@ export default async function NewPoPage() {
Fill in the details below. You can save as draft or submit directly for approval.
</p>
</div>
<NewPoForm vessels={vessels} accounts={accounts} vendors={vendors} />
<NewPoForm vessels={vessels} accounts={accounts} vendors={vendors} initialLineItems={initialLineItems} />
</div>
);
}

View file

@ -0,0 +1,33 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { ShoppingCart } from "lucide-react";
import { getCart } from "@/lib/cart";
export function CartIcon() {
const router = useRouter();
const [count, setCount] = useState(0);
useEffect(() => {
const update = () => setCount(getCart().reduce((s, i) => s + i.quantity, 0));
update();
window.addEventListener("cart-updated", update);
return () => window.removeEventListener("cart-updated", update);
}, []);
return (
<button
onClick={() => router.push("/inventory/cart")}
title="Cart"
className="relative flex items-center justify-center rounded-lg p-2 text-neutral-500 hover:bg-neutral-100 hover:text-neutral-700 transition-colors"
>
<ShoppingCart className="h-4 w-4" />
{count > 0 && (
<span className="absolute -top-0.5 -right-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-primary-600 text-[10px] font-bold text-white leading-none">
{count > 99 ? "99+" : count}
</span>
)}
</button>
);
}

View file

@ -3,6 +3,7 @@
import { signOut } from "next-auth/react";
import { LogOut } from "lucide-react";
import type { Role } from "@prisma/client";
import { CartIcon } from "./cart-icon";
const ROLE_LABELS: Record<Role, string> = {
TECHNICAL: "Technical",
@ -14,6 +15,8 @@ const ROLE_LABELS: Record<Role, string> = {
ADMIN: "Admin",
};
const CART_ROLES: Role[] = ["TECHNICAL", "MANNING", "SUPERUSER", "MANAGER"];
interface HeaderProps {
user: { name: string; email: string; role: Role };
}
@ -22,8 +25,9 @@ export function Header({ user }: HeaderProps) {
return (
<header className="flex h-16 items-center justify-between border-b border-neutral-200 bg-white px-6">
<div />
<div className="flex items-center gap-4">
<div className="text-right">
<div className="flex items-center gap-2">
{CART_ROLES.includes(user.role) && <CartIcon />}
<div className="text-right ml-2">
<p className="text-sm font-medium text-neutral-900">{user.name}</p>
<p className="text-xs text-neutral-500">{ROLE_LABELS[user.role]}</p>
</div>