Adds two PO-level charges shown below GST, per issue #133 ask 2. - Stored as ABSOLUTE rupee amounts on PurchaseOrder.tcsAmount / discountAmount (Decimal?, default 0; null/0 on historical & imported POs). Migration added. - Discount is applied post-GST. totalAmount folds the charges in (net payable = subtotal + GST + TCS − Discount), so payments / reports / advance all use the true amount due. lib/po-money.ts is the single source of truth. - Forms (create + edit) render a shared TcsDiscountFields with a % control bidirectionally linked to the rupee value (percentage is convenience only, taken against the GST-inclusive total; only the absolute amount is persisted). - createPo / updatePo store & compute; both manager-edit actions PRESERVE the PO's TCS/Discount when recomputing the total; import leaves them at 0. - PO detail shows TCS / Discount / Net payable below GST; PDF + XLSX export show the same breakdown and a corrected grand total. Tests: lib/po-money unit tests; po-tcs-discount integration test (create / edit / manager-line-edit preservation). Docs: CLAUDE.md GST section + wiki Purchase Orders (TCS/Discount + a full "what import sets vs. not" field-mapping table). Full unit (360) + integration (305) suites green; tsc clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
123 lines
3.6 KiB
TypeScript
123 lines
3.6 KiB
TypeScript
"use client";
|
|
|
|
import { formatCurrency } from "@/lib/utils";
|
|
import { amountToPercent, percentToAmount } from "@/lib/po-money";
|
|
|
|
/**
|
|
* PO-level TCS and Discount charges, shown below GST (issue #133).
|
|
*
|
|
* Each charge is entered as an absolute rupee amount **or** as a percentage of the
|
|
* GST-inclusive line-items total (`base`) — the two are bidirectionally linked.
|
|
* Only the absolute amount is stored (the parent persists `tcs` / `discount`); the
|
|
* percentage is a convenience. Discount is applied post-GST.
|
|
*/
|
|
interface Props {
|
|
base: number; // GST-inclusive line-items total — the % base
|
|
currency: string;
|
|
tcs: number;
|
|
discount: number;
|
|
onTcsChange: (amount: number) => void;
|
|
onDiscountChange: (amount: number) => void;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
const INPUT =
|
|
"w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
|
|
|
|
function round2(n: number): number {
|
|
return Math.round((n + Number.EPSILON) * 100) / 100;
|
|
}
|
|
|
|
function ChargeRow({
|
|
label,
|
|
hint,
|
|
base,
|
|
value,
|
|
onChange,
|
|
disabled,
|
|
}: {
|
|
label: string;
|
|
hint: string;
|
|
base: number;
|
|
value: number;
|
|
onChange: (amount: number) => void;
|
|
disabled?: boolean;
|
|
}) {
|
|
const percent = round2(amountToPercent(value, base));
|
|
|
|
return (
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[1fr_auto_auto] sm:items-end">
|
|
<div>
|
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">{label}</label>
|
|
<p className="text-xs text-neutral-400">{hint}</p>
|
|
</div>
|
|
<div className="sm:w-40">
|
|
<label className="block text-xs text-neutral-500 mb-1">Amount (₹)</label>
|
|
<input
|
|
type="number"
|
|
min={0}
|
|
step="0.01"
|
|
value={value === 0 ? "" : value}
|
|
placeholder="0.00"
|
|
disabled={disabled}
|
|
onChange={(e) => onChange(Math.max(0, Number(e.target.value) || 0))}
|
|
className={INPUT}
|
|
/>
|
|
</div>
|
|
<div className="sm:w-32">
|
|
<label className="block text-xs text-neutral-500 mb-1">% of total</label>
|
|
<input
|
|
type="number"
|
|
min={0}
|
|
step="0.01"
|
|
value={percent === 0 ? "" : percent}
|
|
placeholder="0"
|
|
disabled={disabled || base === 0}
|
|
onChange={(e) => onChange(round2(percentToAmount(Number(e.target.value) || 0, base)))}
|
|
className={INPUT}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function TcsDiscountFields({
|
|
base,
|
|
currency,
|
|
tcs,
|
|
discount,
|
|
onTcsChange,
|
|
onDiscountChange,
|
|
disabled,
|
|
}: Props) {
|
|
const netPayable = base + (tcs || 0) - (discount || 0);
|
|
|
|
return (
|
|
<div className="space-y-5">
|
|
<p className="text-xs text-neutral-400">
|
|
Optional charges applied after GST. Enter a rupee amount or a percentage of the
|
|
GST-inclusive total — they stay in sync. Stored as an absolute amount.
|
|
</p>
|
|
<ChargeRow
|
|
label="TCS"
|
|
hint="Tax Collected at Source, added to the total."
|
|
base={base}
|
|
value={tcs}
|
|
onChange={onTcsChange}
|
|
disabled={disabled}
|
|
/>
|
|
<ChargeRow
|
|
label="Discount"
|
|
hint="Applied post-GST, subtracted from the total."
|
|
base={base}
|
|
value={discount}
|
|
onChange={onDiscountChange}
|
|
disabled={disabled}
|
|
/>
|
|
<div className="flex items-center justify-between border-t border-neutral-100 pt-3 text-sm">
|
|
<span className="font-medium text-neutral-600">Net payable</span>
|
|
<span className="font-semibold text-neutral-900">{formatCurrency(netPayable, currency)}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|