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:
parent
8750a459f5
commit
2fcf35235a
5 changed files with 82 additions and 12 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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("");
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
33
App/pelagia-portal/components/layout/cart-icon.tsx
Normal file
33
App/pelagia-portal/components/layout/cart-icon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue