pelagia-portal/App/components/po/tcs-discount-fields.tsx
Hardik 78afcb610b
Some checks failed
PR checks / checks (pull_request) Failing after 35s
PR checks / integration (pull_request) Successful in 33s
feat(po): TCS & Discount below GST (#133)
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>
2026-06-29 14:50:34 +05:30

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>
);
}