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>
91 lines
2.7 KiB
TypeScript
91 lines
2.7 KiB
TypeScript
"use server";
|
|
|
|
import { auth } from "@/auth";
|
|
import { db } from "@/lib/db";
|
|
import { hasPermission } from "@/lib/permissions";
|
|
import { poNetPayable } from "@/lib/po-money";
|
|
import { revalidatePath } from "next/cache";
|
|
import { z } from "zod";
|
|
|
|
type ActionResult = { ok: true } | { error: string };
|
|
|
|
const lineItemSchema = z.object({
|
|
name: z.string().min(1),
|
|
description: z.string().optional(),
|
|
quantity: z.coerce.number().positive(),
|
|
unit: z.string().min(1),
|
|
size: z.string().optional(),
|
|
unitPrice: z.coerce.number().nonnegative(),
|
|
gstRate: z.coerce.number().min(0).max(1).default(0.18),
|
|
});
|
|
|
|
export async function managerEditLineItems({
|
|
poId,
|
|
lineItems,
|
|
}: {
|
|
poId: string;
|
|
lineItems: Array<{ name: string; description?: string; quantity: number; unit: string; size?: string; unitPrice: number }>;
|
|
}): Promise<ActionResult> {
|
|
const session = await auth();
|
|
if (!session?.user || !hasPermission(session.user.role, "approve_po")) {
|
|
return { error: "Forbidden" };
|
|
}
|
|
|
|
const parsed = z.array(lineItemSchema).min(1).safeParse(lineItems);
|
|
if (!parsed.success) return { error: parsed.error.errors[0].message };
|
|
|
|
const po = await db.purchaseOrder.findUnique({
|
|
where: { id: poId },
|
|
include: { lineItems: { orderBy: { sortOrder: "asc" } } },
|
|
});
|
|
if (!po) return { error: "PO not found" };
|
|
if (po.status !== "MGR_REVIEW") return { error: "Line items can only be edited while the PO is under review." };
|
|
|
|
const originalSnapshot = po.lineItems.map((li) => ({
|
|
name: (li as typeof li & { name: string }).name,
|
|
description: li.description ?? undefined,
|
|
quantity: Number(li.quantity),
|
|
unit: li.unit,
|
|
size: li.size ?? undefined,
|
|
unitPrice: Number(li.unitPrice),
|
|
}));
|
|
|
|
// Recompute the total from the edited line items, preserving the PO-level
|
|
// TCS / Discount charges (#133) so a manager line edit doesn't drop them.
|
|
const newTotal = poNetPayable(
|
|
parsed.data,
|
|
Number(po.tcsAmount ?? 0),
|
|
Number(po.discountAmount ?? 0)
|
|
);
|
|
|
|
await db.purchaseOrder.update({
|
|
where: { id: poId },
|
|
data: {
|
|
totalAmount: newTotal,
|
|
lineItems: {
|
|
deleteMany: {},
|
|
create: parsed.data.map((item, idx) => ({
|
|
name: item.name,
|
|
description: item.description ?? null,
|
|
quantity: item.quantity,
|
|
unit: item.unit,
|
|
size: item.size ?? null,
|
|
unitPrice: item.unitPrice,
|
|
totalPrice: item.quantity * item.unitPrice,
|
|
gstRate: item.gstRate,
|
|
sortOrder: idx,
|
|
})),
|
|
},
|
|
actions: {
|
|
create: {
|
|
actionType: "MANAGER_LINE_EDIT",
|
|
actorId: session.user.id,
|
|
metadata: { original: originalSnapshot },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
revalidatePath(`/approvals/${poId}`);
|
|
return { ok: true };
|
|
}
|