booking flow,summtion, approval, contract, mock payemnt and integration to back office, and also add permissions

This commit is contained in:
marshal
2026-06-05 10:38:31 +03:00
parent 810bbc1168
commit d226d7ef22
94 changed files with 3509 additions and 1042 deletions

View File

@@ -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,
};
}

View File

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

View File

@@ -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",

View File

@@ -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,