All checks were successful
PR checks / checks (pull_request) Successful in 32s
Managers and superusers can cancel a PO from any state via a confirmation modal that requires typing "cancel" and a mandatory reason. A cancelled PO becomes a terminal CANCELLED state and drops out of every spend tracker/graph (those filter on POST_APPROVAL_STATUSES / explicit whitelists, none of which include CANCELLED). A cancelled PO may optionally be linked to the existing PO that supersedes it (by PO number); the replacement shows the reciprocal "supersedes" link. No vessel/account/vendor match is enforced and the link can be added any time. Cancelled POs remain visible (greyed in history) and exportable, with a diagonal "CANCELLED" watermark on both the PDF and XLSX exports. - schema: POStatus CANCELLED; cancelledAt/cancellationReason; self-referential supersededById relation; ActionType CANCELLED/SUPERSEDED (+ migration) - state machine canCancel(); cancel_po permission (MANAGER + SUPERUSER) - cancelPo / supersedePo server actions + PO_CANCELLED notification - cancel modal + supersede form; cancelled banner with reciprocal links - exhaustive CANCELLED entries in all status label/variant maps - diagonal CANCELLED watermark embedded for PDF (CSS) and XLSX (image) - integration tests (cancel from any state, reason/role guards, supersede) Inventory reversal on cancel is deferred to #55 (inventory is feature-flagged off). Closes #53 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
115 lines
3.4 KiB
TypeScript
115 lines
3.4 KiB
TypeScript
import { clsx, type ClassValue } from "clsx";
|
|
import { twMerge } from "tailwind-merge";
|
|
import type { POStatus } from "@prisma/client";
|
|
|
|
export function cn(...inputs: ClassValue[]) {
|
|
return twMerge(clsx(inputs));
|
|
}
|
|
|
|
export function formatCurrency(amount: number | string, currency = "INR"): string {
|
|
return new Intl.NumberFormat("en-IN", { style: "currency", currency }).format(
|
|
Number(amount)
|
|
);
|
|
}
|
|
|
|
// Compact INR formatter using the Indian short scale (lakh = 1e5, crore = 1e7).
|
|
// Produces readable abbreviations for dashboard stat cards, e.g. ₹2 Cr, ₹49 L,
|
|
// ₹75 K, ₹500. Values are rounded to at most 2 decimals with trailing zeros
|
|
// trimmed (₹2.5 Cr, not ₹2.50 Cr). Negative amounts keep their sign.
|
|
export function formatCompactINR(amount: number | string): string {
|
|
const n = Number(amount);
|
|
if (!Number.isFinite(n)) return "₹0";
|
|
|
|
const sign = n < 0 ? "-" : "";
|
|
const abs = Math.abs(n);
|
|
|
|
const format = (value: number, suffix: string) => {
|
|
const rounded = Math.round(value * 100) / 100;
|
|
// Trim trailing zeros: 2 -> "2", 2.5 -> "2.5", 2.05 -> "2.05".
|
|
const text = rounded.toFixed(2).replace(/\.?0+$/, "");
|
|
return `${sign}₹${text}${suffix}`;
|
|
};
|
|
|
|
if (abs >= 1e7) return format(abs / 1e7, " Cr");
|
|
if (abs >= 1e5) return format(abs / 1e5, " L");
|
|
if (abs >= 1e3) return format(abs / 1e3, " K");
|
|
return format(abs, "");
|
|
}
|
|
|
|
export function formatDate(date: Date | string): string {
|
|
return new Intl.DateTimeFormat("en-US", {
|
|
year: "numeric",
|
|
month: "short",
|
|
day: "numeric",
|
|
}).format(new Date(date));
|
|
}
|
|
|
|
export function formatDateTime(date: Date | string): string {
|
|
return new Intl.DateTimeFormat("en-US", {
|
|
year: "numeric",
|
|
month: "short",
|
|
day: "numeric",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
}).format(new Date(date));
|
|
}
|
|
|
|
export function generatePoNumber(): string {
|
|
const year = new Date().getFullYear();
|
|
const seq = Math.floor(Math.random() * 100000)
|
|
.toString()
|
|
.padStart(5, "0");
|
|
return `PO-${year}-${seq}`;
|
|
}
|
|
|
|
export const PO_STATUS_LABELS: Record<POStatus, string> = {
|
|
DRAFT: "Draft",
|
|
SUBMITTED: "Submitted",
|
|
MGR_REVIEW: "Under Review",
|
|
VENDOR_ID_PENDING: "Vendor ID Pending",
|
|
EDITS_REQUESTED: "Edits Requested",
|
|
REJECTED: "Rejected",
|
|
MGR_APPROVED: "Approved",
|
|
SENT_FOR_PAYMENT: "Sent for Payment",
|
|
PARTIALLY_PAID: "Partially Paid",
|
|
PAID_DELIVERED: "Paid",
|
|
PARTIALLY_CLOSED: "Partially Received",
|
|
CLOSED: "Closed",
|
|
CANCELLED: "Cancelled",
|
|
};
|
|
|
|
// Statuses a PO can be in once it has received manager approval. A PO keeps its
|
|
// `approvedAt` timestamp as it moves through these states, so "approved this month"
|
|
// aggregations must match against all of them — not just MGR_APPROVED.
|
|
export const POST_APPROVAL_STATUSES = [
|
|
"MGR_APPROVED",
|
|
"SENT_FOR_PAYMENT",
|
|
"PARTIALLY_PAID",
|
|
"PAID_DELIVERED",
|
|
"PARTIALLY_CLOSED",
|
|
"CLOSED",
|
|
] as const satisfies readonly POStatus[];
|
|
|
|
export type BadgeVariant =
|
|
| "default"
|
|
| "secondary"
|
|
| "success"
|
|
| "warning"
|
|
| "danger"
|
|
| "outline";
|
|
|
|
export const PO_STATUS_VARIANTS: Record<POStatus, BadgeVariant> = {
|
|
DRAFT: "outline",
|
|
SUBMITTED: "secondary",
|
|
MGR_REVIEW: "secondary",
|
|
VENDOR_ID_PENDING: "warning",
|
|
EDITS_REQUESTED: "warning",
|
|
REJECTED: "danger",
|
|
MGR_APPROVED: "success",
|
|
SENT_FOR_PAYMENT: "default",
|
|
PARTIALLY_PAID: "warning",
|
|
PAID_DELIVERED: "success",
|
|
PARTIALLY_CLOSED: "warning",
|
|
CLOSED: "secondary",
|
|
CANCELLED: "danger",
|
|
};
|