feat(po): multi-account support per line item

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 <noreply@anthropic.com>
This commit is contained in:
Hardik 2026-05-15 00:30:23 +05:30
parent 9d1496a3bf
commit e9ed0f8eb0
8 changed files with 136 additions and 24 deletions

View file

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

View file

@ -24,6 +24,7 @@ type SerializedLineItem = {
gstRate: number;
sortOrder: number;
productId: string | null;
accountId: string | null;
};
type PoWithItems = Omit<PurchaseOrder, "totalAmount"> & {
@ -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) {
</select>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
Account / Cost Centre <span className="text-danger">*</span>
</label>
<select name="accountId" required defaultValue={po.accountId} className={INPUT_CLS}>
<div className="flex items-center justify-between mb-1.5">
<label className="text-sm font-medium text-neutral-700">
{multiAccount ? "Default Account" : "Account / Cost Centre"} <span className="text-danger">*</span>
</label>
<label className="flex items-center gap-1.5 text-xs text-neutral-500 cursor-pointer select-none">
<input
type="checkbox"
checked={multiAccount}
onChange={(e) => setMultiAccount(e.target.checked)}
className="rounded border-neutral-300"
/>
Multiple accounts
</label>
</div>
<select
name="accountId"
required
value={defaultAccountId}
onChange={(e) => setDefaultAccountId(e.target.value)}
className={INPUT_CLS}
>
<option value="">Select account</option>
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name} ({a.code})</option>)}
</select>
@ -185,7 +208,13 @@ export function EditPoForm({ po, vessels, accounts, vendors }: Props) {
{/* Line Items */}
<section className="rounded-lg border border-neutral-200 bg-white p-6">
<h2 className="text-base font-semibold text-neutral-900 mb-4">Line Items</h2>
<LineItemsEditor items={lineItems} onChange={setLineItems} />
<LineItemsEditor
items={lineItems}
onChange={setLineItems}
multiAccount={multiAccount}
accounts={accounts.map((a) => ({ id: a.id, name: a.name, code: a.code }))}
defaultAccountId={defaultAccountId || undefined}
/>
</section>
{/* Vendor */}

View file

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

View file

@ -27,6 +27,8 @@ export function NewPoForm({ vessels, accounts, vendors }: Props) {
const [files, setFiles] = useState<File[]>([]);
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) {
</select>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
Account / Cost Centre <span className="text-danger">*</span>
</label>
<select name="accountId" required className={INPUT_CLS}>
<div className="flex items-center justify-between mb-1.5">
<label className="text-sm font-medium text-neutral-700">
{multiAccount ? "Default Account" : "Account / Cost Centre"} <span className="text-danger">*</span>
</label>
<label className="flex items-center gap-1.5 text-xs text-neutral-500 cursor-pointer select-none">
<input
type="checkbox"
checked={multiAccount}
onChange={(e) => setMultiAccount(e.target.checked)}
className="rounded border-neutral-300"
/>
Multiple accounts
</label>
</div>
<select
name="accountId"
required
value={defaultAccountId}
onChange={(e) => setDefaultAccountId(e.target.value)}
className={INPUT_CLS}
>
<option value="">Select account</option>
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name} ({a.code})</option>)}
</select>
@ -150,7 +170,13 @@ export function NewPoForm({ vessels, accounts, vendors }: Props) {
{/* Line Items */}
<section className="rounded-lg border border-neutral-200 bg-white p-6">
<h2 className="text-base font-semibold text-neutral-900 mb-4">Line Items</h2>
<LineItemsEditor items={lineItems} onChange={setLineItems} />
<LineItemsEditor
items={lineItems}
onChange={setLineItems}
multiAccount={multiAccount}
accounts={accounts.map((a) => ({ id: a.id, name: a.name, code: a.code }))}
defaultAccountId={defaultAccountId || undefined}
/>
</section>
{/* Vendor */}

View file

@ -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<EditRow[]>(() => 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
<th className="pb-2 text-right font-medium text-neutral-600 pl-4 whitespace-nowrap">Taxable</th>
<th className="pb-2 text-right font-medium text-neutral-600 pl-4 whitespace-nowrap">GST%</th>
<th className="pb-2 text-right font-medium text-neutral-600 pl-4 whitespace-nowrap">Total</th>
{showAccount && <th className="pb-2 text-left font-medium text-neutral-600 pl-4 whitespace-nowrap">Account</th>}
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
@ -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 (
<tr key={i}>
<td className="py-2 pr-4">
@ -277,21 +304,26 @@ export function LineItemsEditor({ items, onChange, readOnly = false, originalIte
<td className="py-2 pl-4 text-right">{formatCurrency(taxableAmt)}</td>
<td className="py-2 pl-4 text-right text-neutral-500">{Math.round((item.gstRate ?? 0.18) * 100)}%</td>
<td className="py-2 pl-4 text-right">{formatCurrency(taxableAmt + gstAmt)}</td>
{showAccount && (
<td className="py-2 pl-4 text-xs text-neutral-500 font-mono">
{acct ? acct.code : "—"}
</td>
)}
</tr>
);
})}
</tbody>
<tfoot>
<tr className="border-t border-neutral-100">
<td colSpan={hasSize ? 5 : 4} className="pt-3 text-right text-sm text-neutral-500">Taxable subtotal</td>
<td colSpan={hasSize ? (showAccount ? 6 : 5) : (showAccount ? 5 : 4)} className="pt-3 text-right text-sm text-neutral-500">Taxable subtotal</td>
<td className="pt-3 pl-4 text-right text-sm text-neutral-700" colSpan={3}>{formatCurrency(taxable)}</td>
</tr>
<tr>
<td colSpan={hasSize ? 5 : 4} className="py-0.5 text-right text-sm text-neutral-500">GST</td>
<td colSpan={hasSize ? (showAccount ? 6 : 5) : (showAccount ? 5 : 4)} className="py-0.5 text-right text-sm text-neutral-500">GST</td>
<td className="py-0.5 pl-4 text-right text-sm text-neutral-700" colSpan={3}>{formatCurrency(gst)}</td>
</tr>
<tr className="border-t border-neutral-200">
<td colSpan={hasSize ? 5 : 4} className="pt-2 text-right text-sm font-semibold text-neutral-900">Grand Total</td>
<td colSpan={hasSize ? (showAccount ? 6 : 5) : (showAccount ? 5 : 4)} className="pt-2 text-right text-sm font-semibold text-neutral-900">Grand Total</td>
<td className="pt-2 pl-4 text-right text-sm font-semibold text-neutral-900" colSpan={3}>{formatCurrency(grand)}</td>
</tr>
</tfoot>
@ -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
<th className="pb-2 text-left font-medium text-neutral-600 pl-3 whitespace-nowrap">Size</th>
<th className="pb-2 text-right font-medium text-neutral-600 pl-4 whitespace-nowrap">Unit Price</th>
<th className="pb-2 text-right font-medium text-neutral-600 pl-4 whitespace-nowrap">GST%</th>
{multiAccount && <th className="pb-2 text-left font-medium text-neutral-600 pl-4 whitespace-nowrap">Account</th>}
<th className="pb-2 text-right font-medium text-neutral-600 pl-4 whitespace-nowrap">Total</th>
<th className="pb-2 pl-4 w-8" />
</tr>
@ -335,9 +369,7 @@ export function LineItemsEditor({ items, onChange, readOnly = false, originalIte
</td>
<td className="py-2 pl-4">
<input
type="number"
min="0"
step="any"
type="number" min="0" step="any"
value={row.quantity}
onChange={(e) => 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
</td>
<td className="py-2 pl-4">
<input
type="number"
min="0"
step="any"
type="number" min="0" step="any"
value={row.unitPrice}
onChange={(e) => update(i, "unitPrice", e.target.value)}
placeholder="0.00"
@ -386,6 +416,19 @@ export function LineItemsEditor({ items, onChange, readOnly = false, originalIte
<option value="0.28">28%</option>
</select>
</td>
{multiAccount && (
<td className="py-2 pl-4">
<select
value={row.accountId ?? defaultAccountId ?? ""}
onChange={(e) => update(i, "accountId", e.target.value)}
className="w-36 rounded border border-neutral-200 px-2 py-1.5 text-sm focus:border-primary-500 focus:outline-none"
>
{accounts.map((a) => (
<option key={a.id} value={a.id}>{a.name} ({a.code})</option>
))}
</select>
</td>
)}
<td className="py-2 pl-4 text-right text-sm">
{formatCurrency(taxableAmt * (1 + gstR))}
</td>
@ -406,15 +449,15 @@ export function LineItemsEditor({ items, onChange, readOnly = false, originalIte
</tbody>
<tfoot>
<tr className="border-t border-neutral-100">
<td colSpan={6} className="pt-3 text-right text-sm text-neutral-500">Taxable subtotal</td>
<td colSpan={multiAccount ? 7 : 6} className="pt-3 text-right text-sm text-neutral-500">Taxable subtotal</td>
<td className="pt-3 pl-4 text-right text-sm text-neutral-700" colSpan={2}>{formatCurrency(taxable)}</td>
</tr>
<tr>
<td colSpan={6} className="py-0.5 text-right text-sm text-neutral-500">GST</td>
<td colSpan={multiAccount ? 7 : 6} className="py-0.5 text-right text-sm text-neutral-500">GST</td>
<td className="py-0.5 pl-4 text-right text-sm text-neutral-700" colSpan={2}>{formatCurrency(gst)}</td>
</tr>
<tr className="border-t border-neutral-200">
<td colSpan={6} className="pt-2 text-right text-sm font-semibold text-neutral-900">Grand Total</td>
<td colSpan={multiAccount ? 7 : 6} className="pt-2 text-right text-sm font-semibold text-neutral-900">Grand Total</td>
<td className="pt-2 pl-4 text-right text-sm font-semibold text-neutral-900" colSpan={2}>{formatCurrency(grand)}</td>
</tr>
</tfoot>

View file

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

View file

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

View file

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