From e9ed0f8eb0ed3b75ac7cfa1e4b8e104fe8adecc3 Mon Sep 17 00:00:00 2001 From: Hardik Date: Fri, 15 May 2026 00:30:23 +0530 Subject: [PATCH] feat(po): multi-account support per line item MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema - POLineItem.accountId String? — optional per-row account override - Account.lineItems back-relation added - Migration: 20260514185519 Line Items Editor - New props: multiAccount, accounts, defaultAccountId - When multiAccount=true: Account column appears in the editable table, each row pre-selected with defaultAccountId, independently changeable - Read-only view: Account column shown when any line item carries an accountId (displays account code) New & Edit PO forms - "Multiple accounts" checkbox (default off) sits inline with the Account label - When checked: label changes to "Default Account / Cost Centre"; per-row dropdowns appear in the line items table pre-filled with the chosen default account - Edit form: checkbox initialises to checked when the PO already has per-line accounts; existing accountId values are pre-populated - accountId per line item is included in form data and persisted Co-Authored-By: Claude Sonnet 4.6 --- .../app/(portal)/po/[id]/edit/actions.ts | 2 + .../(portal)/po/[id]/edit/edit-po-form.tsx | 39 ++++++++-- .../app/(portal)/po/new/actions.ts | 3 + .../app/(portal)/po/new/new-po-form.tsx | 36 ++++++++-- .../components/po/po-line-items-editor.tsx | 71 +++++++++++++++---- App/pelagia-portal/lib/validations/po.ts | 1 + .../migrations/20260514185519_/migration.sql | 5 ++ App/pelagia-portal/prisma/schema.prisma | 3 + 8 files changed, 136 insertions(+), 24 deletions(-) create mode 100644 App/pelagia-portal/prisma/migrations/20260514185519_/migration.sql diff --git a/App/pelagia-portal/app/(portal)/po/[id]/edit/actions.ts b/App/pelagia-portal/app/(portal)/po/[id]/edit/actions.ts index 4997fa1..ac7dd8f 100644 --- a/App/pelagia-portal/app/(portal)/po/[id]/edit/actions.ts +++ b/App/pelagia-portal/app/(portal)/po/[id]/edit/actions.ts @@ -18,6 +18,7 @@ function parseLineItems(formData: FormData) { size: (formData.get(`lineItems[${i}].size`) as string) || undefined, unitPrice: Number(formData.get(`lineItems[${i}].unitPrice`)), gstRate: Number(formData.get(`lineItems[${i}].gstRate`) ?? 0.18), + accountId: (formData.get(`lineItems[${i}].accountId`) as string) || undefined, }); i++; } @@ -110,6 +111,7 @@ export async function updatePo( totalPrice: item.quantity * item.unitPrice, gstRate: item.gstRate, sortOrder: idx, + accountId: (item as { accountId?: string }).accountId ?? null, })), }, actions: { diff --git a/App/pelagia-portal/app/(portal)/po/[id]/edit/edit-po-form.tsx b/App/pelagia-portal/app/(portal)/po/[id]/edit/edit-po-form.tsx index 4f55793..ac38071 100644 --- a/App/pelagia-portal/app/(portal)/po/[id]/edit/edit-po-form.tsx +++ b/App/pelagia-portal/app/(portal)/po/[id]/edit/edit-po-form.tsx @@ -24,6 +24,7 @@ type SerializedLineItem = { gstRate: number; sortOrder: number; productId: string | null; + accountId: string | null; }; type PoWithItems = Omit & { @@ -49,10 +50,14 @@ export function EditPoForm({ po, vessels, accounts, vendors }: Props) { size: li.size ?? undefined, unitPrice: li.unitPrice, gstRate: li.gstRate, + accountId: li.accountId ?? undefined, })) ); const [submitting, setSubmitting] = useState<"save" | "resubmit" | null>(null); const [error, setError] = useState(""); + const hasPerLineAccounts = po.lineItems.some((li) => li.accountId); + const [multiAccount, setMultiAccount] = useState(hasPerLineAccounts); + const [defaultAccountId, setDefaultAccountId] = useState(po.accountId ?? ""); const canResubmit = po.status === "EDITS_REQUESTED"; @@ -70,6 +75,7 @@ export function EditPoForm({ po, vessels, accounts, vendors }: Props) { data.set(`lineItems[${i}].size`, item.size ?? ""); data.set(`lineItems[${i}].unitPrice`, String(item.unitPrice)); data.set(`lineItems[${i}].gstRate`, String(item.gstRate ?? 0.18)); + if (multiAccount && item.accountId) data.set(`lineItems[${i}].accountId`, item.accountId); }); const result = await updatePo(po.id, data); @@ -124,10 +130,27 @@ export function EditPoForm({ po, vessels, accounts, vendors }: Props) {
- - setMultiAccount(e.target.checked)} + className="rounded border-neutral-300" + /> + Multiple accounts + +
+ @@ -185,7 +208,13 @@ export function EditPoForm({ po, vessels, accounts, vendors }: Props) { {/* Line Items */}

Line Items

- + ({ id: a.id, name: a.name, code: a.code }))} + defaultAccountId={defaultAccountId || undefined} + />
{/* Vendor */} diff --git a/App/pelagia-portal/app/(portal)/po/new/actions.ts b/App/pelagia-portal/app/(portal)/po/new/actions.ts index e7ad4dd..0bbd801 100644 --- a/App/pelagia-portal/app/(portal)/po/new/actions.ts +++ b/App/pelagia-portal/app/(portal)/po/new/actions.ts @@ -31,6 +31,7 @@ export async function createPo( unitPrice: number; gstRate: number; productId?: string; + accountId?: string; }> = []; let i = 0; while (formData.has(`lineItems[${i}].name`)) { @@ -43,6 +44,7 @@ export async function createPo( unitPrice: Number(formData.get(`lineItems[${i}].unitPrice`)), gstRate: Number(formData.get(`lineItems[${i}].gstRate`) ?? 0.18), productId: (formData.get(`lineItems[${i}].productId`) as string) || undefined, + accountId: (formData.get(`lineItems[${i}].accountId`) as string) || undefined, }); i++; } @@ -116,6 +118,7 @@ export async function createPo( gstRate: item.gstRate, sortOrder: idx, productId: item.productId ?? null, + accountId: item.accountId ?? null, })), }, actions: { diff --git a/App/pelagia-portal/app/(portal)/po/new/new-po-form.tsx b/App/pelagia-portal/app/(portal)/po/new/new-po-form.tsx index 6659b91..f2b0341 100644 --- a/App/pelagia-portal/app/(portal)/po/new/new-po-form.tsx +++ b/App/pelagia-portal/app/(portal)/po/new/new-po-form.tsx @@ -27,6 +27,8 @@ export function NewPoForm({ vessels, accounts, vendors }: Props) { const [files, setFiles] = useState([]); const [submitting, setSubmitting] = useState<"draft" | "submit" | null>(null); const [error, setError] = useState(""); + const [multiAccount, setMultiAccount] = useState(false); + const [defaultAccountId, setDefaultAccountId] = useState(""); async function handleSubmit(intent: "draft" | "submit") { setSubmitting(intent); @@ -43,6 +45,7 @@ export function NewPoForm({ vessels, accounts, vendors }: Props) { data.set(`lineItems[${i}].unitPrice`, String(item.unitPrice)); data.set(`lineItems[${i}].gstRate`, String(item.gstRate ?? 0.18)); if (item.productId) data.set(`lineItems[${i}].productId`, item.productId); + if (multiAccount && item.accountId) data.set(`lineItems[${i}].accountId`, item.accountId); }); const result = await createPo(data); @@ -84,10 +87,27 @@ export function NewPoForm({ vessels, accounts, vendors }: Props) {
- - setMultiAccount(e.target.checked)} + className="rounded border-neutral-300" + /> + Multiple accounts + +
+ @@ -150,7 +170,13 @@ export function NewPoForm({ vessels, accounts, vendors }: Props) { {/* Line Items */}

Line Items

- + ({ id: a.id, name: a.name, code: a.code }))} + defaultAccountId={defaultAccountId || undefined} + />
{/* Vendor */} diff --git a/App/pelagia-portal/components/po/po-line-items-editor.tsx b/App/pelagia-portal/components/po/po-line-items-editor.tsx index 1dfd8fb..a33f36f 100644 --- a/App/pelagia-portal/components/po/po-line-items-editor.tsx +++ b/App/pelagia-portal/components/po/po-line-items-editor.tsx @@ -33,11 +33,18 @@ type ProductHit = { vendorPrices: { vendorId: string; vendorName: string; price: number }[]; }; +export type AccountOption = { id: string; name: string; code: string }; + interface Props { items: LineItemInput[]; onChange?: (items: LineItemInput[]) => void; readOnly?: boolean; originalItems?: LineItemInput[]; + /** When true, show per-row account selector */ + multiAccount?: boolean; + accounts?: AccountOption[]; + /** The PO-level default account id — pre-selected in each row dropdown */ + defaultAccountId?: string; } type EditRow = { @@ -49,6 +56,7 @@ type EditRow = { unitPrice: string; gstRate: string; productId?: string; + accountId?: string; }; function toEditRow(item: LineItemInput): EditRow { @@ -61,6 +69,7 @@ function toEditRow(item: LineItemInput): EditRow { unitPrice: item.unitPrice ? String(item.unitPrice) : "", gstRate: String(item.gstRate ?? 0.18), productId: item.productId, + accountId: item.accountId, }; } @@ -74,6 +83,7 @@ function toLineItem(row: EditRow): LineItemInput { unitPrice: parseFloat(row.unitPrice) || 0, gstRate: parseFloat(row.gstRate) || 0.18, productId: row.productId || undefined, + accountId: row.accountId || undefined, }; } @@ -187,7 +197,15 @@ function NameCell({ ); } -export function LineItemsEditor({ items, onChange, readOnly = false, originalItems }: Props) { +export function LineItemsEditor({ + items, + onChange, + readOnly = false, + originalItems, + multiAccount = false, + accounts = [], + defaultAccountId, +}: Props) { const [rows, setRows] = useState(() => items.map(toEditRow)); function updateRows(next: EditRow[]) { @@ -215,16 +233,23 @@ export function LineItemsEditor({ items, onChange, readOnly = false, originalIte } function add() { - updateRows([...rows, { name: "", description: "", quantity: "1", unit: "pc", size: "", unitPrice: "", gstRate: "0.18" }]); + updateRows([...rows, { + name: "", description: "", quantity: "1", unit: "pc", size: "", unitPrice: "", gstRate: "0.18", + accountId: defaultAccountId, + }]); } function remove(index: number) { updateRows(rows.filter((_, i) => i !== index)); } + const accountMap = Object.fromEntries(accounts.map((a) => [a.id, a])); + + // ── Read-only view ─────────────────────────────────────────────────────────── if (readOnly) { const hasSize = items.some((item) => item.size); const hasDiff = originalItems && originalItems.length > 0; + const showAccount = items.some((item) => item.accountId); const { taxable, gst, grand } = calcTotals(items); return ( @@ -245,6 +270,7 @@ export function LineItemsEditor({ items, onChange, readOnly = false, originalIte Taxable GST% Total + {showAccount && Account} @@ -255,6 +281,7 @@ export function LineItemsEditor({ items, onChange, readOnly = false, originalIte const nameChanged = orig && orig.name !== item.name; const taxableAmt = item.quantity * item.unitPrice; const gstAmt = taxableAmt * (item.gstRate ?? 0.18); + const acct = item.accountId ? accountMap[item.accountId] : null; return ( @@ -277,21 +304,26 @@ export function LineItemsEditor({ items, onChange, readOnly = false, originalIte {formatCurrency(taxableAmt)} {Math.round((item.gstRate ?? 0.18) * 100)}% {formatCurrency(taxableAmt + gstAmt)} + {showAccount && ( + + {acct ? acct.code : "—"} + + )} ); })} - Taxable subtotal + Taxable subtotal {formatCurrency(taxable)} - GST + GST {formatCurrency(gst)} - Grand Total + Grand Total {formatCurrency(grand)} @@ -300,6 +332,7 @@ export function LineItemsEditor({ items, onChange, readOnly = false, originalIte ); } + // ── Edit view ──────────────────────────────────────────────────────────────── const liveItems = rows.map(toLineItem); const { taxable, gst, grand } = calcTotals(liveItems); @@ -315,6 +348,7 @@ export function LineItemsEditor({ items, onChange, readOnly = false, originalIte Size Unit Price GST% + {multiAccount && Account} Total @@ -335,9 +369,7 @@ export function LineItemsEditor({ items, onChange, readOnly = false, originalIte update(i, "quantity", e.target.value)} className="w-20 rounded border border-neutral-200 px-2 py-1.5 text-sm text-right focus:border-primary-500 focus:outline-none" @@ -364,9 +396,7 @@ export function LineItemsEditor({ items, onChange, readOnly = false, originalIte update(i, "unitPrice", e.target.value)} placeholder="0.00" @@ -386,6 +416,19 @@ export function LineItemsEditor({ items, onChange, readOnly = false, originalIte + {multiAccount && ( + + + + )} {formatCurrency(taxableAmt * (1 + gstR))} @@ -406,15 +449,15 @@ export function LineItemsEditor({ items, onChange, readOnly = false, originalIte - Taxable subtotal + Taxable subtotal {formatCurrency(taxable)} - GST + GST {formatCurrency(gst)} - Grand Total + Grand Total {formatCurrency(grand)} diff --git a/App/pelagia-portal/lib/validations/po.ts b/App/pelagia-portal/lib/validations/po.ts index 2d5f68a..2f24fb6 100644 --- a/App/pelagia-portal/lib/validations/po.ts +++ b/App/pelagia-portal/lib/validations/po.ts @@ -9,6 +9,7 @@ export const lineItemSchema = z.object({ unitPrice: z.coerce.number().nonnegative("Unit price must be non-negative"), gstRate: z.coerce.number().min(0).max(1).default(0.18), productId: z.string().optional(), + accountId: z.string().optional(), }); export const TC_FIXED_LINE = diff --git a/App/pelagia-portal/prisma/migrations/20260514185519_/migration.sql b/App/pelagia-portal/prisma/migrations/20260514185519_/migration.sql new file mode 100644 index 0000000..88e606c --- /dev/null +++ b/App/pelagia-portal/prisma/migrations/20260514185519_/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "POLineItem" ADD COLUMN "accountId" TEXT; + +-- AddForeignKey +ALTER TABLE "POLineItem" ADD CONSTRAINT "POLineItem_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/App/pelagia-portal/prisma/schema.prisma b/App/pelagia-portal/prisma/schema.prisma index 7a66683..7b8b579 100644 --- a/App/pelagia-portal/prisma/schema.prisma +++ b/App/pelagia-portal/prisma/schema.prisma @@ -101,6 +101,7 @@ model Account { isActive Boolean @default(true) purchaseOrders PurchaseOrder[] + lineItems POLineItem[] } model Vendor { @@ -244,6 +245,8 @@ model POLineItem { size String? productId String? product Product? @relation(fields: [productId], references: [id]) + accountId String? + account Account? @relation(fields: [accountId], references: [id]) poId String po PurchaseOrder @relation(fields: [poId], references: [id], onDelete: Cascade)