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:
parent
9d1496a3bf
commit
e9ed0f8eb0
8 changed files with 136 additions and 24 deletions
|
|
@ -18,6 +18,7 @@ function parseLineItems(formData: FormData) {
|
||||||
size: (formData.get(`lineItems[${i}].size`) as string) || undefined,
|
size: (formData.get(`lineItems[${i}].size`) as string) || undefined,
|
||||||
unitPrice: Number(formData.get(`lineItems[${i}].unitPrice`)),
|
unitPrice: Number(formData.get(`lineItems[${i}].unitPrice`)),
|
||||||
gstRate: Number(formData.get(`lineItems[${i}].gstRate`) ?? 0.18),
|
gstRate: Number(formData.get(`lineItems[${i}].gstRate`) ?? 0.18),
|
||||||
|
accountId: (formData.get(`lineItems[${i}].accountId`) as string) || undefined,
|
||||||
});
|
});
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
|
|
@ -110,6 +111,7 @@ export async function updatePo(
|
||||||
totalPrice: item.quantity * item.unitPrice,
|
totalPrice: item.quantity * item.unitPrice,
|
||||||
gstRate: item.gstRate,
|
gstRate: item.gstRate,
|
||||||
sortOrder: idx,
|
sortOrder: idx,
|
||||||
|
accountId: (item as { accountId?: string }).accountId ?? null,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ type SerializedLineItem = {
|
||||||
gstRate: number;
|
gstRate: number;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
productId: string | null;
|
productId: string | null;
|
||||||
|
accountId: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PoWithItems = Omit<PurchaseOrder, "totalAmount"> & {
|
type PoWithItems = Omit<PurchaseOrder, "totalAmount"> & {
|
||||||
|
|
@ -49,10 +50,14 @@ export function EditPoForm({ po, vessels, accounts, vendors }: Props) {
|
||||||
size: li.size ?? undefined,
|
size: li.size ?? undefined,
|
||||||
unitPrice: li.unitPrice,
|
unitPrice: li.unitPrice,
|
||||||
gstRate: li.gstRate,
|
gstRate: li.gstRate,
|
||||||
|
accountId: li.accountId ?? undefined,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
const [submitting, setSubmitting] = useState<"save" | "resubmit" | null>(null);
|
const [submitting, setSubmitting] = useState<"save" | "resubmit" | null>(null);
|
||||||
const [error, setError] = useState("");
|
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";
|
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}].size`, item.size ?? "");
|
||||||
data.set(`lineItems[${i}].unitPrice`, String(item.unitPrice));
|
data.set(`lineItems[${i}].unitPrice`, String(item.unitPrice));
|
||||||
data.set(`lineItems[${i}].gstRate`, String(item.gstRate ?? 0.18));
|
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);
|
const result = await updatePo(po.id, data);
|
||||||
|
|
@ -124,10 +130,27 @@ export function EditPoForm({ po, vessels, accounts, vendors }: Props) {
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
Account / Cost Centre <span className="text-danger">*</span>
|
<label className="text-sm font-medium text-neutral-700">
|
||||||
|
{multiAccount ? "Default Account" : "Account / Cost Centre"} <span className="text-danger">*</span>
|
||||||
</label>
|
</label>
|
||||||
<select name="accountId" required defaultValue={po.accountId} className={INPUT_CLS}>
|
<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>
|
<option value="">Select account…</option>
|
||||||
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name} ({a.code})</option>)}
|
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name} ({a.code})</option>)}
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -185,7 +208,13 @@ export function EditPoForm({ po, vessels, accounts, vendors }: Props) {
|
||||||
{/* Line Items */}
|
{/* Line Items */}
|
||||||
<section className="rounded-lg border border-neutral-200 bg-white p-6">
|
<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>
|
<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>
|
</section>
|
||||||
|
|
||||||
{/* Vendor */}
|
{/* Vendor */}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ export async function createPo(
|
||||||
unitPrice: number;
|
unitPrice: number;
|
||||||
gstRate: number;
|
gstRate: number;
|
||||||
productId?: string;
|
productId?: string;
|
||||||
|
accountId?: string;
|
||||||
}> = [];
|
}> = [];
|
||||||
let i = 0;
|
let i = 0;
|
||||||
while (formData.has(`lineItems[${i}].name`)) {
|
while (formData.has(`lineItems[${i}].name`)) {
|
||||||
|
|
@ -43,6 +44,7 @@ export async function createPo(
|
||||||
unitPrice: Number(formData.get(`lineItems[${i}].unitPrice`)),
|
unitPrice: Number(formData.get(`lineItems[${i}].unitPrice`)),
|
||||||
gstRate: Number(formData.get(`lineItems[${i}].gstRate`) ?? 0.18),
|
gstRate: Number(formData.get(`lineItems[${i}].gstRate`) ?? 0.18),
|
||||||
productId: (formData.get(`lineItems[${i}].productId`) as string) || undefined,
|
productId: (formData.get(`lineItems[${i}].productId`) as string) || undefined,
|
||||||
|
accountId: (formData.get(`lineItems[${i}].accountId`) as string) || undefined,
|
||||||
});
|
});
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
|
|
@ -116,6 +118,7 @@ export async function createPo(
|
||||||
gstRate: item.gstRate,
|
gstRate: item.gstRate,
|
||||||
sortOrder: idx,
|
sortOrder: idx,
|
||||||
productId: item.productId ?? null,
|
productId: item.productId ?? null,
|
||||||
|
accountId: item.accountId ?? null,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ export function NewPoForm({ vessels, accounts, vendors }: Props) {
|
||||||
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("");
|
||||||
|
const [multiAccount, setMultiAccount] = useState(false);
|
||||||
|
const [defaultAccountId, setDefaultAccountId] = useState("");
|
||||||
|
|
||||||
async function handleSubmit(intent: "draft" | "submit") {
|
async function handleSubmit(intent: "draft" | "submit") {
|
||||||
setSubmitting(intent);
|
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}].unitPrice`, String(item.unitPrice));
|
||||||
data.set(`lineItems[${i}].gstRate`, String(item.gstRate ?? 0.18));
|
data.set(`lineItems[${i}].gstRate`, String(item.gstRate ?? 0.18));
|
||||||
if (item.productId) data.set(`lineItems[${i}].productId`, item.productId);
|
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);
|
const result = await createPo(data);
|
||||||
|
|
@ -84,10 +87,27 @@ export function NewPoForm({ vessels, accounts, vendors }: Props) {
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
Account / Cost Centre <span className="text-danger">*</span>
|
<label className="text-sm font-medium text-neutral-700">
|
||||||
|
{multiAccount ? "Default Account" : "Account / Cost Centre"} <span className="text-danger">*</span>
|
||||||
</label>
|
</label>
|
||||||
<select name="accountId" required className={INPUT_CLS}>
|
<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>
|
<option value="">Select account…</option>
|
||||||
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name} ({a.code})</option>)}
|
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name} ({a.code})</option>)}
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -150,7 +170,13 @@ export function NewPoForm({ vessels, accounts, vendors }: Props) {
|
||||||
{/* Line Items */}
|
{/* Line Items */}
|
||||||
<section className="rounded-lg border border-neutral-200 bg-white p-6">
|
<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>
|
<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>
|
</section>
|
||||||
|
|
||||||
{/* Vendor */}
|
{/* Vendor */}
|
||||||
|
|
|
||||||
|
|
@ -33,11 +33,18 @@ type ProductHit = {
|
||||||
vendorPrices: { vendorId: string; vendorName: string; price: number }[];
|
vendorPrices: { vendorId: string; vendorName: string; price: number }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AccountOption = { id: string; name: string; code: string };
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
items: LineItemInput[];
|
items: LineItemInput[];
|
||||||
onChange?: (items: LineItemInput[]) => void;
|
onChange?: (items: LineItemInput[]) => void;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
originalItems?: LineItemInput[];
|
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 = {
|
type EditRow = {
|
||||||
|
|
@ -49,6 +56,7 @@ type EditRow = {
|
||||||
unitPrice: string;
|
unitPrice: string;
|
||||||
gstRate: string;
|
gstRate: string;
|
||||||
productId?: string;
|
productId?: string;
|
||||||
|
accountId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function toEditRow(item: LineItemInput): EditRow {
|
function toEditRow(item: LineItemInput): EditRow {
|
||||||
|
|
@ -61,6 +69,7 @@ function toEditRow(item: LineItemInput): EditRow {
|
||||||
unitPrice: item.unitPrice ? String(item.unitPrice) : "",
|
unitPrice: item.unitPrice ? String(item.unitPrice) : "",
|
||||||
gstRate: String(item.gstRate ?? 0.18),
|
gstRate: String(item.gstRate ?? 0.18),
|
||||||
productId: item.productId,
|
productId: item.productId,
|
||||||
|
accountId: item.accountId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,6 +83,7 @@ function toLineItem(row: EditRow): LineItemInput {
|
||||||
unitPrice: parseFloat(row.unitPrice) || 0,
|
unitPrice: parseFloat(row.unitPrice) || 0,
|
||||||
gstRate: parseFloat(row.gstRate) || 0.18,
|
gstRate: parseFloat(row.gstRate) || 0.18,
|
||||||
productId: row.productId || undefined,
|
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));
|
const [rows, setRows] = useState<EditRow[]>(() => items.map(toEditRow));
|
||||||
|
|
||||||
function updateRows(next: EditRow[]) {
|
function updateRows(next: EditRow[]) {
|
||||||
|
|
@ -215,16 +233,23 @@ export function LineItemsEditor({ items, onChange, readOnly = false, originalIte
|
||||||
}
|
}
|
||||||
|
|
||||||
function add() {
|
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) {
|
function remove(index: number) {
|
||||||
updateRows(rows.filter((_, i) => i !== index));
|
updateRows(rows.filter((_, i) => i !== index));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const accountMap = Object.fromEntries(accounts.map((a) => [a.id, a]));
|
||||||
|
|
||||||
|
// ── Read-only view ───────────────────────────────────────────────────────────
|
||||||
if (readOnly) {
|
if (readOnly) {
|
||||||
const hasSize = items.some((item) => item.size);
|
const hasSize = items.some((item) => item.size);
|
||||||
const hasDiff = originalItems && originalItems.length > 0;
|
const hasDiff = originalItems && originalItems.length > 0;
|
||||||
|
const showAccount = items.some((item) => item.accountId);
|
||||||
const { taxable, gst, grand } = calcTotals(items);
|
const { taxable, gst, grand } = calcTotals(items);
|
||||||
|
|
||||||
return (
|
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">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">GST%</th>
|
||||||
<th className="pb-2 text-right font-medium text-neutral-600 pl-4 whitespace-nowrap">Total</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-neutral-100">
|
<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 nameChanged = orig && orig.name !== item.name;
|
||||||
const taxableAmt = item.quantity * item.unitPrice;
|
const taxableAmt = item.quantity * item.unitPrice;
|
||||||
const gstAmt = taxableAmt * (item.gstRate ?? 0.18);
|
const gstAmt = taxableAmt * (item.gstRate ?? 0.18);
|
||||||
|
const acct = item.accountId ? accountMap[item.accountId] : null;
|
||||||
return (
|
return (
|
||||||
<tr key={i}>
|
<tr key={i}>
|
||||||
<td className="py-2 pr-4">
|
<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">{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 text-neutral-500">{Math.round((item.gstRate ?? 0.18) * 100)}%</td>
|
||||||
<td className="py-2 pl-4 text-right">{formatCurrency(taxableAmt + gstAmt)}</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>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr className="border-t border-neutral-100">
|
<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>
|
<td className="pt-3 pl-4 text-right text-sm text-neutral-700" colSpan={3}>{formatCurrency(taxable)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<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>
|
<td className="py-0.5 pl-4 text-right text-sm text-neutral-700" colSpan={3}>{formatCurrency(gst)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr className="border-t border-neutral-200">
|
<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>
|
<td className="pt-2 pl-4 text-right text-sm font-semibold text-neutral-900" colSpan={3}>{formatCurrency(grand)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
|
|
@ -300,6 +332,7 @@ export function LineItemsEditor({ items, onChange, readOnly = false, originalIte
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Edit view ────────────────────────────────────────────────────────────────
|
||||||
const liveItems = rows.map(toLineItem);
|
const liveItems = rows.map(toLineItem);
|
||||||
const { taxable, gst, grand } = calcTotals(liveItems);
|
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-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">Unit Price</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">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 text-right font-medium text-neutral-600 pl-4 whitespace-nowrap">Total</th>
|
||||||
<th className="pb-2 pl-4 w-8" />
|
<th className="pb-2 pl-4 w-8" />
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -335,9 +369,7 @@ export function LineItemsEditor({ items, onChange, readOnly = false, originalIte
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 pl-4">
|
<td className="py-2 pl-4">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number" min="0" step="any"
|
||||||
min="0"
|
|
||||||
step="any"
|
|
||||||
value={row.quantity}
|
value={row.quantity}
|
||||||
onChange={(e) => update(i, "quantity", e.target.value)}
|
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"
|
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>
|
||||||
<td className="py-2 pl-4">
|
<td className="py-2 pl-4">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number" min="0" step="any"
|
||||||
min="0"
|
|
||||||
step="any"
|
|
||||||
value={row.unitPrice}
|
value={row.unitPrice}
|
||||||
onChange={(e) => update(i, "unitPrice", e.target.value)}
|
onChange={(e) => update(i, "unitPrice", e.target.value)}
|
||||||
placeholder="0.00"
|
placeholder="0.00"
|
||||||
|
|
@ -386,6 +416,19 @@ export function LineItemsEditor({ items, onChange, readOnly = false, originalIte
|
||||||
<option value="0.28">28%</option>
|
<option value="0.28">28%</option>
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</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">
|
<td className="py-2 pl-4 text-right text-sm">
|
||||||
{formatCurrency(taxableAmt * (1 + gstR))}
|
{formatCurrency(taxableAmt * (1 + gstR))}
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -406,15 +449,15 @@ export function LineItemsEditor({ items, onChange, readOnly = false, originalIte
|
||||||
</tbody>
|
</tbody>
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr className="border-t border-neutral-100">
|
<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>
|
<td className="pt-3 pl-4 text-right text-sm text-neutral-700" colSpan={2}>{formatCurrency(taxable)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<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>
|
<td className="py-0.5 pl-4 text-right text-sm text-neutral-700" colSpan={2}>{formatCurrency(gst)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr className="border-t border-neutral-200">
|
<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>
|
<td className="pt-2 pl-4 text-right text-sm font-semibold text-neutral-900" colSpan={2}>{formatCurrency(grand)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ export const lineItemSchema = z.object({
|
||||||
unitPrice: z.coerce.number().nonnegative("Unit price must be non-negative"),
|
unitPrice: z.coerce.number().nonnegative("Unit price must be non-negative"),
|
||||||
gstRate: z.coerce.number().min(0).max(1).default(0.18),
|
gstRate: z.coerce.number().min(0).max(1).default(0.18),
|
||||||
productId: z.string().optional(),
|
productId: z.string().optional(),
|
||||||
|
accountId: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const TC_FIXED_LINE =
|
export const TC_FIXED_LINE =
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -101,6 +101,7 @@ model Account {
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
|
|
||||||
purchaseOrders PurchaseOrder[]
|
purchaseOrders PurchaseOrder[]
|
||||||
|
lineItems POLineItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Vendor {
|
model Vendor {
|
||||||
|
|
@ -244,6 +245,8 @@ model POLineItem {
|
||||||
size String?
|
size String?
|
||||||
productId String?
|
productId String?
|
||||||
product Product? @relation(fields: [productId], references: [id])
|
product Product? @relation(fields: [productId], references: [id])
|
||||||
|
accountId String?
|
||||||
|
account Account? @relation(fields: [accountId], references: [id])
|
||||||
|
|
||||||
poId String
|
poId String
|
||||||
po PurchaseOrder @relation(fields: [poId], references: [id], onDelete: Cascade)
|
po PurchaseOrder @relation(fields: [poId], references: [id], onDelete: Cascade)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue