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() {
|
function createPO() {
|
||||||
// Encode cart into query params and navigate to new PO with prefill
|
|
||||||
const encoded = encodeURIComponent(JSON.stringify(items));
|
const encoded = encodeURIComponent(JSON.stringify(items));
|
||||||
|
clearCart();
|
||||||
|
setItems([]);
|
||||||
router.push(`/po/new?cart=${encoded}`);
|
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-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>
|
<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">
|
<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="/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="/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/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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -107,7 +108,7 @@ export function CartView() {
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<button onClick={() => { clearCart(); setItems([]); }} className="text-sm text-danger-600 hover:underline">Clear cart</button>
|
<button onClick={() => { clearCart(); setItems([]); }} className="text-sm text-danger-600 hover:underline">Clear cart</button>
|
||||||
<div className="flex gap-3">
|
<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
|
+ Add more items
|
||||||
</Link>
|
</Link>
|
||||||
<button onClick={createPO} className="rounded-lg bg-primary-600 px-5 py-2 text-sm font-semibold text-white hover:bg-primary-700">
|
<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 =
|
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";
|
"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 {
|
interface Props {
|
||||||
vessels: Vessel[];
|
vessels: Vessel[];
|
||||||
accounts: Account[];
|
accounts: Account[];
|
||||||
vendors: Vendor[];
|
vendors: Vendor[];
|
||||||
|
initialLineItems?: LineItemInput[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NewPoForm({ vessels, accounts, vendors }: Props) {
|
export function NewPoForm({ vessels, accounts, vendors, initialLineItems }: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [lineItems, setLineItems] = useState<LineItemInput[]>([
|
const [lineItems, setLineItems] = useState<LineItemInput[]>(
|
||||||
{ name: "", description: "", quantity: 1, unit: "pc", size: "", unitPrice: 0, gstRate: 0.18 },
|
initialLineItems && initialLineItems.length > 0 ? initialLineItems : [EMPTY_LINE]
|
||||||
]);
|
);
|
||||||
const [files, setFiles] = useState<File[]>([]);
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
const [submitting, setSubmitting] = useState<"draft" | "submit" | null>(null);
|
const [submitting, setSubmitting] = useState<"draft" | "submit" | null>(null);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,16 @@ import { hasPermission } from "@/lib/permissions";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { NewPoForm } from "./new-po-form";
|
import { NewPoForm } from "./new-po-form";
|
||||||
import type { Metadata } from "next";
|
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 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();
|
const session = await auth();
|
||||||
if (!session?.user) redirect("/login");
|
if (!session?.user) redirect("/login");
|
||||||
|
|
||||||
|
|
@ -15,6 +21,29 @@ export default async function NewPoPage() {
|
||||||
redirect("/dashboard");
|
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([
|
const [vessels, accounts, vendors] = await Promise.all([
|
||||||
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
||||||
db.account.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.
|
Fill in the details below. You can save as draft or submit directly for approval.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NewPoForm vessels={vessels} accounts={accounts} vendors={vendors} />
|
<NewPoForm vessels={vessels} accounts={accounts} vendors={vendors} initialLineItems={initialLineItems} />
|
||||||
</div>
|
</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 { signOut } from "next-auth/react";
|
||||||
import { LogOut } from "lucide-react";
|
import { LogOut } from "lucide-react";
|
||||||
import type { Role } from "@prisma/client";
|
import type { Role } from "@prisma/client";
|
||||||
|
import { CartIcon } from "./cart-icon";
|
||||||
|
|
||||||
const ROLE_LABELS: Record<Role, string> = {
|
const ROLE_LABELS: Record<Role, string> = {
|
||||||
TECHNICAL: "Technical",
|
TECHNICAL: "Technical",
|
||||||
|
|
@ -14,6 +15,8 @@ const ROLE_LABELS: Record<Role, string> = {
|
||||||
ADMIN: "Admin",
|
ADMIN: "Admin",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CART_ROLES: Role[] = ["TECHNICAL", "MANNING", "SUPERUSER", "MANAGER"];
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
user: { name: string; email: string; role: Role };
|
user: { name: string; email: string; role: Role };
|
||||||
}
|
}
|
||||||
|
|
@ -22,8 +25,9 @@ export function Header({ user }: HeaderProps) {
|
||||||
return (
|
return (
|
||||||
<header className="flex h-16 items-center justify-between border-b border-neutral-200 bg-white px-6">
|
<header className="flex h-16 items-center justify-between border-b border-neutral-200 bg-white px-6">
|
||||||
<div />
|
<div />
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-2">
|
||||||
<div className="text-right">
|
{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-sm font-medium text-neutral-900">{user.name}</p>
|
||||||
<p className="text-xs text-neutral-500">{ROLE_LABELS[user.role]}</p>
|
<p className="text-xs text-neutral-500">{ROLE_LABELS[user.role]}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue