mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 14:08:11 +00:00
booking flow,summtion, approval, contract, mock payemnt and integration to back office, and also add permissions
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
import type { BookingApprovalStep, BookingStatus } from "@/types/booking";
|
||||
import { getNextPendingApprovalStep } from "@/features/bookings/booking-actions.config";
|
||||
|
||||
export interface ApprovalProgressSummary {
|
||||
label: string;
|
||||
detail: string;
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
/** Compact approval chain summary for list rows and badges. */
|
||||
export function formatApprovalProgress(
|
||||
status: BookingStatus | string,
|
||||
steps?: BookingApprovalStep[] | null,
|
||||
): ApprovalProgressSummary {
|
||||
const sorted = [...(steps ?? [])].sort((a, b) => a.stepOrder - b.stepOrder);
|
||||
|
||||
if (sorted.length === 0) {
|
||||
if (status === "SUBMITTED") {
|
||||
return {
|
||||
label: "Awaiting accept",
|
||||
detail: "Staff must accept intake",
|
||||
complete: false,
|
||||
};
|
||||
}
|
||||
if (
|
||||
status === "PENDING_APPROVAL" ||
|
||||
status === "APPROVED_PENDING_SIGNATURE"
|
||||
) {
|
||||
return {
|
||||
label: "No steps",
|
||||
detail: "Approval chain not started",
|
||||
complete: false,
|
||||
};
|
||||
}
|
||||
if (
|
||||
[
|
||||
"APPROVED",
|
||||
"CONTRACT_READY",
|
||||
"SIGNED_CUSTOMER",
|
||||
"FULLY_EXECUTED",
|
||||
"PAID",
|
||||
"COMPLETED",
|
||||
].includes(status)
|
||||
) {
|
||||
return {
|
||||
label: "Approved",
|
||||
detail: "Internal approval complete",
|
||||
complete: true,
|
||||
};
|
||||
}
|
||||
return { label: "—", detail: "", complete: false };
|
||||
}
|
||||
|
||||
const approved = sorted.filter((s) => s.status === "APPROVED").length;
|
||||
const total = sorted.length;
|
||||
const next = getNextPendingApprovalStep(sorted);
|
||||
|
||||
if (!next && approved === total) {
|
||||
return {
|
||||
label: `${approved}/${total} done`,
|
||||
detail: sorted.map((s) => `${s.requiredRole} ✓`).join(" · "),
|
||||
complete: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (next) {
|
||||
return {
|
||||
label: `${approved}/${total}`,
|
||||
detail: `Next: ${next.requiredRole} (step ${next.stepOrder})`,
|
||||
complete: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: `${approved}/${total}`,
|
||||
detail: sorted.map((s) => `${s.requiredRole}: ${s.status}`).join(" · "),
|
||||
complete: approved === total,
|
||||
};
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
import type { AuthUser } from "@/auth/types";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import type {
|
||||
BookingApprovalStep,
|
||||
BookingDetail,
|
||||
@@ -26,12 +28,13 @@ export type BookingActionId =
|
||||
| "rejectApproval"
|
||||
| "generateContract"
|
||||
| "viewContract"
|
||||
| "generatePnr"
|
||||
| "verifyPayment"
|
||||
| "signContractStaff"
|
||||
| "payBooking"
|
||||
| "startTransit"
|
||||
| "complete";
|
||||
| "complete"
|
||||
| "cancel";
|
||||
|
||||
export type BookingActionInputKind = "note" | "reason";
|
||||
export type BookingActionInputKind = "note" | "reason" | "file";
|
||||
|
||||
export interface BookingActionDef {
|
||||
id: BookingActionId;
|
||||
@@ -140,18 +143,118 @@ const SUBMITTED_ACTIONS: BookingActionDef[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const CANCEL_ACTION: BookingActionDef = {
|
||||
id: "cancel",
|
||||
label: "Cancel booking",
|
||||
shortLabel: "Cancel",
|
||||
description: "Cancel this booking",
|
||||
confirmTitle: "Cancel booking?",
|
||||
confirmDescription:
|
||||
"The booking will be marked cancelled. Provide a reason for the audit trail.",
|
||||
variant: "destructive",
|
||||
icon: Ban,
|
||||
input: "reason",
|
||||
inputLabel: "Cancellation reason",
|
||||
inputPlaceholder: "Reason for cancellation…",
|
||||
};
|
||||
|
||||
const VIEW_CONTRACT_ACTION: BookingActionDef = {
|
||||
id: "viewContract",
|
||||
label: "View contract",
|
||||
shortLabel: "Contract",
|
||||
description: "Open contract document and signatures",
|
||||
confirmTitle: "",
|
||||
confirmDescription: "",
|
||||
variant: "outline",
|
||||
icon: FileSignature,
|
||||
};
|
||||
|
||||
const SIGN_CONTRACT_STAFF_ACTION: BookingActionDef = {
|
||||
id: "signContractStaff",
|
||||
label: "Sign contract",
|
||||
shortLabel: "Sign",
|
||||
description: "Open contract page and apply staff counter-signature",
|
||||
confirmTitle: "",
|
||||
confirmDescription: "",
|
||||
variant: "default",
|
||||
icon: FileSignature,
|
||||
primary: true,
|
||||
};
|
||||
|
||||
const PAY_BOOKING_ACTION: BookingActionDef = {
|
||||
id: "payBooking",
|
||||
label: "Pay",
|
||||
shortLabel: "Pay",
|
||||
description: "Complete in-app payment",
|
||||
confirmTitle: "Complete payment?",
|
||||
confirmDescription:
|
||||
"This simulates an in-app payment (Telebirr for ETB, card for USD) and marks the booking as paid.",
|
||||
variant: "default",
|
||||
icon: Wallet,
|
||||
primary: true,
|
||||
};
|
||||
|
||||
function withCancel(actions: BookingActionDef[]): BookingActionDef[] {
|
||||
return [...actions, CANCEL_ACTION];
|
||||
}
|
||||
|
||||
const ACTION_PERMISSION: Partial<Record<BookingActionId, string>> = {
|
||||
accept: FREIGHT_PERMS.bookings.staffAccept,
|
||||
requestChanges: FREIGHT_PERMS.bookings.requestChanges,
|
||||
reject: FREIGHT_PERMS.bookings.reject,
|
||||
rejectApproval: FREIGHT_PERMS.bookings.rejectApproval,
|
||||
generateContract: FREIGHT_PERMS.bookings.generateContract,
|
||||
viewContract: FREIGHT_PERMS.bookings.view,
|
||||
signContractStaff: FREIGHT_PERMS.bookings.signStaff,
|
||||
payBooking: FREIGHT_PERMS.bookings.view,
|
||||
startTransit: FREIGHT_PERMS.bookings.operations,
|
||||
complete: FREIGHT_PERMS.bookings.operations,
|
||||
cancel: FREIGHT_PERMS.bookings.cancel,
|
||||
};
|
||||
|
||||
const approvePermissionForRole = (role: string): string | undefined => {
|
||||
if (role === "LINE_STAFF") return FREIGHT_PERMS.bookings.approveLineStaff;
|
||||
if (role === "DIRECTOR") return FREIGHT_PERMS.bookings.approveDirector;
|
||||
if (role === "CEO") return FREIGHT_PERMS.bookings.approveCeo;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
function filterActionsByUser(
|
||||
actions: BookingActionDef[],
|
||||
user: AuthUser | null | undefined,
|
||||
approvalSteps?: BookingApprovalStep[] | null,
|
||||
): BookingActionDef[] {
|
||||
if (!user) return [];
|
||||
const next = getNextPendingApprovalStep(approvalSteps);
|
||||
return actions.filter((action) => {
|
||||
if (action.id === "approve" && next) {
|
||||
const perm = approvePermissionForRole(next.requiredRole);
|
||||
return perm ? hasPermission(user, perm) : false;
|
||||
}
|
||||
const perm = ACTION_PERMISSION[action.id];
|
||||
return perm ? hasPermission(user, perm) : true;
|
||||
});
|
||||
}
|
||||
|
||||
/** Actions available for the current booking status (detail or list). */
|
||||
export function getBookingActions(ctx: BookingActionContext): BookingActionDef[] {
|
||||
const { status, paymentCurrency, approvalSteps } = ctx;
|
||||
export function getBookingActions(
|
||||
ctx: BookingActionContext,
|
||||
user?: AuthUser | null,
|
||||
): BookingActionDef[] {
|
||||
const { status, approvalSteps } = ctx;
|
||||
|
||||
let actions: BookingActionDef[];
|
||||
|
||||
switch (status) {
|
||||
case "SUBMITTED":
|
||||
return SUBMITTED_ACTIONS;
|
||||
actions = withCancel(SUBMITTED_ACTIONS);
|
||||
break;
|
||||
case "PENDING_APPROVAL":
|
||||
case "APPROVED_PENDING_SIGNATURE":
|
||||
return approvalActions(approvalSteps);
|
||||
actions = withCancel(approvalActions(approvalSteps));
|
||||
break;
|
||||
case "APPROVED":
|
||||
return [
|
||||
actions = [
|
||||
{
|
||||
id: "generateContract",
|
||||
label: "Generate contract",
|
||||
@@ -164,64 +267,23 @@ export function getBookingActions(ctx: BookingActionContext): BookingActionDef[]
|
||||
icon: FileText,
|
||||
primary: true,
|
||||
},
|
||||
CANCEL_ACTION,
|
||||
];
|
||||
break;
|
||||
case "CONTRACT_READY":
|
||||
actions = [{ ...VIEW_CONTRACT_ACTION, primary: true }];
|
||||
break;
|
||||
case "SIGNED_CUSTOMER":
|
||||
actions = [SIGN_CONTRACT_STAFF_ACTION, VIEW_CONTRACT_ACTION];
|
||||
break;
|
||||
case "FULLY_EXECUTED":
|
||||
return [
|
||||
{
|
||||
id: "viewContract",
|
||||
label:
|
||||
status === "SIGNED_CUSTOMER"
|
||||
? "View & sign contract (staff)"
|
||||
: status === "CONTRACT_READY"
|
||||
? "View contract"
|
||||
: "View executed contract",
|
||||
shortLabel: "Contract",
|
||||
description: "Open contract document and signatures",
|
||||
confirmTitle: "",
|
||||
confirmDescription: "",
|
||||
variant: "default",
|
||||
icon: FileSignature,
|
||||
primary: true,
|
||||
},
|
||||
];
|
||||
case "FULLY_EXECUTED":
|
||||
if (paymentCurrency === "ETB") {
|
||||
return [
|
||||
{
|
||||
id: "generatePnr",
|
||||
label: "Generate PNR",
|
||||
shortLabel: "PNR",
|
||||
description: "Issue PNR for ETB bank payment",
|
||||
confirmTitle: "Generate PNR?",
|
||||
confirmDescription:
|
||||
"A payment reference number will be issued for the customer.",
|
||||
variant: "default",
|
||||
icon: Wallet,
|
||||
primary: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
case "PAYMENT_VERIFICATION_IN_PROGRESS":
|
||||
return [
|
||||
{
|
||||
id: "verifyPayment",
|
||||
label: "Verify payment",
|
||||
shortLabel: "Verify",
|
||||
description: "Confirm USD payment proof",
|
||||
confirmTitle: "Verify payment?",
|
||||
confirmDescription:
|
||||
"Finance confirms the uploaded proof and marks the booking as paid.",
|
||||
variant: "default",
|
||||
icon: Check,
|
||||
primary: true,
|
||||
},
|
||||
actions = [
|
||||
PAY_BOOKING_ACTION,
|
||||
{ ...VIEW_CONTRACT_ACTION, label: "View executed contract" },
|
||||
];
|
||||
break;
|
||||
case "PAID":
|
||||
case "PNR_GENERATED":
|
||||
return [
|
||||
actions = [
|
||||
{
|
||||
id: "startTransit",
|
||||
label: "Start transit",
|
||||
@@ -234,8 +296,9 @@ export function getBookingActions(ctx: BookingActionContext): BookingActionDef[]
|
||||
primary: true,
|
||||
},
|
||||
];
|
||||
break;
|
||||
case "IN_TRANSIT":
|
||||
return [
|
||||
actions = [
|
||||
{
|
||||
id: "complete",
|
||||
label: "Complete booking",
|
||||
@@ -249,20 +312,39 @@ export function getBookingActions(ctx: BookingActionContext): BookingActionDef[]
|
||||
primary: true,
|
||||
},
|
||||
];
|
||||
break;
|
||||
case "CHANGES_REQUESTED":
|
||||
actions = [CANCEL_ACTION];
|
||||
break;
|
||||
default:
|
||||
return [];
|
||||
actions = [];
|
||||
}
|
||||
|
||||
if (user === undefined) return actions;
|
||||
return filterActionsByUser(actions, user, approvalSteps);
|
||||
}
|
||||
|
||||
export function listRowHasActions(row: {
|
||||
status: BookingStatus;
|
||||
paymentCurrency: string;
|
||||
}): boolean {
|
||||
const actions = getBookingActions({
|
||||
status: row.status,
|
||||
paymentCurrency: row.paymentCurrency,
|
||||
reference: "",
|
||||
});
|
||||
if (actions.length > 0) return true;
|
||||
return row.status === "FULLY_EXECUTED" && row.paymentCurrency === "USD";
|
||||
/** Opens contract page without confirmation dialog. */
|
||||
export function isContractNavAction(id: BookingActionId): boolean {
|
||||
return id === "viewContract" || id === "signContractStaff";
|
||||
}
|
||||
|
||||
export function listRowHasActions(
|
||||
row: {
|
||||
status: BookingStatus;
|
||||
paymentCurrency: string;
|
||||
approvalSteps?: BookingApprovalStep[] | null;
|
||||
},
|
||||
user?: AuthUser | null,
|
||||
): boolean {
|
||||
const actions = getBookingActions(
|
||||
{
|
||||
status: row.status,
|
||||
paymentCurrency: row.paymentCurrency,
|
||||
reference: "",
|
||||
approvalSteps: row.approvalSteps,
|
||||
},
|
||||
user,
|
||||
);
|
||||
return actions.length > 0;
|
||||
}
|
||||
|
||||
@@ -199,20 +199,31 @@ export const BOOKING_STATUS_META: Record<string, StatusMeta> = {
|
||||
};
|
||||
|
||||
export const BOOKING_LIST_TABS = [
|
||||
{ key: "all", label: "All bookings", status: null },
|
||||
{ key: "SUBMITTED", label: "Submitted", status: "SUBMITTED" },
|
||||
{ key: "PENDING_APPROVAL", label: "Pending Approval", status: "PENDING_APPROVAL" },
|
||||
{ key: "all", label: "All bookings", statuses: null as string[] | null },
|
||||
{ key: "intake", label: "Submitted", statuses: ["SUBMITTED"] },
|
||||
{
|
||||
key: "APPROVED_PENDING_SIGNATURE",
|
||||
label: "Pending Signature",
|
||||
status: "APPROVED_PENDING_SIGNATURE",
|
||||
key: "in_approval",
|
||||
label: "In approval",
|
||||
statuses: ["PENDING_APPROVAL", "APPROVED_PENDING_SIGNATURE"],
|
||||
},
|
||||
{ key: "SIGNED_CUSTOMER", label: "Customer Signed", status: "SIGNED_CUSTOMER" },
|
||||
{
|
||||
key: "PAYMENT_VERIFICATION_IN_PROGRESS",
|
||||
label: "Payment Verification",
|
||||
status: "PAYMENT_VERIFICATION_IN_PROGRESS",
|
||||
key: "approved_contract",
|
||||
label: "Approved & contract",
|
||||
statuses: [
|
||||
"APPROVED",
|
||||
"CONTRACT_READY",
|
||||
"SIGNED_CUSTOMER",
|
||||
"FULLY_EXECUTED",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "payment",
|
||||
label: "Payment",
|
||||
statuses: ["FULLY_EXECUTED", "PAID"],
|
||||
},
|
||||
{ key: "operations", label: "Operations", statuses: ["IN_TRANSIT", "PENDING_CONSOLIDATION", "CONSOLIDATED"] },
|
||||
{ key: "completed", label: "Completed", statuses: ["COMPLETED"] },
|
||||
{ key: "closed", label: "Closed", statuses: ["REJECTED", "CANCELLED"] },
|
||||
] as const;
|
||||
|
||||
export type BookingStatusTabKey = (typeof BOOKING_LIST_TABS)[number]["key"];
|
||||
@@ -229,11 +240,7 @@ export const WORKFLOW_STAGES = [
|
||||
},
|
||||
{
|
||||
label: "Payment",
|
||||
statuses: [
|
||||
"PNR_GENERATED",
|
||||
"PAYMENT_VERIFICATION_IN_PROGRESS",
|
||||
"PAID",
|
||||
],
|
||||
statuses: ["FULLY_EXECUTED", "PAID"],
|
||||
},
|
||||
{
|
||||
label: "Operations",
|
||||
|
||||
@@ -18,6 +18,7 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow {
|
||||
return {
|
||||
id: booking.id,
|
||||
reference: booking.reference,
|
||||
approvalSteps: booking.approvalSteps,
|
||||
customerLabel: labelFromRef(booking.company, booking.companyId),
|
||||
// customerLabel: labelFromRef(booking.customer, booking.customerId),
|
||||
status: booking.status,
|
||||
|
||||
Reference in New Issue
Block a user