mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-02 05:03:12 +00:00
# Conflicts: # e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png # src/app/(protected)/workorders/_components/list/table/cells/uplift-cell.tsx # src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx # src/app/(protected)/workorders/_components/uplifts/work-order-uplift-list-item.tsx # src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog-content.tsx # src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog.tsx # src/components/common/address-autocomplete-field.tsx # src/domain/work-orders/utils/uplift-display-utils.ts # src/test/app/(protected)/workorders/work-order-uplifts-affordances.test.tsx
269 lines
8.5 KiB
TypeScript
269 lines
8.5 KiB
TypeScript
import type {
|
|
WorkOrderUplift,
|
|
UpliftSummary,
|
|
WorkOrderUpliftStatus,
|
|
} from "@/domain/work-orders/types/work-order-uplift";
|
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
|
|
|
export function formatUpliftMoney(value: number | null | undefined): string {
|
|
if (value == null || Number.isNaN(value)) {
|
|
return "—";
|
|
}
|
|
return new Intl.NumberFormat("en-US", {
|
|
style: "currency",
|
|
currency: "USD",
|
|
maximumFractionDigits: 0,
|
|
}).format(value);
|
|
}
|
|
|
|
const PILL_LABELS: Record<WorkOrderUpliftStatus, string> = {
|
|
pending: "Pending approval",
|
|
approved: "Approved",
|
|
auto_approved: "Auto-approved",
|
|
rejected: "Rejected",
|
|
cancelled: "Cancelled",
|
|
revoked: "Revoked",
|
|
};
|
|
|
|
const CELL_STATUS_WORD: Record<WorkOrderUpliftStatus, string> = {
|
|
pending: "Pending",
|
|
approved: "Approved",
|
|
auto_approved: "Approved",
|
|
rejected: "Rejected",
|
|
cancelled: "Cancelled",
|
|
revoked: "Revoked",
|
|
};
|
|
|
|
export function formatUpliftStatusLabel(status: WorkOrderUpliftStatus): string {
|
|
return PILL_LABELS[status];
|
|
}
|
|
|
|
export function getUpliftCellLabel(summary: UpliftSummary): string | null {
|
|
if (!summary.hasUplift && summary.pendingCount <= 0) {
|
|
return null;
|
|
}
|
|
if (summary.pendingCount > 0) {
|
|
if (summary.amount != null) {
|
|
return `${formatUpliftMoney(summary.amount)} Pending`;
|
|
}
|
|
return summary.pendingCount === 1 ? "1 Pending" : `${summary.pendingCount} Pending`;
|
|
}
|
|
if (summary.primaryStatus) {
|
|
const label = CELL_STATUS_WORD[summary.primaryStatus];
|
|
if (summary.amount != null) {
|
|
return `${formatUpliftMoney(summary.amount)} ${label}`;
|
|
}
|
|
return label;
|
|
}
|
|
if (summary.hasUplift) {
|
|
return "Uplift";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function getUpliftStatusPillStyle(status: WorkOrderUpliftStatus): {
|
|
background: string;
|
|
color: string;
|
|
} {
|
|
switch (status) {
|
|
case "pending":
|
|
return { background: "var(--status-pending-bg)", color: "var(--status-pending-text)" };
|
|
case "approved":
|
|
case "auto_approved":
|
|
return { background: "var(--status-completed-bg)", color: "var(--status-completed-text)" };
|
|
case "rejected":
|
|
return { background: "var(--status-pastdue-bg)", color: "var(--status-pastdue-text)" };
|
|
case "cancelled":
|
|
case "revoked":
|
|
return { background: "var(--status-canceled-bg)", color: "var(--status-canceled-text)" };
|
|
default:
|
|
return { background: "var(--color-bg-muted)", color: "var(--color-text-muted)" };
|
|
}
|
|
}
|
|
|
|
export function isWorkOrderUpliftsReadOnly(status: WorkOrderTableRow["status"]): boolean {
|
|
return status === "Completed" || status === "Canceled";
|
|
}
|
|
|
|
export type UpliftAllowanceHelperTone = "muted" | "success" | "warning";
|
|
export type UpliftAllowanceHelperIcon = "check" | "alert" | null;
|
|
|
|
export type UpliftAllowanceHelper = {
|
|
text: string;
|
|
tone: UpliftAllowanceHelperTone;
|
|
tint: boolean;
|
|
icon: UpliftAllowanceHelperIcon;
|
|
};
|
|
|
|
export function upliftAllowanceHelper(
|
|
amount: number,
|
|
remainingAllowance: number,
|
|
): UpliftAllowanceHelper {
|
|
const typed = Number.isFinite(amount) && amount > 0;
|
|
if (!typed) {
|
|
if (remainingAllowance > 0) {
|
|
return {
|
|
text: `${formatUpliftMoney(remainingAllowance)} left to auto-approve`,
|
|
tone: "muted",
|
|
tint: false,
|
|
icon: null,
|
|
};
|
|
}
|
|
return {
|
|
text: "Limit used — new uplifts need admin approval",
|
|
tone: "warning",
|
|
tint: false,
|
|
icon: "alert",
|
|
};
|
|
}
|
|
if (amount <= remainingAllowance) {
|
|
return {
|
|
text: `Approves instantly · ${formatUpliftMoney(remainingAllowance - amount)} left after this`,
|
|
tone: "success",
|
|
tint: false,
|
|
icon: "check",
|
|
};
|
|
}
|
|
return {
|
|
text: `Needs admin approval · ${formatUpliftMoney(remainingAllowance)} limit`,
|
|
tone: "warning",
|
|
tint: true,
|
|
icon: "alert",
|
|
};
|
|
}
|
|
|
|
export function canRevokeWorkOrderUplift(
|
|
uplift: Pick<WorkOrderUplift, "status" | "requestedById">,
|
|
currentUserId: string | number | null | undefined,
|
|
currentUserIsAdmin: boolean,
|
|
): boolean {
|
|
if (uplift.status !== "auto_approved") {
|
|
// why: SH-214/SH-212 keep admin-approved revoke on Uplift Approvals, not this work order panel.
|
|
return false;
|
|
}
|
|
if (currentUserIsAdmin) {
|
|
return false;
|
|
}
|
|
return (
|
|
currentUserId != null &&
|
|
currentUserId !== "" &&
|
|
uplift.requestedById != null &&
|
|
String(uplift.requestedById) === String(currentUserId)
|
|
);
|
|
}
|
|
|
|
export function upliftRevokeRequiresReason(status: WorkOrderUpliftStatus): boolean {
|
|
return status === "approved";
|
|
}
|
|
|
|
export const UPLIFT_STANDARD_ALLOWANCE_USD = 500;
|
|
export const UPLIFT_EMERGENCY_ALLOWANCE_USD = 5000;
|
|
|
|
export function getUpliftAllowanceCap(workOrderType: string): number {
|
|
return workOrderType === "Emergency"
|
|
? UPLIFT_EMERGENCY_ALLOWANCE_USD
|
|
: UPLIFT_STANDARD_ALLOWANCE_USD;
|
|
}
|
|
|
|
export function hasOpenWorkOrderUplift(
|
|
uplifts: ReadonlyArray<Pick<WorkOrderUplift, "status">>,
|
|
): boolean {
|
|
return uplifts.some((uplift) => uplift.status === "pending");
|
|
}
|
|
|
|
export function getRemainingUpliftAllowance(
|
|
workOrderType: string,
|
|
uplifts: ReadonlyArray<Pick<WorkOrderUplift, "status" | "amount">>,
|
|
): number {
|
|
const consumed = uplifts
|
|
.filter((uplift) => uplift.status === "auto_approved")
|
|
.reduce((sum, uplift) => sum + uplift.amount, 0);
|
|
return Math.max(0, getUpliftAllowanceCap(workOrderType) - consumed);
|
|
}
|
|
|
|
function displayActor(name: string): string {
|
|
return name.trim() === "" ? "—" : name;
|
|
}
|
|
|
|
export function formatUpliftAbsoluteDate(iso: string): string {
|
|
if (iso.trim() === "") return "—";
|
|
const date = new Date(iso);
|
|
if (Number.isNaN(date.getTime())) return "—";
|
|
return date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
|
}
|
|
|
|
export function formatUpliftRelativeTime(iso: string, nowMs: number): string {
|
|
if (iso.trim() === "") return "—";
|
|
const timestamp = new Date(iso).getTime();
|
|
if (Number.isNaN(timestamp)) return "—";
|
|
const minutes = Math.floor((nowMs - timestamp) / 60000);
|
|
if (minutes < 1) return "just now";
|
|
if (minutes < 60) return `${minutes}m ago`;
|
|
const hours = Math.floor(minutes / 60);
|
|
if (hours < 24) return `${hours}h ago`;
|
|
return `${Math.floor(hours / 24)}d ago`;
|
|
}
|
|
|
|
type UpliftMetaSource = Pick<
|
|
WorkOrderUplift,
|
|
"status" | "requestedAt" | "requestedByName" | "decidedAt" | "decidedByName"
|
|
>;
|
|
|
|
export function formatUpliftCardMeta(uplift: UpliftMetaSource, nowMs: number): string {
|
|
const requester = displayActor(uplift.requestedByName);
|
|
const decider = displayActor(uplift.decidedByName);
|
|
if (uplift.status === "pending") {
|
|
return `${requester} · ${formatUpliftRelativeTime(uplift.requestedAt, nowMs)}`;
|
|
}
|
|
if (uplift.status === "auto_approved") {
|
|
return `Within allowance · ${requester} · ${formatUpliftAbsoluteDate(uplift.requestedAt)}`;
|
|
}
|
|
if (uplift.status === "approved") {
|
|
return `Approved by ${decider} · ${formatUpliftAbsoluteDate(uplift.decidedAt)}`;
|
|
}
|
|
if (uplift.status === "rejected") {
|
|
return `Rejected by ${decider} · ${formatUpliftAbsoluteDate(uplift.decidedAt)}`;
|
|
}
|
|
if (uplift.status === "revoked") {
|
|
return `Revoked by ${decider} · ${formatUpliftAbsoluteDate(uplift.decidedAt)}`;
|
|
}
|
|
const when = uplift.decidedAt.trim() === "" ? uplift.requestedAt : uplift.decidedAt;
|
|
return `Cancelled by ${requester} · ${formatUpliftAbsoluteDate(when)}`;
|
|
}
|
|
|
|
export function isVoidUpliftStatus(status: WorkOrderUpliftStatus): boolean {
|
|
return status === "rejected" || status === "cancelled" || status === "revoked";
|
|
}
|
|
|
|
export type UpliftCellVisual =
|
|
| { kind: "pending"; amount: string | null; statusLabel: "Pending" }
|
|
| { kind: "approved"; amount: string | null; statusLabel: "Approved" }
|
|
| { kind: "rejected"; amount: string | null; statusLabel: "Rejected" }
|
|
| { kind: "plain"; label: string };
|
|
|
|
export function getUpliftCellVisual(summary: UpliftSummary): UpliftCellVisual | null {
|
|
const label = getUpliftCellLabel(summary);
|
|
if (label == null) return null;
|
|
if (summary.pendingCount > 0) {
|
|
return {
|
|
kind: "pending",
|
|
amount: summary.amount != null ? formatUpliftMoney(summary.amount) : null,
|
|
statusLabel: "Pending",
|
|
};
|
|
}
|
|
if (summary.primaryStatus === "approved" || summary.primaryStatus === "auto_approved") {
|
|
return {
|
|
kind: "approved",
|
|
amount: summary.amount != null ? formatUpliftMoney(summary.amount) : null,
|
|
statusLabel: "Approved",
|
|
};
|
|
}
|
|
if (summary.primaryStatus === "rejected") {
|
|
return {
|
|
kind: "rejected",
|
|
amount: summary.amount != null ? formatUpliftMoney(summary.amount) : null,
|
|
statusLabel: "Rejected",
|
|
};
|
|
}
|
|
return { kind: "plain", label };
|
|
}
|