From cbff529e31d5592a391f3d9963c6fd3b51dab615 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 23 Jul 2026 07:40:48 +0000 Subject: [PATCH 01/16] feat: rebuild the admin pages --- .../dto/create-organization-user.dto.ts | 6 +- .../src/locales/am/translation.json | 87 ++++- .../src/locales/en/translation.json | 82 ++++ .../backoffice/src/pages/DashboardPage.tsx | 6 +- .../src/pages/OrganizationAdminsPage.tsx | 4 +- .../components/org-admins/AdminFormModal.tsx | 211 ++++++++++ .../org-admins/AssignExistingAdminModal.tsx | 165 ++++++++ .../org-admins/OrgAdminsColumnDefn.tsx | 228 +++++++++++ .../components/org-admins/OrgAdminsPage.tsx | 364 ++++++++++++++++++ .../components/org-admins/OrgPicker.tsx | 142 +++++++ .../AssignOrgAdminDialog.tsx | 277 ------------- .../organizationAdmins/OrganizationAdmins.tsx | 134 ------- .../OrganizationAdminsActions.tsx | 80 ---- .../OrganizationAdminsColumnDefn.tsx | 77 ---- .../src/super-admin/hooks/useOrgAdmins.ts | 199 ++++++++++ .../CreatePositionForm.tsx | 2 +- 16 files changed, 1485 insertions(+), 579 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/AdminFormModal.tsx create mode 100644 apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/AssignExistingAdminModal.tsx create mode 100644 apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsColumnDefn.tsx create mode 100644 apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgPicker.tsx delete mode 100644 apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/AssignOrgAdminDialog.tsx delete mode 100644 apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/OrganizationAdmins.tsx delete mode 100644 apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/OrganizationAdminsActions.tsx delete mode 100644 apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/OrganizationAdminsColumnDefn.tsx create mode 100644 apps/edr-freight-web/backoffice/src/super-admin/hooks/useOrgAdmins.ts diff --git a/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts b/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts index cf324a501..cc7507dd6 100644 --- a/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts +++ b/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts @@ -1,5 +1,6 @@ import { ApiProperty } from "@nestjs/swagger"; -import { IsBoolean, IsEmail, IsObject, IsOptional, IsString, MinLength } from "class-validator"; +import { Type } from "class-transformer"; +import { IsBoolean, IsEmail, IsOptional, IsString, MinLength, ValidateNested } from "class-validator"; class CreateOrganizationUserNameDto { @ApiProperty() @@ -29,7 +30,8 @@ export class CreateOrganizationUserDto { phoneNumber?: string; @ApiProperty({ type: CreateOrganizationUserNameDto }) - @IsObject() + @ValidateNested() + @Type(() => CreateOrganizationUserNameDto) name!: CreateOrganizationUserNameDto; @ApiProperty({ required: false, default: false }) diff --git a/apps/edr-freight-web/backoffice/src/locales/am/translation.json b/apps/edr-freight-web/backoffice/src/locales/am/translation.json index a37525807..4f3daeaee 100644 --- a/apps/edr-freight-web/backoffice/src/locales/am/translation.json +++ b/apps/edr-freight-web/backoffice/src/locales/am/translation.json @@ -2585,7 +2585,6 @@ "archiveDepartment": "የስራ መደብ መጠርያ አርክብ አድርግ", "deleteDepartment": "የስራ መደብ መጠርያ ሰርዝ", "deleteConfirm": "የስራ መደብ መጠርያ ይሰርዝ?", - "delete": "ሰርዝ", "deleteFailed": "የስራ መደብ መጠርያ ሰረዝ ወደ ተሳክቶ", "cannotDeleteWithEmployees": "ተመድቦ ካለበት ሰራተኞች ጋር የስራ መደብ መጠርያ መሰረዝ አይቻልም", "reassignEmployeesFirst": "እባክዎ ሁሉንም ሰራተኞች በዚህ ክፍል ውስጥ ዳግም ይሰጧቸው ወይም ያስወግዱ።", @@ -3117,7 +3116,7 @@ "referenceNumberStyles": "የማጣቀሻ ቁጥር ስታይሎች", "other": "ሌላ", "styleCategories": "የስታይል ምድቦች", - "styleEditor": "ስታይል አርታኢ", + "styleEditor": "የስታይል አርታዒ", "livePreview": "የቀጥታ ቅድመ-እይታ", "fontsHint": "አማራጭ የቅርጸ-ቁምፊ ሪሶርስ ይምረጡ።", "fontResource": "የቅርጸ-ቁምፊ ፋይል", @@ -3209,11 +3208,9 @@ "previewLanguage": "የቅድመ-እይታ ቋንቋ", "amharic": "አማርኛ", "english": "እንግሊዝኛ", - "styleCategories": "የስታይል ምድቦች", "categoryHint": "ለማርትዕ ክፍል ይምረጡ", "expandSidebar": "የጎን ማውጫን ዘርጋ", "collapseSidebar": "የጎን ማውጫን ጠቅልል", - "styleEditor": "የስታይል አርታዒ", "headerFooterSelection": "ራስጌ እና ግርጌ", "noSettings": "ምንም የስታይል ቅንብሮች የሉም", "visible": "የሚታይ", @@ -7393,5 +7390,87 @@ "department": "ዲፓርትመንት", "unit": "ክፍል", "notAvailable": "ያልዋቀረ" + }, + "orgAdmins": { + "title": "የድርጅት አስተዳዳሪዎች", + "subtitle": "አስተዳዳሪዎቹን ለማየት እና ለማስተዳደር ድርጅት ይምረጡ።", + "tableName": "የድርጅት አስተዳዳሪዎች", + "selectOrg": "ድርጅት ይምረጡ", + "searchOrgs": "ድርጅቶችን ይፈልጉ...", + "noOrgsFound": "ምንም ድርጅት አልተገኘም።", + "adminsCount": "{{count}} አስተዳዳሪ", + "adminsCount_other": "{{count}} አስተዳዳሪዎች", + "noAdmins": "አስተዳዳሪ የለም", + "activeEmployees": "{{count}} ንቁ ሰራተኞች", + "selectOrgPrompt": "አስተዳዳሪዎቹን ለማስተዳደር ድርጅት ይምረጡ", + "selectOrgPromptHint": "ከላይ ያለውን መምረጫ ተጠቅመው ድርጅት ይፈልጉ እና ይምረጡ።", + "noAdminsHint": "{{name}} እስካሁን አስተዳዳሪ የለውም። አዲስ አስተዳዳሪ ይጋብዙ ወይም ነባር ሰራተኛ ይመድቡ።", + "inviteAdmin": "አስተዳዳሪ ይጋብዙ", + "assignExisting": "ነባር ሰራተኛ ይመድቡ", + "roleOrgAdmin": "የድርጅት አስተዳዳሪ", + "roleUnitAdmin": "የክፍል አስተዳዳሪ", + "statusInvited": "የተጋበዘ", + "statusActive": "ንቁ", + "statusInactive": "ንቁ ያልሆነ", + "columns": { + "name": "ስም", + "email": "ኢሜይል", + "phone": "ስልክ", + "role": "ሚና", + "status": "ሁኔታ", + "addedOn": "የተጨመረበት ቀን", + "actions": "እርምጃዎች" + }, + "actions": { + "edit": "መገለጫ ያስተካክሉ", + "resend": "ግብዣ እንደገና ይላኩ", + "activate": "መለያ ያንቁ", + "deactivate": "መለያ ያቦዝኑ", + "remove": "አስተዳዳሪ ያስወግዱ" + }, + "form": { + "nameEn": "ስም (እንግሊዝኛ)", + "nameAm": "ስም (አማርኛ)", + "username": "የተጠቃሚ ስም", + "email": "ኢሜይል", + "phoneNumber": "ስልክ ቁጥር" + }, + "invite": { + "title": "አዲስ አስተዳዳሪ ይጋብዙ", + "description": "ተጋባዡ የይለፍ ቃል ለማዘጋጀት እና መለያውን ለማንቃት አገናኝ በSMS ይደርሰዋል።", + "submit": "ግብዣ ይላኩ" + }, + "edit": { + "title": "የአስተዳዳሪ መገለጫ ያስተካክሉ", + "description": "የዚህን አስተዳዳሪ የመገለጫ ዝርዝሮች ያዘምኑ።", + "submit": "ለውጦችን ያስቀምጡ" + }, + "assign": { + "title": "ነባር ሰራተኛ ይመድቡ", + "description": "የዚህን ድርጅት ሰራተኛ ወደ አስተዳዳሪነት ያሳድጉ።", + "searchUsers": "ሰራተኞችን በስም ወይም በኢሜይል ይፈልጉ...", + "noUsersFound": "ምንም ሰራተኛ አልተገኘም።", + "alreadyAdmin": "አስቀድሞ አስተዳዳሪ ነው", + "submit": "እንደ አስተዳዳሪ ይመድቡ" + }, + "confirmRemove": { + "title": "አስተዳዳሪ ይወገድ?", + "description": "ይህ የ{{name}}ን የአስተዳዳሪነት ሚና ከ{{org}} ያስወግዳል። የተጠቃሚው መለያ ራሱ ይቀራል።", + "removing": "በማስወገድ ላይ..." + }, + "confirmToggle": { + "activateTitle": "መለያ ይንቃ?", + "deactivateTitle": "መለያ ይቦዝን?", + "description": "ይህ የ{{name}}ን የመለያ ሁኔታ በመላው ስርዓቱ ላይ ይቀይራል፣ ለዚህ ድርጅት ብቻ አይደለም።" + }, + "toasts": { + "invited": "ግብዣው በተሳካ ሁኔታ ተልኳል!", + "assigned": "አስተዳዳሪ በተሳካ ሁኔታ ተመድቧል!", + "removed": "አስተዳዳሪ በተሳካ ሁኔታ ተወግዷል!", + "resent": "ግብዣው እንደገና ተልኳል!", + "activated": "መለያው ነቅቷል!", + "deactivated": "መለያው ቦዝኗል!", + "profileUpdated": "መገለጫው ተዘምኗል!" + } } } diff --git a/apps/edr-freight-web/backoffice/src/locales/en/translation.json b/apps/edr-freight-web/backoffice/src/locales/en/translation.json index 8cf8582b7..c300cb27a 100644 --- a/apps/edr-freight-web/backoffice/src/locales/en/translation.json +++ b/apps/edr-freight-web/backoffice/src/locales/en/translation.json @@ -7391,5 +7391,87 @@ "department": "Department", "unit": "Unit", "notAvailable": "Not Available" + }, + "orgAdmins": { + "title": "Organization Admins", + "subtitle": "Pick an organization to view and manage its administrators.", + "tableName": "Organization Admins", + "selectOrg": "Select an organization", + "searchOrgs": "Search organizations...", + "noOrgsFound": "No organizations found.", + "adminsCount": "{{count}} admin", + "adminsCount_other": "{{count}} admins", + "noAdmins": "No admins", + "activeEmployees": "{{count}} active employees", + "selectOrgPrompt": "Select an organization to manage its admins", + "selectOrgPromptHint": "Use the selector above to search and pick an organization.", + "noAdminsHint": "{{name}} has no administrators yet. Invite a new admin or assign an existing employee.", + "inviteAdmin": "Invite Admin", + "assignExisting": "Assign Existing", + "roleOrgAdmin": "Org Admin", + "roleUnitAdmin": "Unit Admin", + "statusInvited": "Invited", + "statusActive": "Active", + "statusInactive": "Inactive", + "columns": { + "name": "Name", + "email": "Email", + "phone": "Phone", + "role": "Role", + "status": "Status", + "addedOn": "Added On", + "actions": "Actions" + }, + "actions": { + "edit": "Edit Profile", + "resend": "Resend Invite", + "activate": "Activate Account", + "deactivate": "Deactivate Account", + "remove": "Remove Admin" + }, + "form": { + "nameEn": "Name (English)", + "nameAm": "Name (Amharic)", + "username": "Username", + "email": "Email", + "phoneNumber": "Phone Number" + }, + "invite": { + "title": "Invite New Admin", + "description": "The invitee receives an SMS with a link to set their password and activate the account.", + "submit": "Send Invite" + }, + "edit": { + "title": "Edit Admin Profile", + "description": "Update this administrator's profile details.", + "submit": "Save Changes" + }, + "assign": { + "title": "Assign Existing Employee", + "description": "Promote an employee of this organization to administrator.", + "searchUsers": "Search employees by name or email...", + "noUsersFound": "No employees found.", + "alreadyAdmin": "Already admin", + "submit": "Assign as Admin" + }, + "confirmRemove": { + "title": "Remove Admin?", + "description": "This removes the admin role of {{name}} for {{org}}. The user account itself is kept.", + "removing": "Removing..." + }, + "confirmToggle": { + "activateTitle": "Activate Account?", + "deactivateTitle": "Deactivate Account?", + "description": "This changes the account status of {{name}} across the whole platform, not just for this organization." + }, + "toasts": { + "invited": "Invitation sent successfully!", + "assigned": "Admin assigned successfully!", + "removed": "Admin removed successfully!", + "resent": "Invitation re-sent successfully!", + "activated": "Account activated successfully!", + "deactivated": "Account deactivated successfully!", + "profileUpdated": "Profile updated successfully!" + } } } diff --git a/apps/edr-freight-web/backoffice/src/pages/DashboardPage.tsx b/apps/edr-freight-web/backoffice/src/pages/DashboardPage.tsx index 701400310..2df952af8 100644 --- a/apps/edr-freight-web/backoffice/src/pages/DashboardPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/DashboardPage.tsx @@ -67,7 +67,8 @@ const DashboardPage = () => { refetch(); refetchAdmins(); }} - className="bg-primary hover:bg-primary/90 text-primary-foreground"> + className="bg-primary hover:bg-primary/90 text-primary-foreground" + > {t("organization.retry")} @@ -140,7 +141,8 @@ const DashboardPage = () => { diff --git a/apps/edr-freight-web/backoffice/src/pages/OrganizationAdminsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/OrganizationAdminsPage.tsx index 6fce0ec39..0503baad8 100644 --- a/apps/edr-freight-web/backoffice/src/pages/OrganizationAdminsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/OrganizationAdminsPage.tsx @@ -1,7 +1,7 @@ -import OrganizationsAdmins from "@/super-admin/components/organizationAdmins/OrganizationAdmins"; +import OrgAdminsPage from "@/super-admin/components/org-admins/OrgAdminsPage"; const OrganizationAdminsPage = () => { - return ; + return ; }; export default OrganizationAdminsPage; diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/AdminFormModal.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/AdminFormModal.tsx new file mode 100644 index 000000000..8fff2764d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/AdminFormModal.tsx @@ -0,0 +1,211 @@ +import { useEffect } from "react"; +import { z } from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { t } from "i18next"; +import { useTranslation } from "react-i18next"; +import { Loader2 } from "lucide-react"; +import { Button } from "@/shared/common/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/shared/common/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/shared/common/ui/form"; +import { Input } from "@/shared/common/ui/input"; +import { OrgAdminUser } from "@/super-admin/hooks/useOrgAdmins"; + +const adminSchema = z.object({ + name: z.object({ + en: z.string().min(1, t("organization.englishNameRequired")), + am: z.string().min(1, t("organization.amharicNameRequired")), + }), + username: z.string().min(3, t("organization.usernameMinLength")), + email: z.string().email(t("organization.invalidEmail")), + phoneNumber: z + .string() + .regex(/^(\+251|0)?9\d{8}$/, t("organization.invalidPhoneNumber")), +}); + +export type AdminFormValues = z.infer; + +const EMPTY_VALUES: AdminFormValues = { + name: { en: "", am: "" }, + username: "", + email: "", + phoneNumber: "", +}; + +interface AdminFormModalProps { + isOpen: boolean; + onClose: () => void; + /** null → invite a new admin; set → edit this admin's profile */ + admin: OrgAdminUser | null; + onSubmit: (values: AdminFormValues) => void; + isSubmitting: boolean; +} + +/** Invite-new-admin / edit-admin-profile modal (same fields, one form). */ +export default function AdminFormModal({ + isOpen, + onClose, + admin, + onSubmit, + isSubmitting, +}: AdminFormModalProps) { + const { t } = useTranslation(); + const isEdit = !!admin; + + const form = useForm({ + resolver: zodResolver(adminSchema), + defaultValues: EMPTY_VALUES, + }); + + useEffect(() => { + if (isOpen) { + form.reset( + admin + ? { + name: { + en: admin.name?.en ?? "", + am: admin.name?.am ?? "", + }, + username: admin.username ?? "", + email: admin.email ?? "", + phoneNumber: admin.phoneNumber ?? "", + } + : EMPTY_VALUES, + ); + } + }, [isOpen, admin, form]); + + const handleClose = () => { + if (isSubmitting) return; + onClose(); + }; + + return ( + !open && handleClose()}> + + + + {isEdit ? t("orgAdmins.edit.title") : t("orgAdmins.invite.title")} + + + {isEdit + ? t("orgAdmins.edit.description") + : t("orgAdmins.invite.description")} + + + +
+ +
+ ( + + {t("orgAdmins.form.nameEn")} + + + + + + )} + /> + ( + + {t("orgAdmins.form.nameAm")} + + + + + + )} + /> +
+ ( + + {t("orgAdmins.form.username")} + + + + + + )} + /> + ( + + {t("orgAdmins.form.email")} + + + + + + )} + /> + ( + + {t("orgAdmins.form.phoneNumber")} + + + + + + )} + /> + + + + + + + +
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/AssignExistingAdminModal.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/AssignExistingAdminModal.tsx new file mode 100644 index 000000000..0390f2671 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/AssignExistingAdminModal.tsx @@ -0,0 +1,165 @@ +import { useMemo, useState } from "react"; +import { Check, Loader2, UserPlus } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/shared/common/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/shared/common/ui/dialog"; +import { Input } from "@/shared/common/ui/input"; +import { ScrollArea } from "@/shared/common/ui/scroll-area"; +import { Badge } from "@/shared/common/ui/badge"; +import { cn } from "@/super-admin/lib/utils"; +import { useLocalizedName } from "@/shared/common/localizedName"; +import { useEmployees } from "@/user-management/hooks/useEmployees"; + +interface AssignExistingAdminModalProps { + isOpen: boolean; + onClose: () => void; + organizationId: string; + /** user ids that are already admins — shown disabled */ + existingAdminIds: string[]; + onAssign: (userId: string) => void; + isAssigning: boolean; +} + +/** Promote an existing employee of the selected organization to org admin. */ +export default function AssignExistingAdminModal({ + isOpen, + onClose, + organizationId, + existingAdminIds, + onAssign, + isAssigning, +}: AssignExistingAdminModalProps) { + const { t } = useTranslation(); + const localizedName = useLocalizedName(); + const [selectedUserId, setSelectedUserId] = useState(""); + const [search, setSearch] = useState(""); + + const { employeesResponseByOrg, isLoadingEmployeesByOrg } = useEmployees({ + organizationId: isOpen ? organizationId : undefined, + params: { take: 3000, skip: 0 }, + }); + + const filteredEmployees = useMemo(() => { + const query = search.trim().toLowerCase(); + const employees = employeesResponseByOrg?.items ?? []; + if (!query) return employees; + return employees.filter((employee: any) => { + const name = localizedName(employee.user?.name).toLowerCase(); + const email = employee.user?.email?.toLowerCase() ?? ""; + return name.includes(query) || email.includes(query); + }); + }, [employeesResponseByOrg, localizedName, search]); + + const handleClose = () => { + if (isAssigning) return; + setSelectedUserId(""); + setSearch(""); + onClose(); + }; + + return ( + !open && handleClose()}> + + + {t("orgAdmins.assign.title")} + + {t("orgAdmins.assign.description")} + + + +
+ setSearch(event.target.value)} + placeholder={t("orgAdmins.assign.searchUsers")} + /> + {isLoadingEmployeesByOrg ? ( +
+ +
+ ) : ( + +
+ {filteredEmployees.map((employee: any) => { + const userId = employee.user?.id; + if (!userId) return null; + const isAlreadyAdmin = existingAdminIds.includes(userId); + return ( + + ); + })} + {filteredEmployees.length === 0 && ( +

+ {t("orgAdmins.assign.noUsersFound")} +

+ )} +
+
+ )} +
+ + + + + +
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsColumnDefn.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsColumnDefn.tsx new file mode 100644 index 000000000..97c3bfbc6 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsColumnDefn.tsx @@ -0,0 +1,228 @@ +import { ColumnDef } from "@tanstack/react-table"; +import { + ArrowUpDown, + MoreHorizontal, + Pencil, + Send, + Trash2, + UserCheck, + UserX, +} from "lucide-react"; +import { t } from "i18next"; +import { Button } from "@/shared/common/ui/button"; +import { Badge } from "@/shared/common/ui/badge"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/shared/common/ui/dropdown-menu"; +import { OrgAdminUser } from "@/super-admin/hooks/useOrgAdmins"; + +export const ORG_ADMIN_ROLE_KEY = "organization_admin"; +export const UNIT_ADMIN_ROLE_KEY = "unit_admin"; + +export interface AdminRoleInfo { + isOrgAdmin: boolean; + isUnitAdmin: boolean; + unitId?: string; +} + +/** + * all-admins/:id returns users who are org admins of the org OR unit admins of + * one of its units; userRoles carries every role of the user, so match the org + * explicitly for the org-admin grant. + */ +// ponytail: unit relation isn't loaded, so a unit_admin grant from another org +// can't be told apart — acceptable, the server only returns admins of this org. +export function getAdminRoleInfo( + admin: OrgAdminUser, + selectedOrgId: string, +): AdminRoleInfo { + const roles = admin.userRoles ?? []; + const isOrgAdmin = roles.some( + (r) => + r.role?.key === ORG_ADMIN_ROLE_KEY && + r.organizationId === selectedOrgId, + ); + const unitRole = roles.find( + (r) => r.role?.key === UNIT_ADMIN_ROLE_KEY && r.unitId, + ); + return { + isOrgAdmin, + isUnitAdmin: !!unitRole, + unitId: unitRole?.unitId ?? undefined, + }; +} + +interface ColumnCallbacks { + selectedOrgId: string; + localizedName: (name?: { am?: string; en?: string }) => string; + onEdit: (admin: OrgAdminUser) => void; + onResend: (admin: OrgAdminUser) => void; + onToggleActive: (admin: OrgAdminUser) => void; + onRemove: (admin: OrgAdminUser, roleInfo: AdminRoleInfo) => void; +} + +export function getOrgAdminsColumnDefn({ + selectedOrgId, + localizedName, + onEdit, + onResend, + onToggleActive, + onRemove, +}: ColumnCallbacks): ColumnDef[] { + return [ + { + id: "name", + accessorFn: (row) => localizedName(row.name), + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+

+ {localizedName(row.original.name) || "—"} +

+

+ {row.original.username} +

+
+ ), + }, + { + id: "email", + accessorFn: (row) => row.email ?? "", + header: () => t("orgAdmins.columns.email"), + cell: ({ row }) => ( + {row.original.email || "—"} + ), + }, + { + id: "phoneNumber", + accessorFn: (row) => row.phoneNumber ?? "", + header: () => t("orgAdmins.columns.phone"), + cell: ({ row }) => ( + {row.original.phoneNumber || "—"} + ), + }, + { + id: "role", + header: () => t("orgAdmins.columns.role"), + cell: ({ row }) => { + const info = getAdminRoleInfo(row.original, selectedOrgId); + return ( +
+ {info.isOrgAdmin && ( + + {t("orgAdmins.roleOrgAdmin")} + + )} + {info.isUnitAdmin && ( + + {t("orgAdmins.roleUnitAdmin")} + + )} +
+ ); + }, + }, + { + id: "status", + accessorFn: (row) => + !row.hasSetPassword + ? "invited" + : row.isActive + ? "active" + : "inactive", + header: () => t("orgAdmins.columns.status"), + cell: ({ row }) => { + const admin = row.original; + if (!admin.hasSetPassword) { + return ( + + {t("orgAdmins.statusInvited")} + + ); + } + return admin.isActive ? ( + + {t("orgAdmins.statusActive")} + + ) : ( + + {t("orgAdmins.statusInactive")} + + ); + }, + }, + { + id: "createdAt", + accessorFn: (row) => row.createdAt ?? "", + header: () => t("orgAdmins.columns.addedOn"), + cell: ({ row }) => + row.original.createdAt + ? new Date(row.original.createdAt).toLocaleDateString() + : "—", + }, + { + id: "actions", + header: () => t("orgAdmins.columns.actions"), + enableHiding: false, + cell: ({ row }) => { + const admin = row.original; + const roleInfo = getAdminRoleInfo(admin, selectedOrgId); + return ( + + + + + + onEdit(admin)}> + + {t("orgAdmins.actions.edit")} + + {!admin.hasSetPassword && ( + onResend(admin)}> + + {t("orgAdmins.actions.resend")} + + )} + onToggleActive(admin)}> + {admin.isActive ? ( + <> + + {t("orgAdmins.actions.deactivate")} + + ) : ( + <> + + {t("orgAdmins.actions.activate")} + + )} + + + onRemove(admin, roleInfo)} + > + + {t("orgAdmins.actions.remove")} + + + + ); + }, + }, + ]; +} diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsPage.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsPage.tsx new file mode 100644 index 000000000..1935d049c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsPage.tsx @@ -0,0 +1,364 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Building2, Plus, UserPlus, Users2 } from "lucide-react"; +import { Button } from "@/shared/common/ui/button"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/shared/common/ui/card"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/common/ui/alert-dialog"; +import { Badge } from "@/shared/common/ui/badge"; +import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable"; +import { useLocalizedName } from "@/shared/common/localizedName"; +import { OrganizationDto } from "@/shared/dto/organization/organizationDto"; +import { + OrgAdminUser, + useOrgAdmins, +} from "@/super-admin/hooks/useOrgAdmins"; +import { OrgPicker } from "./OrgPicker"; +import { + AdminRoleInfo, + getOrgAdminsColumnDefn, +} from "./OrgAdminsColumnDefn"; +import AdminFormModal, { AdminFormValues } from "./AdminFormModal"; +import AssignExistingAdminModal from "./AssignExistingAdminModal"; + +interface RemoveTarget { + admin: OrgAdminUser; + roleInfo: AdminRoleInfo; +} + +export default function OrgAdminsPage() { + const { t } = useTranslation(); + const localizedName = useLocalizedName(); + + const [selectedOrg, setSelectedOrg] = useState(null); + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(10); + + // modals & confirms + const [formOpen, setFormOpen] = useState(false); + const [editAdmin, setEditAdmin] = useState(null); + const [assignOpen, setAssignOpen] = useState(false); + const [removeTarget, setRemoveTarget] = useState(null); + const [toggleTarget, setToggleTarget] = useState(null); + + const { + adminsResponse, + isLoading, + refetch, + inviteAdmin, + isInviting, + assignAdmin, + isAssigning, + removeAdmin, + isRemoving, + resendInvite, + toggleActive, + isToggling, + updateAdminProfile, + isUpdatingProfile, + } = useOrgAdmins(selectedOrg?.id, { + take: pageSize, + skip: pageIndex * pageSize, + }); + + useEffect(() => { + setPageIndex(0); + }, [selectedOrg?.id, pageSize]); + + const admins = adminsResponse?.items ?? []; + const adminCount = adminsResponse?.count ?? 0; + const existingAdminIds = useMemo( + () => admins.map((admin) => admin.id), + [admins], + ); + + const handleFormSubmit = (values: AdminFormValues) => { + if (!selectedOrg) return; + if (editAdmin) { + updateAdminProfile( + { id: editAdmin.id, payload: values }, + { + onSuccess: () => { + setFormOpen(false); + setEditAdmin(null); + }, + }, + ); + } else { + inviteAdmin( + { organizationId: selectedOrg.id, ...values }, + { onSuccess: () => setFormOpen(false) }, + ); + } + }; + + const handleAssign = (userId: string) => { + if (!selectedOrg) return; + assignAdmin( + { organizationId: selectedOrg.id, userId }, + { onSuccess: () => setAssignOpen(false) }, + ); + }; + + const handleRemoveConfirm = () => { + if (!removeTarget || !selectedOrg) return; + const { admin, roleInfo } = removeTarget; + removeAdmin( + roleInfo.isOrgAdmin + ? { userId: admin.id, organizationId: selectedOrg.id } + : { userId: admin.id, unitId: roleInfo.unitId }, + { onSuccess: () => setRemoveTarget(null) }, + ); + }; + + const handleToggleConfirm = () => { + if (!toggleTarget) return; + toggleActive( + { id: toggleTarget.id, activate: !toggleTarget.isActive }, + { onSuccess: () => setToggleTarget(null) }, + ); + }; + + const columns = useMemo( + () => + getOrgAdminsColumnDefn({ + selectedOrgId: selectedOrg?.id ?? "", + localizedName: localizedName as (name?: { + am?: string; + en?: string; + }) => string, + onEdit: (admin) => { + setEditAdmin(admin); + setFormOpen(true); + }, + onResend: (admin) => + resendInvite({ + email: admin.email ?? "", + phoneNumber: admin.phoneNumber ?? "", + }), + onToggleActive: setToggleTarget, + onRemove: (admin, roleInfo) => setRemoveTarget({ admin, roleInfo }), + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [selectedOrg?.id], + ); + + return ( +
+ + + + {t("orgAdmins.title")} + +

+ {t("orgAdmins.subtitle")} +

+
+ + {/* Org selector + summary */} +
+ + {selectedOrg && ( +
+ + + {t("orgAdmins.adminsCount", { count: adminCount })} + + {selectedOrg.activeEmployeeCount !== undefined && ( + + {t("orgAdmins.activeEmployees", { + count: selectedOrg.activeEmployeeCount, + })} + + )} + + {selectedOrg.status} + +
+ )} +
+ + {!selectedOrg ? ( +
+ +

+ {t("orgAdmins.selectOrgPrompt")} +

+

+ {t("orgAdmins.selectOrgPromptHint")} +

+
+ ) : ( + <> + {!isLoading && adminCount === 0 && ( +
+ {t("orgAdmins.noAdminsHint", { + name: localizedName(selectedOrg.name), + })} +
+ )} + setPageIndex(pageIndex + 1)} + prevFunction={() => setPageIndex(Math.max(pageIndex - 1, 0))} + refresh={refetch} + isLoading={isLoading} + extraToolbar={ +
+ + +
+ } + /> + + )} +
+
+ + {/* Invite / Edit modal */} + { + setFormOpen(false); + setEditAdmin(null); + }} + admin={editAdmin} + onSubmit={handleFormSubmit} + isSubmitting={isInviting || isUpdatingProfile} + /> + + {/* Assign existing employee modal */} + {selectedOrg && ( + setAssignOpen(false)} + organizationId={selectedOrg.id} + existingAdminIds={existingAdminIds} + onAssign={handleAssign} + isAssigning={isAssigning} + /> + )} + + {/* Remove admin confirm */} + !open && setRemoveTarget(null)} + > + + + + {t("orgAdmins.confirmRemove.title")} + + + {t("orgAdmins.confirmRemove.description", { + name: + localizedName(removeTarget?.admin.name) || + removeTarget?.admin.email, + org: localizedName(selectedOrg?.name), + })} + + + + + {t("common.cancel")} + + + {isRemoving + ? t("orgAdmins.confirmRemove.removing") + : t("orgAdmins.actions.remove")} + + + + + + {/* Activate / Deactivate confirm */} + !open && setToggleTarget(null)} + > + + + + {toggleTarget?.isActive + ? t("orgAdmins.confirmToggle.deactivateTitle") + : t("orgAdmins.confirmToggle.activateTitle")} + + + {t("orgAdmins.confirmToggle.description", { + name: + localizedName(toggleTarget?.name) || toggleTarget?.email, + })} + + + + + {t("common.cancel")} + + + {toggleTarget?.isActive + ? t("orgAdmins.actions.deactivate") + : t("orgAdmins.actions.activate")} + + + + +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgPicker.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgPicker.tsx new file mode 100644 index 000000000..bff9172c7 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgPicker.tsx @@ -0,0 +1,142 @@ +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useDebouncedValue } from "@mantine/hooks"; +import { Building2, Check, ChevronsUpDown, Loader2 } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/shared/common/ui/button"; +import { Badge } from "@/shared/common/ui/badge"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/shared/common/ui/popover"; +import { + Command, + CommandEmpty, + CommandInput, + CommandItem, + CommandList, +} from "@/shared/common/ui/command"; +import { cn } from "@/super-admin/lib/utils"; +import { useLocalizedName } from "@/shared/common/localizedName"; +import { getOrganizationsWithAdminFlag } from "@/shared/services/organizationsService"; +import { OrganizationDto } from "@/shared/dto/organization/organizationDto"; +import { ORG_PICKER_KEY } from "@/super-admin/hooks/useOrgAdmins"; + +interface OrgPickerProps { + value: OrganizationDto | null; + onChange: (org: OrganizationDto) => void; +} + +/** Searchable organization combobox — server-side name search, shows admin counts. */ +export function OrgPicker({ value, onChange }: OrgPickerProps) { + const { t } = useTranslation(); + const localizedName = useLocalizedName(); + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const [debouncedSearch] = useDebouncedValue(search, 300); + + const { data: orgsResponse, isLoading } = useQuery({ + queryKey: [ORG_PICKER_KEY, debouncedSearch], + queryFn: async () => { + const { data } = await getOrganizationsWithAdminFlag({ + take: 50, + skip: 0, + name: debouncedSearch || undefined, + }); + return { + count: (data?.count ?? 0) as number, + items: (data?.items ?? []) as OrganizationDto[], + }; + }, + staleTime: 5 * 60 * 1000, + retry: false, + }); + + const orgs = orgsResponse?.items ?? []; + + return ( + + + + + + + + + {isLoading ? ( +
+ +
+ ) : ( + <> + {t("orgAdmins.noOrgsFound")} + {orgs.map((org) => ( + { + onChange(org); + setOpen(false); + }} + className="flex items-center justify-between gap-2" + > + + + + {localizedName(org.name)} + + + {(org.adminsCount ?? 0) > 0 ? ( + + {t("orgAdmins.adminsCount", { + count: org.adminsCount ?? 0, + })} + + ) : ( + + {t("orgAdmins.noAdmins")} + + )} + + ))} + + )} +
+
+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/AssignOrgAdminDialog.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/AssignOrgAdminDialog.tsx deleted file mode 100644 index 2ac9ef71d..000000000 --- a/apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/AssignOrgAdminDialog.tsx +++ /dev/null @@ -1,277 +0,0 @@ -import { FormEvent, useMemo, useState } from "react"; -import { useQueryClient } from "@tanstack/react-query"; -import { Check, Loader2 } from "lucide-react"; -import { useTranslation } from "react-i18next"; -import { toast } from "sonner"; - -import { Button } from "@/shared/common/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/shared/common/ui/dialog"; -import { Input } from "@/shared/common/ui/input"; -import { Label } from "@/shared/common/ui/label"; -import { ScrollArea } from "@/shared/common/ui/scroll-area"; -import { useLocalizedName } from "@/shared/common/localizedName"; -import { cn } from "@/super-admin/lib/utils"; -import { useOrganizations } from "@/super-admin/hooks/useOrganizations"; -import { - assignOrgAdminRole, - RemoveOrAssignOrgAdminPayload, -} from "@/super-admin/services/api/userRoleService"; -import { useEmployees } from "@/user-management/hooks/useEmployees"; - -interface AssignOrgAdminDialogProps { - isOpen: boolean; - onClose: () => void; - onSuccess: () => void; -} - -export function AssignOrgAdminDialog({ - isOpen, - onClose, - onSuccess, -}: AssignOrgAdminDialogProps) { - const queryClient = useQueryClient(); - const { t } = useTranslation(); - const localizedName = useLocalizedName(); - const [selectedOrg, setSelectedOrg] = useState(""); - const [selectedUser, setSelectedUser] = useState(""); - const [search, setSearch] = useState(""); - const [isAssigning, setIsAssigning] = useState(false); - - const { organizationsResponse, isLoading: isLoadingOrgs } = useOrganizations( - "Org", - { take: 300 } - ); - - const { - employeesResponseByOrg, - isLoadingEmployeesByOrg: isLoadingEmployees, - } = useEmployees({ - organizationId: selectedOrg || undefined, - params: { take: 3000, skip: 0 }, - }); - - const filteredEmployees = useMemo(() => { - const query = search.trim().toLowerCase(); - const employees = employeesResponseByOrg?.items ?? []; - - if (!query) return employees; - - return employees.filter((employee) => { - const name = localizedName(employee.user.name).toLowerCase(); - const email = employee.user.email?.toLowerCase() ?? ""; - - return name.includes(query) || email.includes(query); - }); - }, [employeesResponseByOrg, localizedName, search]); - - const resetForm = () => { - setSelectedOrg(""); - setSelectedUser(""); - setSearch(""); - }; - - const handleClose = () => { - if (isAssigning) return; - resetForm(); - onClose(); - }; - - const handleOrganizationChange = (organizationId: string) => { - setSelectedOrg(organizationId); - setSelectedUser(""); - setSearch(""); - }; - - const handleSubmit = async (event: FormEvent) => { - event.preventDefault(); - - if (!selectedOrg || !selectedUser) { - toast.error(t("organization.allFieldsRequired", "All fields are required")); - return; - } - - const payload: RemoveOrAssignOrgAdminPayload = { - organizationId: selectedOrg, - userId: selectedUser, - }; - - setIsAssigning(true); - - try { - await assignOrgAdminRole(payload); - await queryClient.invalidateQueries({ - queryKey: ["organizationAdmins"], - }); - resetForm(); - onClose(); - onSuccess(); - } catch (error: any) { - toast.error(t("organization.userAssignFailed"), { - description: error?.response?.data?.message, - }); - } finally { - setIsAssigning(false); - } - }; - - return ( - { - if (!open) handleClose(); - }} - > - - - - {t( - "organization.assignAdminToOrganization", - "Assign admin to organization" - )} - - - {t( - "organization.assignOrgAdminInstructions", - "Select an organization and a user to assign as its administrator." - )} - - - -
-
-
- - {isLoadingOrgs ? ( -
- -
- ) : ( - -
- {organizationsResponse?.items?.map((organization) => ( - - ))} -
-
- )} -
- -
- - {!selectedOrg ? ( -
-

- {t("organization.selectOrganizationFirst")} -

-
- ) : isLoadingEmployees ? ( -
- -
- ) : ( -
- setSearch(event.target.value)} - placeholder={t( - "organization.searchOrganizationUsers", - "Search organization users" - )} - /> - -
- {filteredEmployees.map((employee) => ( - - ))} - {filteredEmployees.length === 0 && ( -

- {t("organization.noUsersFound")} -

- )} -
-
-
- )} -
-
- - - - - -
-
-
- ); -} diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/OrganizationAdmins.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/OrganizationAdmins.tsx deleted file mode 100644 index 81fcaff80..000000000 --- a/apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/OrganizationAdmins.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import { useState } from "react"; -import { Button } from "@/shared/common/ui/button"; -import { Link } from "react-router-dom"; -import { Plus, Loader2, UserPlus } from "lucide-react"; -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from "../../../shared/common/ui/card"; -import { toast } from "sonner"; -import { AdvancedTable } from "../../../shared/common/ui/table/AdvancedTable"; -import { OrganizationAdminsColumnDefn } from "./OrganizationAdminsColumnDefn"; -import { useOrganizations } from "@/super-admin/hooks/useOrganizations"; -import { AssignOrgAdminDialog } from "./AssignOrgAdminDialog"; -import { useTranslation } from "react-i18next"; -import { useLocalizedName } from "@/shared/common/localizedName"; - -export default function OrganizationAdmins() { - const [pageIndex, setPageIndex] = useState(0); // starts at 0 - const pageSize = 10; - const { t } = useTranslation(); - const localizedName = useLocalizedName(); - const [isAssignDialogOpen, setIsAssignDialogOpen] = useState(false); - const [searchTerm, setSearchTerm] = useState(""); - const { organizationsAdminsResponse, isLoading, isError, refetch } = - useOrganizations("Admin", { - take: pageSize, - skip: pageIndex * pageSize, - orderBy: "createdAt", - order: "createdAt:Desc", - name: searchTerm || undefined, - }); - - const handlePageChange = (newPage: number) => { - setPageIndex(newPage); - }; - - const handleSearchChange = (term: string) => { - setSearchTerm(term); - setPageIndex(0); // Reset to first page when search term changes - }; - - const handleRefresh = () => { - refetch(); - }; - - if (isLoading) { - return ( -
-
- -
- {t("organization.loadingAdmins")} -
-
-
- ); - } - - if (isError) { - return ( -
-
-
- {t("organization.errorLoadingAdmins")} -
- -
-
- ); - } - - return ( - <> -
- - - - {t("organization.organizationAdmins")} - - - - string - )} - data={organizationsAdminsResponse?.items || []} - tableName="Organization Admins" - toolBarPosition="right" - refresh={handleRefresh} - onGlobalFilterChange={handleSearchChange} - disableClientFiltering={true} - extraToolbar={ -
- - - - -
- } - itemCount={organizationsAdminsResponse?.count || 0} - pageIndex={pageIndex} - onPageChange={handlePageChange} - nextFunction={() => handlePageChange(pageIndex + 1)} - prevFunction={() => handlePageChange(Math.max(pageIndex - 1, 0))} - /> -
-
-
- - {/* Assign Admin Dialog */} - setIsAssignDialogOpen(false)} - onSuccess={() => { - refetch(); - toast.success(t("organization.adminAssignedSuccess")); - }} - /> - - ); -} diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/OrganizationAdminsActions.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/OrganizationAdminsActions.tsx deleted file mode 100644 index 812b0cb29..000000000 --- a/apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/OrganizationAdminsActions.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import React from "react"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "../../../shared/common/ui/dropdown-menu"; -import { Edit, MoreHorizontal, Trash, UserCheck, UserX } from "lucide-react"; -import { Button } from "../../../shared/common/ui/button"; -import { Link } from "react-router-dom"; -import { OrganizationAdmin } from "@/super-admin/services/api/organizationAdminService"; -import { toast } from "sonner"; - -interface OrganizationAdminsActionsProps { - rowData: OrganizationAdmin; -} -const OrganizationAdminsActions: React.FC = ({ - rowData, -}) => { - const handleDeleteClick = () => { - toast.warning("Delete functionality not implemented yet"); - }; - - const handleStatusChange = (status: string) => { - toast.info(`Admin status change to ${status} not implemented yet`); - }; - - return ( - - - - - - Actions - - - - - Edit - - - {rowData.status !== "active" && ( - handleStatusChange("active")} - className="flex items-center" - > - - Activate - - )} - {rowData.status === "active" && ( - handleStatusChange("inactive")} - className="flex items-center" - > - - Deactivate - - )} - handleDeleteClick()} - className="flex items-center" - > - - Delete - - - - ); -}; - -export default OrganizationAdminsActions; diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/OrganizationAdminsColumnDefn.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/OrganizationAdminsColumnDefn.tsx deleted file mode 100644 index 4a1e4d33f..000000000 --- a/apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/OrganizationAdminsColumnDefn.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { ColumnDef } from "@tanstack/react-table"; -import { Button } from "../../../shared/common/ui/button"; -import { ArrowUpDown, Eye } from "lucide-react"; -import { OrganizationAdminsDto } from "@/shared/dto/organization/organizationDto"; -import { StatusCell } from "./StatusCell"; -import { t } from "i18next"; -import { useLocalizedName } from "@/shared/common/localizedName"; -import { Link } from "react-router-dom"; -import OrganizationAdminsActions from "./OrganizationAdminsActions"; - -export const OrganizationAdminsColumnDefn = ( - localizedName: (name?: { am?: string; en?: string }) => string, -): ColumnDef[] => { - return [ - { - accessorKey: "name", - header: ({ column }) => { - return ( - - ); - }, - cell: ({ row }) => ( -
{localizedName(row.original.name)}
- ), - }, - { - accessorKey: "status", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const id = row.original.id; - return ( - - ); - }, - }, - { - accessorKey: "actions", - header: t("organization.actions") || "Actions", - cell: ({ row }) => ( -
- - - - -
- ), - }, - ]; -}; diff --git a/apps/edr-freight-web/backoffice/src/super-admin/hooks/useOrgAdmins.ts b/apps/edr-freight-web/backoffice/src/super-admin/hooks/useOrgAdmins.ts new file mode 100644 index 000000000..6c50a8907 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/super-admin/hooks/useOrgAdmins.ts @@ -0,0 +1,199 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { useTranslation } from "react-i18next"; +import { useErrorHandler } from "@/shared/hooks/useErrorHandler"; +import { + assignOrganizationAdmin, + fetchAllUnitAdminById, + OrganizationAdminPayload, +} from "@/super-admin/services/api/organizationAdminService"; +import { + assignOrgAdminRole, + removeOrgAdminRole, + removeUnitAdminRole, +} from "@/super-admin/services/api/userRoleService"; +import { + activateUser, + deactivateUser, +} from "@/super-admin/services/api/userService"; +import { resendVerificationCode } from "@/shared/services/authService"; +import { + updateProfile, + UpdateProfilePayload, +} from "@/user-management/services/api/employeePositionsService"; + +export interface OrgAdminUserRole { + id: string; + organizationId?: string | null; + unitId?: string | null; + role?: { key?: string } | null; +} + +/** User row returned by GET /organizations/all-admins/:id (userRoles.role relation included). */ +export interface OrgAdminUser { + id: string; + name?: { am?: string; en?: string }; + username?: string; + email?: string; + phoneNumber?: string; + isActive: boolean; + hasSetPassword: boolean; + status?: string; + createdAt?: string; + userRoles?: OrgAdminUserRole[]; +} + +export const ORG_ADMINS_KEY = "orgAdmins"; +export const ORG_PICKER_KEY = "orgAdminsOrgPicker"; + +export interface RemoveAdminInput { + userId: string; + /** set for org-admin removal */ + organizationId?: string; + /** set for unit-admin removal (wins over organizationId) */ + unitId?: string; +} + +export const useOrgAdmins = ( + orgId?: string, + params?: { take?: number; skip?: number }, +) => { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + const { handleError } = useErrorHandler(t); + + const { + data: adminsResponse, + isLoading, + isError, + refetch, + } = useQuery({ + queryKey: [ORG_ADMINS_KEY, orgId, params], + queryFn: async () => { + const { data } = await fetchAllUnitAdminById(orgId as string, params); + return { + count: (data?.count ?? 0) as number, + items: (data?.items ?? []) as OrgAdminUser[], + }; + }, + enabled: !!orgId, + staleTime: 5 * 60 * 1000, + retry: false, + }); + + const invalidate = () => { + queryClient.invalidateQueries({ queryKey: [ORG_ADMINS_KEY] }); + // picker + org tables show adminsCount — keep them fresh + queryClient.invalidateQueries({ queryKey: [ORG_PICKER_KEY] }); + queryClient.invalidateQueries({ queryKey: ["organizations"] }); + queryClient.invalidateQueries({ queryKey: ["organizationAdmins"] }); + }; + + const { mutate: inviteAdmin, isPending: isInviting } = useMutation({ + mutationFn: async (payload: OrganizationAdminPayload) => { + const { data } = await assignOrganizationAdmin(payload); + return data; + }, + onSuccess: () => { + toast.success(t("orgAdmins.toasts.invited")); + invalidate(); + }, + onError: handleError, + }); + + const { mutate: assignAdmin, isPending: isAssigning } = useMutation({ + mutationFn: async (payload: { organizationId: string; userId: string }) => { + const { data } = await assignOrgAdminRole(payload); + return data; + }, + onSuccess: () => { + toast.success(t("orgAdmins.toasts.assigned")); + invalidate(); + }, + onError: handleError, + }); + + const { mutate: removeAdmin, isPending: isRemoving } = useMutation({ + mutationFn: async ({ userId, organizationId, unitId }: RemoveAdminInput) => { + const { data } = unitId + ? await removeUnitAdminRole({ unitId, userId }) + : await removeOrgAdminRole({ + organizationId: organizationId as string, + userId, + }); + return data; + }, + onSuccess: () => { + toast.success(t("orgAdmins.toasts.removed")); + invalidate(); + }, + onError: handleError, + }); + + const { mutate: resendInvite, isPending: isResending } = useMutation({ + mutationFn: async (payload: { email: string; phoneNumber: string }) => { + const { data } = await resendVerificationCode(payload); + return data; + }, + onSuccess: () => { + toast.success(t("orgAdmins.toasts.resent")); + }, + onError: handleError, + }); + + const { mutate: toggleActive, isPending: isToggling } = useMutation({ + mutationFn: async ({ id, activate }: { id: string; activate: boolean }) => { + const { data } = activate + ? await activateUser(id) + : await deactivateUser(id); + return data; + }, + onSuccess: (_data, variables) => { + toast.success( + variables.activate + ? t("orgAdmins.toasts.activated") + : t("orgAdmins.toasts.deactivated"), + ); + invalidate(); + }, + onError: handleError, + }); + + const { mutate: updateAdminProfile, isPending: isUpdatingProfile } = + useMutation({ + mutationFn: async ({ + id, + payload, + }: { + id: string; + payload: UpdateProfilePayload; + }) => { + const { data } = await updateProfile(payload, id); + return data; + }, + onSuccess: () => { + toast.success(t("orgAdmins.toasts.profileUpdated")); + invalidate(); + }, + onError: handleError, + }); + + return { + adminsResponse, + isLoading, + isError, + refetch, + inviteAdmin, + isInviting, + assignAdmin, + isAssigning, + removeAdmin, + isRemoving, + resendInvite, + isResending, + toggleActive, + isToggling, + updateAdminProfile, + isUpdatingProfile, + }; +}; diff --git a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/CreatePositionForm.tsx b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/CreatePositionForm.tsx index 8c06bf50e..d525aa4f8 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/CreatePositionForm.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/CreatePositionForm.tsx @@ -280,7 +280,7 @@ export const CreatePositionForm = ({ navigate("/user-management/position-management"); } } catch { - toast.error(t("contentManagement.permissionSuccess")); + toast.error(t("contentManagement.permissionFailed")); } }; From 13609f8d59c5defd0feb75a959e886f295f56bb8 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 23 Jul 2026 11:06:10 +0000 Subject: [PATCH 02/16] changes --- ...820000000000-CustomsClearanceRouteScope.ts | 67 +++++ ...2830000000000-ReturnSurchargeRouteScope.ts | 64 +++++ .../bookings/booking-pricing.service.ts | 24 +- .../src/modules/bookings/bookings.service.ts | 8 + .../contracts/contract-pricing.service.ts | 72 ++++- .../contracts/contract-transition.service.ts | 28 +- .../modules/contracts/contracts.controller.ts | 37 ++- .../dto/list-rule-engine-query.dto.ts | 16 ++ .../repositories/rates.repository.ts | 15 + .../rule-engine/rule-engine.service.spec.ts | 188 ++++++++++++ .../rule-engine/rule-engine.service.ts | 175 +++++++++++- .../rule-engine/services/rates.service.ts | 66 ++++- .../src/seed/freight-permissions.registry.ts | 51 +++- .../contracts/ContractActionsToolbar.tsx | 80 ++++-- .../backoffice/src/lib/permissions.ts | 16 +- .../ruleEngine/RuleEngineResourcePage.tsx | 39 ++- .../src/pages/ruleEngine/config/resources.ts | 88 +++++- .../services/ruleEngine/ruleEngine.service.ts | 5 + .../e2e/flows/bulk_import_window_reopen.cy.ts | 9 +- .../cypress/e2e/flows/general_drawdown.cy.ts | 140 +++++++++ .../e2e/flows/general_mixed_reopen.cy.ts | 136 +++++++++ .../cypress/e2e/flows/general_rush_hour.cy.ts | 179 ++++++++++++ .../e2e/flows/general_two_trains.cy.ts | 269 ++++++++++++++++++ e2e/freight/cypress/e2e/flows/import-utils.ts | 47 ++- .../flows/mixed_import_reopen_matrix.cy.ts | 7 +- e2e/freight/cypress/fixtures/seed-company.sql | 6 +- 26 files changed, 1717 insertions(+), 115 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2820000000000-CustomsClearanceRouteScope.ts create mode 100644 apps/edr-freight-api/src/migrations/2830000000000-ReturnSurchargeRouteScope.ts create mode 100644 e2e/freight/cypress/e2e/flows/general_drawdown.cy.ts create mode 100644 e2e/freight/cypress/e2e/flows/general_mixed_reopen.cy.ts create mode 100644 e2e/freight/cypress/e2e/flows/general_rush_hour.cy.ts create mode 100644 e2e/freight/cypress/e2e/flows/general_two_trains.cy.ts diff --git a/apps/edr-freight-api/src/migrations/2820000000000-CustomsClearanceRouteScope.ts b/apps/edr-freight-api/src/migrations/2820000000000-CustomsClearanceRouteScope.ts new file mode 100644 index 000000000..17902ff0d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2820000000000-CustomsClearanceRouteScope.ts @@ -0,0 +1,67 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Scope the customs clearance service fee to a direction + route. + * + * The fee was a single global flat rate; the business sells it per lane — + * "import clearance, Djibouti → Adama, 300 USD". CUSTOMS_CLEARANCE rates now + * carry trade_direction + the yard pair, and contract pricing matches on them + * strictly (no route-less fallback). + * + * Existing route-less clearance rates cannot be backfilled (no way to know + * which lane each was meant for) — retired exactly like the base-freight + * retirement in AddRateYardScope: SUPERSEDED + soft-deleted, kept for + * snapshot history. + */ +export class CustomsClearanceRouteScope2820000000000 implements MigrationInterface { + name = 'CustomsClearanceRouteScope2820000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.rates + SET status = 'SUPERSEDED', + deleted_at = now(), + updated_at = now() + WHERE deleted_at IS NULL + AND rate_type = 'CUSTOMS_CLEARANCE' + AND (origin_yard_id IS NULL OR destination_yard_id IS NULL); + `); + + await queryRunner.query( + `ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";`, + ); + await queryRunner.query(` + ALTER TABLE freight.rates + ADD CONSTRAINT "CK_rates_yard_scope" CHECK ( + deleted_at IS NOT NULL + OR status = 'SUPERSEDED' + OR CASE + WHEN ("trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY')) + OR "trigger" = 'CUSTOMS_CLEARANCE' + THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL + ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL + END + ); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Retired rates stay retired (their lanes were never recorded); down only + // restores the pre-customs constraint shape. + await queryRunner.query( + `ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";`, + ); + await queryRunner.query(` + ALTER TABLE freight.rates + ADD CONSTRAINT "CK_rates_yard_scope" CHECK ( + deleted_at IS NOT NULL + OR status = 'SUPERSEDED' + OR CASE + WHEN "trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY') + THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL + ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL + END + ); + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2830000000000-ReturnSurchargeRouteScope.ts b/apps/edr-freight-api/src/migrations/2830000000000-ReturnSurchargeRouteScope.ts new file mode 100644 index 000000000..26c512cfc --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2830000000000-ReturnSurchargeRouteScope.ts @@ -0,0 +1,64 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Scope the empty-container return surcharge to a direction + route + + * container type, like base freight (import-only for now — the box only goes + * back to the port on imports). + * + * Existing route-less RETURN_SURCHARGE rates cannot be backfilled — retired + * (SUPERSEDED + soft-deleted) exactly like base freight and customs clearance + * were, kept readable for snapshot history. Route-scoped replacements must be + * re-entered; a booking that asks for return with no matching rate hard-blocks. + */ +export class ReturnSurchargeRouteScope2830000000000 implements MigrationInterface { + name = 'ReturnSurchargeRouteScope2830000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.rates + SET status = 'SUPERSEDED', + deleted_at = now(), + updated_at = now() + WHERE deleted_at IS NULL + AND rate_type = 'RETURN_SURCHARGE' + AND (origin_yard_id IS NULL OR destination_yard_id IS NULL); + `); + + await queryRunner.query( + `ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";`, + ); + await queryRunner.query(` + ALTER TABLE freight.rates + ADD CONSTRAINT "CK_rates_yard_scope" CHECK ( + deleted_at IS NOT NULL + OR status = 'SUPERSEDED' + OR CASE + WHEN ("trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY')) + OR "trigger" IN ('CUSTOMS_CLEARANCE', 'WITH_RETURN') + THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL + ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL + END + ); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Retired rates stay retired; down only restores the customs-era shape. + await queryRunner.query( + `ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";`, + ); + await queryRunner.query(` + ALTER TABLE freight.rates + ADD CONSTRAINT "CK_rates_yard_scope" CHECK ( + deleted_at IS NOT NULL + OR status = 'SUPERSEDED' + OR CASE + WHEN ("trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY')) + OR "trigger" = 'CUSTOMS_CLEARANCE' + THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL + ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL + END + ); + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index 32aa1a880..e233a4fe6 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -165,8 +165,16 @@ export class BookingPricingService { const usdAmount = mod.calculatedAmount; const rate = rateById.get(mod.rateId); - const unit = rate?.rateUnit ?? 'FLAT'; - const unitUsd = rate ? Number(rate.rateValue) : usdAmount; + // Derived/route-matched charges (import overweight, empty-container + // return) carry their own unit price + billing unit — bill and display + // those, not whatever the referenced rate row says. + const isDerived = mod.unitPriceUsd != null; + const unit = mod.billingUnit ?? rate?.rateUnit ?? 'FLAT'; + const unitUsd = isDerived + ? Number(mod.unitPriceUsd) + : rate + ? Number(rate.rateValue) + : usdAmount; // Per-unit count: FLAT and PER_INVOICE are billed once (qty 1); an // explicit trigger (e.g. overweight tons) wins when present; otherwise // derive from total ÷ unit price (the live unit price — a count, not a @@ -182,11 +190,11 @@ export class BookingPricingService { // H15: bill the frozen contract surcharge rate (already in the booking // currency) when this code has a snapshot; else keep the live amount. - const frozen = this.frozenRateByCode( - frozenRates, - mod.surchargeCode, - paymentCurrency, - ); + // Derived charges skip the snapshot — import overweight prices off the + // route's container freight, never a frozen OVERWEIGHT_PER_TON value. + const frozen = isDerived + ? null + : this.frozenRateByCode(frozenRates, mod.surchargeCode, paymentCurrency); const unitAmount = frozen ? Number(frozen.unitPrice) : isEtbBooking @@ -369,6 +377,8 @@ export class BookingPricingService { isGovernment: booking.isGovernment, allowConsolidation, shippingLineId: booking.shippingLineId, + originYardId: booking.originYardId, + destinationYardId: booking.destinationYardId, totalWagons, // Bulk tonnage scales PER_TON surcharges (e.g. the bulk reefer surcharge). // Container freight carries 0 here — its surcharges scale by container count. diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index e95cd65c9..da7779026 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -422,6 +422,8 @@ export class BookingsService { isReefer?: boolean; isGovernment?: boolean; shippingLineId?: string | null; + originYardId?: string | null; + destinationYardId?: string | null; bulkTons?: number; containers: CreateBookingContainerDto[]; }): Promise { @@ -467,6 +469,8 @@ export class BookingsService { isGovernment: dto.isGovernment ?? false, allowConsolidation, shippingLineId: dto.shippingLineId, + originYardId: dto.originYardId ?? null, + destinationYardId: dto.destinationYardId ?? null, totalWagons, bulkTons: dto.freightType === 'BULK' ? Number(dto.bulkTons ?? 0) : 0, containers, @@ -788,6 +792,8 @@ export class BookingsService { isReefer: dto.isReefer, isGovernment, shippingLineId: dto.shippingLineId, + originYardId: dto.originYardId, + destinationYardId: dto.destinationYardId, bulkTons: dto.cargoTotalWeightVgm, containers, }); @@ -998,6 +1004,8 @@ export class BookingsService { isHazardous: dto.isHazardous ?? existing.isHazardous, isReefer: dto.isReefer ?? existing.isReefer, shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined, + originYardId: dto.originYardId ?? existing.originYardId, + destinationYardId: dto.destinationYardId ?? existing.destinationYardId, bulkTons: dto.cargoTotalWeightVgm ?? Number(existing.cargoTotalWeightVgm ?? 0), containers, }); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts index 235e1051f..d40d5dd6f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts @@ -194,17 +194,49 @@ export class ContractPricingService { contract.freightType === 'CONTAINER' && contract.equipmentReturn === 'WITH_RETURN' ) { - const withReturn = liveRates.find( - (r) => r.rateType === 'RETURN_SURCHARGE' && r.currency === 'USD', - ); - if (withReturn && Number(withReturn.rateValue) > 0) { - lineItems.push({ - code: 'RETURN_SURCHARGE', - label: 'Empty container return', - unit: toContractUnit(withReturn.rateUnit), - unitPrice: convert(Number(withReturn.rateValue)), - conditionalOn: 'with_return', + // Return is sold per direction + route + container type (import-only) — + // one display line per contract size that has a configured rate. A size + // with no rate shows nothing here and hard-blocks at booking time. + // ponytail: bookings bill the live route rate, not a frozen snapshot. + const route = [...(contract.routes ?? [])].sort( + (a, b) => a.sortOrder - b.sortOrder, + )[0]; + const onLeg = route + ? liveRates.filter( + (r) => + r.rateType === 'RETURN_SURCHARGE' && + r.currency === 'USD' && + r.tradeDirection === contract.tradeDirection && + r.originYardId === route.originYardId && + r.destinationYardId === route.destinationYardId, + ) + : []; + if (onLeg.length > 0) { + const sizes = (contract.cargoScope ?? []) + .map((c) => c.containerSize) + .filter((s): s is string => !!s); + const { items: containerTypes } = await this.containerTypesService.findAll({ + isActive: true, + pageSize: 100, }); + for (const size of sizes) { + const sizeFt = size === '40ft' ? 40 : 20; + const matchedIds = new Set( + containerTypes.filter((ct) => ct.sizeFt === sizeFt).map((ct) => ct.id), + ); + const rate = + onLeg.find((r) => r.containerTypeId && matchedIds.has(r.containerTypeId)) ?? + onLeg.find((r) => !r.containerTypeId); + if (!rate || Number(rate.rateValue) <= 0) continue; + lineItems.push({ + code: 'RETURN_SURCHARGE', + label: `Empty container return (${size})`, + unit: toContractUnit(rate.rateUnit), + unitPrice: convert(Number(rate.rateValue)), + containerSize: size, + conditionalOn: 'with_return', + }); + } } } @@ -213,12 +245,24 @@ export class ContractPricingService { // ONE_TIME, per shipment request for GENERAL. Excluded from booking totals. // A customs contract may not proceed without a configured live rate. if (contract.customsClearingEnabled) { - const clearance = liveRates.find( - (r) => r.rateType === 'CUSTOMS_CLEARANCE' && r.currency === 'USD', - ); + // The fee is sold per direction + route — strict, no route-less fallback. + // ponytail: multi-route contracts bill the first lane's fee; per-lane fees need per-route snapshots. + const route = [...(contract.routes ?? [])].sort( + (a, b) => a.sortOrder - b.sortOrder, + )[0]; + const clearance = route + ? liveRates.find( + (r) => + r.rateType === 'CUSTOMS_CLEARANCE' && + r.currency === 'USD' && + r.tradeDirection === contract.tradeDirection && + r.originYardId === route.originYardId && + r.destinationYardId === route.destinationYardId, + ) + : undefined; if (!clearance || Number(clearance.rateValue) <= 0) { throw new UnprocessableEntityException( - 'No customs clearance service fee is configured. Ask the rates team to set a live CUSTOMS_CLEARANCE rate before submitting customs contracts.', + 'No customs clearance service fee is configured for this direction and route. Ask the rates team to set a live CUSTOMS_CLEARANCE rate for this origin → destination.', ); } lineItems.push({ diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index e2e433c70..a8e2f5b62 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -20,8 +20,13 @@ import { MinioService } from '../minio/minio.service'; import { FileRecord } from '../files/entities/file.entity'; import { assertCanApproveContractStep, + assertFreightPermission, canEditContractStep, } from '../../common/freight-permission.util'; +import { + FREIGHT_PERMS, + forFreightType, +} from '../../seed/freight-permissions.registry'; import { ContractDocumentHistoryService } from './contract-document-history.service'; import { ApprovalRulesService } from '../rule-engine/services/approval-rules.service'; import { CargoTypesService } from '../rule-engine/services/cargo-types.service'; @@ -239,8 +244,15 @@ export class ContractTransitionService { actorId: string, validityDays: number, documentSnapshot?: ContractDocumentSnapshotInput | null, + user?: TCurrentUser | null, ): Promise { const contract = await this.contractsService.findById(contractId); + // The route guard passes on either arm; the contract's freight type decides + // which one is actually required (accept bulk ≠ accept container). + assertFreightPermission( + user, + forFreightType(FREIGHT_PERMS.contracts.staffAccept, contract.freightType), + ); assertContractStatus(contract, ['SUBMITTED']); if (!Number.isInteger(validityDays) || validityDays < 1) { @@ -535,8 +547,13 @@ export class ContractTransitionService { contractId: string, note: string, actorId: string, + user?: TCurrentUser | null, ): Promise { const contract = await this.contractsService.findById(contractId); + assertFreightPermission( + user, + forFreightType(FREIGHT_PERMS.contracts.requestChanges, contract.freightType), + ); assertContractStatus(contract, ['SUBMITTED']); await this.contractsRepository.createReviewNote( @@ -554,8 +571,17 @@ export class ContractTransitionService { return updated; } - async reject(contractId: string, reason: string, actorId: string): Promise { + async reject( + contractId: string, + reason: string, + actorId: string, + user?: TCurrentUser | null, + ): Promise { const contract = await this.contractsService.findById(contractId); + assertFreightPermission( + user, + forFreightType(FREIGHT_PERMS.contracts.reject, contract.freightType), + ); assertContractStatus(contract, ['SUBMITTED', 'PENDING_APPROVAL']); await this.contractsRepository.createReviewNote( diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 266a00044..7e4c10d6f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -34,7 +34,11 @@ import { import { actorLabel } from '../warehouses/current-actor.util'; import { BookingStaff } from '../../common/booking-guards'; import { ContractDocumentHistoryService } from './contract-document-history.service'; -import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { + FREIGHT_PERMS, + bothFreightTypes, + forFreightType, +} from '../../seed/freight-permissions.registry'; import { assertFreightPermission, hasFreightPermission, @@ -184,7 +188,10 @@ export class ContractsController { @CurrentUser() user: TCurrentUser, ) { if (dto.isGovernment) { - assertFreightPermission(user, FREIGHT_PERMS.contracts.staffAccept); + assertFreightPermission( + user, + forFreightType(FREIGHT_PERMS.contracts.staffAccept, dto.freightType), + ); } return this.contractsService.create(dto, files ?? [], user?.id); } @@ -337,23 +344,25 @@ export class ContractsController { } @Post(':id/staff/accept') - @BookingStaff(FREIGHT_PERMS.contracts.staffAccept) + // One-of guard; the service then requires the arm matching the contract's freight type. + @BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept)) @ApiOperation({ summary: 'Staff accept → set validity window + start approval chain' }) staffAccept( @Param('id', ParseUUIDPipe) id: string, @Body() dto: AcceptContractDto, - @CurrentUser() user: AuthUserPayload, + @CurrentUser() user: TCurrentUser, ) { return this.transitionService.staffAccept( id, resolveAuthUserId(user), dto.validityDays, dto.documentSnapshot, + user, ); } @Get(':id/document/draft') - @BookingStaff(FREIGHT_PERMS.contracts.staffAccept) + @BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept)) @ApiOperation({ summary: 'Editable contract-document draft (this contract\'s snapshot, or the live template) for the accept/edit dialog', @@ -377,7 +386,7 @@ export class ContractsController { } @Put(':id/document/articles') - @BookingStaff(FREIGHT_PERMS.contracts.staffAccept) + @BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept)) @ApiOperation({ summary: 'Edit this contract\'s document articles only (per-contract; never touches the six shared templates)', @@ -396,29 +405,35 @@ export class ContractsController { } @Post(':id/staff/request-changes') - @BookingStaff(FREIGHT_PERMS.contracts.requestChanges) + @BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.requestChanges)) @ApiOperation({ summary: 'Staff return contract for customer updates' }) requestChanges( @Param('id', ParseUUIDPipe) id: string, @Body() dto: RequestChangesDto, - @CurrentUser() user: AuthUserPayload, + @CurrentUser() user: TCurrentUser, ) { return this.transitionService.requestChanges( id, dto.note, resolveAuthUserId(user), + user, ); } @Post(':id/staff/reject') - @BookingStaff(FREIGHT_PERMS.contracts.reject) + @BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.reject)) @ApiOperation({ summary: 'Staff reject contract' }) reject( @Param('id', ParseUUIDPipe) id: string, @Body() dto: RejectContractDto, - @CurrentUser() user: AuthUserPayload, + @CurrentUser() user: TCurrentUser, ) { - return this.transitionService.reject(id, dto.reason, resolveAuthUserId(user)); + return this.transitionService.reject( + id, + dto.reason, + resolveAuthUserId(user), + user, + ); } @Post(':id/approval-steps/:stepId/approve') diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/list-rule-engine-query.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/list-rule-engine-query.dto.ts index 30241ddaa..d22bd84a7 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/list-rule-engine-query.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/list-rule-engine-query.dto.ts @@ -125,6 +125,22 @@ export class ListRatesQueryDto extends PaginationQueryDto { @IsString() @MaxLength(50) rateType?: string; + + @ApiPropertyOptional({ + description: 'Filter by rate category — comma-separated appliesTo values (e.g. "CONTAINER" or "FIRST_MILE,LAST_MILE").', + }) + @IsOptional() + @IsString() + @MaxLength(100) + appliesTo?: string; + + @ApiPropertyOptional({ + description: 'Filter by surcharge trigger — comma-separated trigger values (e.g. "CUSTOMS_CLEARANCE" or "HAZARDOUS,REEFER").', + }) + @IsOptional() + @IsString() + @MaxLength(200) + trigger?: string; } export class ListWeightLimitRulesQueryDto extends PaginationQueryDto { diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts index 48a948784..bdec72e46 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts @@ -117,6 +117,21 @@ export class RatesRepository implements IRatesRepository { if (query.rateType) { qb.andWhere('rate.rateType = :rateType', { rateType: query.rateType }); } + // Category tabs on the admin page: comma-separated appliesTo / trigger + // lists, ANDed together (e.g. appliesTo=OTHER + trigger=CUSTOMS_CLEARANCE). + const csv = (v?: string) => + (v ?? '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + const appliesTo = csv(query.appliesTo); + if (appliesTo.length > 0) { + qb.andWhere('rate.appliesTo IN (:...appliesTo)', { appliesTo }); + } + const triggers = csv(query.trigger); + if (triggers.length > 0) { + qb.andWhere('rate.trigger IN (:...triggers)', { triggers }); + } if (query.search) { qb.andWhere( '(rate.rateType ILIKE :search OR rate.status ILIKE :search OR rate.rateUnit ILIKE :search OR rate.currency ILIKE :search)', diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts index 85c7db4a0..8a5ee8e31 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts @@ -76,3 +76,191 @@ describe('RuleEngineService — requested service without a configured surcharge expect(result.hardBlocked[0]).toContain('reefer'); }); }); + +describe('RuleEngineService — overweight surcharge by trade direction', () => { + const baseImportRate: Rate = { + id: 'rate-import-20', + rateType: 'CONTAINER_IMPORT', + trigger: 'ALWAYS', + rateValue: 1000, + rateUnit: 'PER_CONTAINER', + currency: 'USD', + status: 'LIVE', + containerTypeId: 'ct-20', + cargoTypeId: null, + originYardId: 'yard-dj', + destinationYardId: 'yard-adama', + } as Rate; + + const configuredOverweight: Rate = { + id: 'rate-ow', + rateType: 'OVERWEIGHT_PER_TON', + trigger: 'OVERWEIGHT', + rateValue: 10, + rateUnit: 'PER_TON', + currency: 'USD', + status: 'LIVE', + containerTypeId: null, + cargoTypeId: null, + } as Rate; + + let service: RuleEngineService; + + beforeEach(() => { + service = new RuleEngineService( + { findById: jest.fn().mockResolvedValue(null) } as never, + { findById: jest.fn().mockResolvedValue(null) } as never, + { + findActiveByContainerTypeId: jest + .fn() + .mockResolvedValue([{ id: 'wlr-20', maxVgmTons: 20, maxCapacityTons: null }]), + } as never, + { findAllActive: jest.fn().mockResolvedValue([]) } as never, + { + findLiveRates: jest.fn().mockResolvedValue([baseImportRate, configuredOverweight]), + } as never, + { findById: jest.fn().mockResolvedValue(null) } as never, + {} as never, + ); + }); + + // One 20ft at 25 t against a 20 t limit → 5 t excess. + const overweightInput = (tradeDirection: string): BookingEvaluationInput => ({ + serviceTypeId: 'svc-1', + paymentCurrency: 'USD', + tradeDirection, + isHazardous: false, + totalWagons: 1, + originYardId: 'yard-dj', + destinationYardId: 'yard-adama', + containers: [ + { containerTypeId: 'ct-20', quantity: 1, vgmPerUnitTons: 25, totalVgmTons: 25 }, + ], + }); + + it('IMPORT derives the per-ton price from base freight ÷ (2 × limit), not the configured rate', async () => { + const result = await service.evaluate(overweightInput('IMPORT')); + const ow = result.appliedModifiers.filter((m) => m.surchargeCode === 'OVERWEIGHT_PER_TON'); + expect(ow).toHaveLength(1); + // 1000 / (2 × 20) = 25 USD/t on 5 excess tons. + expect(ow[0].unitPriceUsd).toBe(25); + expect(ow[0].calculatedAmount).toBe(125); + expect(ow[0].triggerValue).toBe(5); + expect(ow[0].rateId).toBe(baseImportRate.id); + }); + + it('EXPORT keeps billing the configured OVERWEIGHT rate', async () => { + const result = await service.evaluate(overweightInput('EXPORT')); + const ow = result.appliedModifiers.filter((m) => m.surchargeCode === 'OVERWEIGHT_PER_TON'); + expect(ow).toHaveLength(1); + expect(ow[0].rateId).toBe(configuredOverweight.id); + // 5 excess tons × the configured 10 USD/t. + expect(ow[0].calculatedAmount).toBe(50); + expect(ow[0].unitPriceUsd).toBeUndefined(); + }); + + it('IMPORT without a route-matching base rate bills no overweight (base freight blocks anyway)', async () => { + const result = await service.evaluate({ + ...overweightInput('IMPORT'), + destinationYardId: 'yard-elsewhere', + }); + const ow = result.appliedModifiers.filter((m) => m.surchargeCode === 'OVERWEIGHT_PER_TON'); + expect(ow).toHaveLength(0); + }); +}); + +describe('RuleEngineService — empty-container return per route + container type', () => { + const returnRate20: Rate = { + id: 'rate-return-20', + rateType: 'RETURN_SURCHARGE', + trigger: 'WITH_RETURN', + rateValue: 20, + rateUnit: 'PER_CONTAINER', + currency: 'USD', + status: 'LIVE', + containerTypeId: 'ct-20', + cargoTypeId: null, + tradeDirection: 'IMPORT', + originYardId: 'yard-dj', + destinationYardId: 'yard-adama', + } as Rate; + + let service: RuleEngineService; + + beforeEach(() => { + service = new RuleEngineService( + { findById: jest.fn().mockResolvedValue(null) } as never, + { findById: jest.fn().mockResolvedValue(null) } as never, + { + findActiveByContainerTypeId: jest + .fn() + .mockResolvedValue([{ id: 'wlr-20', maxVgmTons: 20, maxCapacityTons: null }]), + } as never, + { findAllActive: jest.fn().mockResolvedValue([]) } as never, + { findLiveRates: jest.fn().mockResolvedValue([returnRate20]) } as never, + { findById: jest.fn().mockResolvedValue(null) } as never, + {} as never, + ); + }); + + const returnInput = (overrides: Partial): BookingEvaluationInput => ({ + serviceTypeId: 'svc-1', + paymentCurrency: 'USD', + tradeDirection: 'IMPORT', + isHazardous: false, + totalWagons: 1, + originYardId: 'yard-dj', + destinationYardId: 'yard-adama', + containers: [ + { + containerTypeId: 'ct-20', + quantity: 4, + vgmPerUnitTons: 10, + totalVgmTons: 40, + returnQuantity: 2, + }, + ], + ...overrides, + }); + + it('bills the route + type matched rate on the opted-in count', async () => { + const result = await service.evaluate(returnInput({})); + const ret = result.appliedModifiers.filter((m) => m.surchargeCode === 'RETURN_SURCHARGE'); + expect(result.hardBlocked).toHaveLength(0); + expect(ret).toHaveLength(1); + expect(ret[0].rateId).toBe(returnRate20.id); + expect(ret[0].triggerValue).toBe(2); + expect(ret[0].calculatedAmount).toBe(40); + expect(ret[0].billingUnit).toBe('PER_CONTAINER'); + }); + + it('hard-blocks when the booking route has no matching return rate', async () => { + const result = await service.evaluate( + returnInput({ destinationYardId: 'yard-elsewhere' }), + ); + expect(result.hardBlocked.some((m) => m.includes('return'))).toBe(true); + expect( + result.appliedModifiers.filter((m) => m.surchargeCode === 'RETURN_SURCHARGE'), + ).toHaveLength(0); + }); + + it('hard-blocks an EXPORT booking asking for return (rates are import-only)', async () => { + const result = await service.evaluate(returnInput({ tradeDirection: 'EXPORT' })); + expect(result.hardBlocked.some((m) => m.includes('return'))).toBe(true); + }); + + it('legacy booking-level flag bills every container at its type rate', async () => { + const result = await service.evaluate( + returnInput({ + withReturn: true, + containers: [ + { containerTypeId: 'ct-20', quantity: 4, vgmPerUnitTons: 10, totalVgmTons: 40 }, + ], + }), + ); + const ret = result.appliedModifiers.filter((m) => m.surchargeCode === 'RETURN_SURCHARGE'); + expect(ret).toHaveLength(1); + expect(ret[0].triggerValue).toBe(4); + expect(ret[0].calculatedAmount).toBe(80); + }); +}); diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index 3a4c4b778..1c053a820 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -67,6 +67,12 @@ export interface BookingEvaluationInput { isGovernment?: boolean; allowConsolidation?: boolean; shippingLineId?: string | null; + /** + * The booking's rail leg. Import overweight derives its per-ton price from + * this route's own container freight rate, so the engine needs the yards. + */ + originYardId?: string | null; + destinationYardId?: string | null; /** * Booking's cargo type needs EDR-provided lashing/securing (cargoType * hasLashing = true). Fires the flat LASHING surcharge. Resolved by the @@ -91,6 +97,15 @@ export interface AppliedCargoModifier { triggerValue: number | null; calculatedAmount: number; currency: string; + /** + * Effective per-unit USD price when it differs from the rate row's own value + * — set by derived charges (import overweight: base freight ÷ 2×limit) so + * the breakdown shows the real per-ton figure, not the base container price. + * Any modifier carrying it also bypasses frozen contract snapshots. + */ + unitPriceUsd?: number | null; + /** Display unit for a unitPriceUsd modifier (e.g. PER_TON for overweight). */ + billingUnit?: string; } export interface ContainerWeightResult { @@ -165,12 +180,16 @@ export class RuleEngineService { ...(await this.capacityViolations(input.containers, input.tradeDirection)), ); + // Per-container-line weight limit (maxVgmTons), index-aligned with + // containerWeightResults — the derived import overweight divides by it. + const lineMaxVgmTons: Array = []; for (const container of input.containers) { const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId( container.containerTypeId, input.tradeDirection, ); const rule = rules[0]; + lineMaxVgmTons.push(rule ? Number(rule.maxVgmTons) : null); let isOverweight = container.isOverweight ?? false; let excess = container.overweightExcessTons ?? null; @@ -273,13 +292,6 @@ export class RuleEngineService { input.containers.some((c) => Number(c.reeferQuantity ?? 0) > 0), label: 'refrigerated (reefer) cargo', }, - { - trigger: 'WITH_RETURN', - wanted: - truthy(input.withReturn) || - input.containers.some((c) => Number(c.returnQuantity ?? 0) > 0), - label: 'empty-container return', - }, ]; for (const svc of requestedServices) { if (svc.wanted && !surchargeRates.some((r) => r.trigger === svc.trigger)) { @@ -292,6 +304,14 @@ export class RuleEngineService { } for (const rate of surchargeRates) { + // Import overweight never bills the configured rate — its per-ton price + // derives from the route's base container freight (see below). + if (rate.trigger === 'OVERWEIGHT' && input.tradeDirection === 'IMPORT') { + continue; + } + // Empty-container return is sold per route + container type — billed by + // the route-matched block below, never by this route-agnostic loop. + if (rate.trigger === 'WITH_RETURN') continue; const triggered = this.matchesTrigger(rate.trigger, { isHazardous: input.isHazardous, hasReefer, @@ -384,6 +404,21 @@ export class RuleEngineService { }); } + if (input.tradeDirection === 'IMPORT') { + appliedModifiers.push( + ...this.derivedImportOverweight( + input, + containerWeightResults, + lineMaxVgmTons, + liveRates, + ), + ); + } + + const withReturn = this.withReturnCharges(input, liveRates); + appliedModifiers.push(...withReturn.modifiers); + hardBlocked.push(...withReturn.blocked); + return { priorityScore, appliedModifiers, @@ -394,6 +429,132 @@ export class RuleEngineService { }; } + /** + * Import overweight — derived, never configured. Each overweight container + * line bills its excess tons at (its own base import freight on the booking's + * route) ÷ (2 × its weight limit): 20ft at 1000 USD with a 20 t limit → + * 25 USD per excess ton. Export keeps the configured OVERWEIGHT rate. + * Note: derives from the LIVE route rate even for frozen-rate contract + * bookings — the frozen snapshot has no route-scoped container price to + * divide. + */ + private derivedImportOverweight( + input: BookingEvaluationInput, + weightResults: ContainerWeightResult[], + lineMaxVgmTons: Array, + liveRates: Rate[], + ): AppliedCargoModifier[] { + const modifiers: AppliedCargoModifier[] = []; + if (!input.originYardId || !input.destinationYardId) return modifiers; + + for (let i = 0; i < weightResults.length; i++) { + const wr = weightResults[i]; + const excess = Number(wr?.overweightExcessTons ?? 0); + const maxVgm = Number(lineMaxVgmTons[i] ?? 0); + if (!wr?.isOverweight || !(excess > 0) || !(maxVgm > 0)) continue; + + // Same precedence as base freight pricing: the rate scoped to this + // container type wins over the route's catch-all rate. + const onLeg = liveRates.filter( + (r) => + r.rateType === 'CONTAINER_IMPORT' && + r.currency === 'USD' && + r.originYardId === input.originYardId && + r.destinationYardId === input.destinationYardId, + ); + const base = + onLeg.find((r) => r.containerTypeId === wr.containerTypeId) ?? + onLeg.find((r) => !r.containerTypeId); + // No base rate → the base-freight line hard-blocks this booking anyway. + if (!base) continue; + + const perTon = Number(base.rateValue) / (2 * maxVgm); + const amount = excess * perTon; + if (!(amount > 0)) continue; + + modifiers.push({ + rateId: base.id, + surchargeCode: 'OVERWEIGHT_PER_TON', + triggerValue: excess, + calculatedAmount: amount, + currency: base.currency, + unitPriceUsd: perTon, + billingUnit: 'PER_TON', + }); + } + return modifiers; + } + + /** + * Empty-container return — sold per direction + route + container type, like + * base freight. Each container line that opted in (returnQuantity, or every + * container when only the legacy booking-level flag is set) bills the + * route-matched WITH_RETURN rate for its own container type; a line with no + * matching rate hard-blocks the booking instead of shipping the service for + * free. Rates are import-only for now, so an export booking that asks for + * return blocks too. + * ponytail: bills the LIVE route rate, not a frozen contract snapshot — one + * RETURN_SURCHARGE snapshot code can't hold per-size route prices. + */ + private withReturnCharges( + input: BookingEvaluationInput, + liveRates: Rate[], + ): { modifiers: AppliedCargoModifier[]; blocked: string[] } { + const modifiers: AppliedCargoModifier[] = []; + const blocked: string[] = []; + const bookingLevel = truthy(input.withReturn); + const wanted = + bookingLevel || input.containers.some((c) => Number(c.returnQuantity ?? 0) > 0); + if (!wanted) return { modifiers, blocked }; + + const onLeg = liveRates.filter( + (r) => + r.trigger === 'WITH_RETURN' && + r.currency === 'USD' && + r.tradeDirection === input.tradeDirection && + r.originYardId === input.originYardId && + r.destinationYardId === input.destinationYardId, + ); + + for (const container of input.containers) { + const qty = + Number(container.returnQuantity ?? 0) > 0 + ? Number(container.returnQuantity) + : bookingLevel + ? Number(container.quantity || 0) + : 0; + if (!(qty > 0)) continue; + + const rate = + onLeg.find((r) => r.containerTypeId === container.containerTypeId) ?? + onLeg.find((r) => !r.containerTypeId); + if (!rate) { + blocked.push( + 'No empty-container return rate is configured for this container ' + + 'type on this route (return is import-only) — remove the return ' + + 'option or ask EDR to configure its rate for this origin → destination.', + ); + continue; + } + + const rateValue = Number(rate.rateValue); + const amount = rate.rateUnit === 'FLAT' ? rateValue : qty * rateValue; + if (!(amount > 0)) continue; + modifiers.push({ + rateId: rate.id, + surchargeCode: this.surchargeCode(rate), + triggerValue: qty, + calculatedAmount: amount, + currency: rate.currency, + unitPriceUsd: rateValue, + billingUnit: rate.rateUnit, + }); + } + + // Same block deduplicated — several lines missing the rate is one problem. + return { modifiers, blocked: [...new Set(blocked)] }; + } + /** * Messages for container lines whose total weight exceeds the hard capacity * ceiling (weight_limit_rules.max_capacity_tons). Non-empty ⇒ the booking diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index 488865f38..203cabaa2 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -93,6 +93,19 @@ export class RatesService { return trigger === 'ALWAYS' && BASE_FREIGHT_CATEGORIES.includes(appliesTo); } + /** + * Rates sold per direction + route. Base freight always; customs clearance + * and empty-container return are the surcharges that are too — their fee + * depends on the lane (and, for returns, the container type). + */ + private isRouteScoped(appliesTo: Rate['appliesTo'], trigger: Rate['trigger']): boolean { + return ( + this.isBaseFreight(appliesTo, trigger) || + trigger === 'CUSTOMS_CLEARANCE' || + trigger === 'WITH_RETURN' + ); + } + /** * Which country each end of the leg must sit in, given what the rate is for. * The railway only sells three shapes: import lands at the Djibouti ports and @@ -126,7 +139,7 @@ export class RatesService { destinationYardId?: string | null; }): Promise { const { appliesTo, trigger, tradeDirection } = input; - if (!this.isBaseFreight(appliesTo, trigger)) { + if (!this.isRouteScoped(appliesTo, trigger)) { return { originYardId: null, destinationYardId: null }; } @@ -134,7 +147,7 @@ export class RatesService { const destinationYardId = input.destinationYardId ?? null; if (!originYardId || !destinationYardId) { throw new BadRequestException( - 'Base freight rates are priced per leg — pick both an origin and a destination yard.', + 'This rate is priced per leg — pick both an origin and a destination yard.', ); } if (originYardId === destinationYardId) { @@ -179,6 +192,25 @@ export class RatesService { }): void { const { appliesTo, trigger, tradeDirection, intercityKind } = input; const { containerTypeId, cargoTypeId } = input; + if (trigger === 'CUSTOMS_CLEARANCE') { + if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') { + throw new BadRequestException( + 'A customs clearance rate must say whether it covers IMPORT or EXPORT.', + ); + } + return; + } + if (trigger === 'WITH_RETURN') { + // Returning the empty box only exists on imports (the box goes back to + // the port) — export return rates are rejected until the business sells + // that. + if (tradeDirection !== 'IMPORT') { + throw new BadRequestException( + 'An empty container return rate is import-only for now.', + ); + } + return; + } if (!this.isBaseFreight(appliesTo, trigger)) return; if (appliesTo === 'INTERCITY') { @@ -247,13 +279,24 @@ export class RatesService { const trigger = dto.trigger as Rate['trigger']; // Surcharges (trigger ≠ ALWAYS) carry no direction/scope — clear them so // the engine never accidentally narrows a surcharge by container/direction. + // Exceptions: customs clearance keeps a direction, and empty-container + // return keeps direction + container type — both are sold per lane. const isSurcharge = trigger !== 'ALWAYS'; - const containerTypeId = isSurcharge ? null : (dto.containerTypeId ?? null); + const containerTypeId = + trigger === 'WITH_RETURN' + ? (dto.containerTypeId ?? null) + : isSurcharge + ? null + : (dto.containerTypeId ?? null); const cargoTypeId = isSurcharge ? null : (dto.cargoTypeId ?? null); // Intercity never leaves Ethiopia, so it has no trade direction to store — // its yard pair already says where it runs. const tradeDirection = - isSurcharge || appliesTo === 'INTERCITY' ? null : (dto.tradeDirection ?? null); + trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN' + ? (dto.tradeDirection ?? null) + : isSurcharge || appliesTo === 'INTERCITY' + ? null + : (dto.tradeDirection ?? null); const intercityKind = dto.intercityKind ?? null; this.assertScopeCoherent({ @@ -376,7 +419,8 @@ export class RatesService { if (dto.appliesTo) updates.appliesTo = appliesTo; if (dto.trigger) updates.trigger = trigger; - const containerTypeId = isSurcharge + const keepsContainerType = !isSurcharge || trigger === 'WITH_RETURN'; + const containerTypeId = !keepsContainerType ? null : dto.containerTypeId !== undefined ? dto.containerTypeId @@ -387,11 +431,15 @@ export class RatesService { ? dto.cargoTypeId : existing.cargoTypeId; const tradeDirection = - isSurcharge || appliesTo === 'INTERCITY' - ? null - : dto.tradeDirection !== undefined + trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN' + ? dto.tradeDirection !== undefined ? dto.tradeDirection - : existing.tradeDirection; + : existing.tradeDirection + : isSurcharge || appliesTo === 'INTERCITY' + ? null + : dto.tradeDirection !== undefined + ? dto.tradeDirection + : existing.tradeDirection; updates.containerTypeId = containerTypeId ?? null; updates.cargoTypeId = cargoTypeId ?? null; diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index d5db055e3..7a5b4de30 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -70,9 +70,15 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [ */ export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [ perm('a3000001-0001-4000-8000-000000000001', 'edr_freight_app:contracts:view', 'View contracts'), - perm('a3000001-0001-4000-8000-000000000002', 'edr_freight_app:contracts:staff_accept', 'Accept contract intake'), - perm('a3000001-0001-4000-8000-000000000003', 'edr_freight_app:contracts:request_changes', 'Request contract changes'), - perm('a3000001-0001-4000-8000-000000000004', 'edr_freight_app:contracts:reject', 'Reject contract'), + // Intake actions are split per freight type (bulk vs container) — fresh ids + // because the seeder upserts ON CONFLICT (key); reusing the old ids with new + // keys would PK-collide with the legacy staff_accept/request_changes/reject rows. + perm('a3000001-0001-4000-8000-000000000011', 'edr_freight_app:contracts:staff_accept:bulk', 'Accept bulk contract intake'), + perm('a3000001-0001-4000-8000-000000000012', 'edr_freight_app:contracts:staff_accept:container', 'Accept container contract intake'), + perm('a3000001-0001-4000-8000-000000000013', 'edr_freight_app:contracts:request_changes:bulk', 'Request bulk contract changes'), + perm('a3000001-0001-4000-8000-000000000014', 'edr_freight_app:contracts:request_changes:container', 'Request container contract changes'), + perm('a3000001-0001-4000-8000-000000000015', 'edr_freight_app:contracts:reject:bulk', 'Reject bulk contract'), + perm('a3000001-0001-4000-8000-000000000016', 'edr_freight_app:contracts:reject:container', 'Reject container contract'), perm('a3000001-0001-4000-8000-000000000005', 'edr_freight_app:contracts:approve_line_staff', 'Approve contract as line staff'), perm('a3000001-0001-4000-8000-000000000006', 'edr_freight_app:contracts:approve_director', 'Approve contract as director'), perm('a3000001-0001-4000-8000-000000000007', 'edr_freight_app:contracts:approve_ceo', 'Approve contract as CEO'), @@ -373,9 +379,18 @@ export const FREIGHT_PERMS = { }, contracts: { view: 'edr_freight_app:contracts:view', - staffAccept: 'edr_freight_app:contracts:staff_accept', - requestChanges: 'edr_freight_app:contracts:request_changes', - reject: 'edr_freight_app:contracts:reject', + staffAccept: { + bulk: 'edr_freight_app:contracts:staff_accept:bulk', + container: 'edr_freight_app:contracts:staff_accept:container', + }, + requestChanges: { + bulk: 'edr_freight_app:contracts:request_changes:bulk', + container: 'edr_freight_app:contracts:request_changes:container', + }, + reject: { + bulk: 'edr_freight_app:contracts:reject:bulk', + container: 'edr_freight_app:contracts:reject:container', + }, approveLineStaff: 'edr_freight_app:contracts:approve_line_staff', approveDirector: 'edr_freight_app:contracts:approve_director', approveCeo: 'edr_freight_app:contracts:approve_ceo', @@ -661,6 +676,18 @@ export const FREIGHT_PERMS = { }, } as const; +/** Both arms of a freight-type-split permission (for one-of route guards). */ +export const bothFreightTypes = (p: { bulk: string; container: string }): string[] => [ + p.bulk, + p.container, +]; + +/** The arm of a freight-type-split permission matching a contract's freightType. */ +export const forFreightType = ( + p: { bulk: string; container: string }, + freightType: string, +): string => (freightType === 'BULK' ? p.bulk : p.container); + const allRuleEngineViewKeys = () => RULE_ENGINE_RESOURCE_SLUGS.map((s) => FREIGHT_PERMS.ruleEngine.view(s)); @@ -712,9 +739,9 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.rejectApproval, FREIGHT_PERMS.bookings.cancel, FREIGHT_PERMS.contracts.view, - FREIGHT_PERMS.contracts.staffAccept, - FREIGHT_PERMS.contracts.requestChanges, - FREIGHT_PERMS.contracts.reject, + ...bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept), + ...bothFreightTypes(FREIGHT_PERMS.contracts.requestChanges), + ...bothFreightTypes(FREIGHT_PERMS.contracts.reject), FREIGHT_PERMS.contracts.approveLineStaff, ...allRuleEngineViewKeys(), ], @@ -804,9 +831,9 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.reviewDocuments, FREIGHT_PERMS.bookings.finalizeClearance, FREIGHT_PERMS.contracts.view, - FREIGHT_PERMS.contracts.staffAccept, - FREIGHT_PERMS.contracts.requestChanges, - FREIGHT_PERMS.contracts.reject, + ...bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept), + ...bothFreightTypes(FREIGHT_PERMS.contracts.requestChanges), + ...bothFreightTypes(FREIGHT_PERMS.contracts.reject), FREIGHT_PERMS.contracts.approveLineStaff, FREIGHT_PERMS.contracts.generateContract, FREIGHT_PERMS.contracts.signStaff, diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx index 7896da730..cc2bfb0fb 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx @@ -14,6 +14,8 @@ import { } from "lucide-react"; import type { Freight } from "@edr/types"; +import { useAuth } from "@/auth/useAuth"; +import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { api } from "@/services/api"; import { contractsService } from "@/services/contracts.service"; import { SectionCard } from "@/components/bookings/detail/SectionCard"; @@ -48,8 +50,19 @@ export function ContractActionsToolbar({ onReviewClearance, }: ContractActionsToolbarProps) { const navigate = useNavigate(); + const { user } = useAuth(); const { status } = contract; + // Intake permissions are split per freight type: an accept:bulk holder must + // not see the accept button on a container contract (API enforces the same). + const arm = contract.freightType === "BULK" ? "bulk" : "container"; + const mayAccept = hasPermission(user, FREIGHT_PERMS.contracts.staffAccept[arm]); + const mayRequestChanges = hasPermission( + user, + FREIGHT_PERMS.contracts.requestChanges[arm], + ); + const mayReject = hasPermission(user, FREIGHT_PERMS.contracts.reject[arm]); + const [editorOpen, setEditorOpen] = useState(false); const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept"); const [previewOpen, setPreviewOpen] = useState(false); @@ -97,7 +110,8 @@ export function ContractActionsToolbar({ ); } - const canAccept = status === "SUBMITTED"; + const canAccept = + status === "SUBMITTED" && (mayAccept || mayRequestChanges || mayReject); // The document stays editable for the whole approval chain, but only by the // approver whose turn it is. The server resolves that against the caller's // position type; the client cannot derive it. @@ -126,35 +140,41 @@ export function ContractActionsToolbar({ {canAccept && ( <> - - - + {mayAccept && ( + + )} + {mayRequestChanges && ( + + )} + {mayReject && ( + + )} )} diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index dfc55d115..5505a4145 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -29,9 +29,19 @@ export const FREIGHT_PERMS = { }, contracts: { view: "edr_freight_app:contracts:view", - staffAccept: "edr_freight_app:contracts:staff_accept", - requestChanges: "edr_freight_app:contracts:request_changes", - reject: "edr_freight_app:contracts:reject", + // Intake actions are split per freight type — mirror of the API registry. + staffAccept: { + bulk: "edr_freight_app:contracts:staff_accept:bulk", + container: "edr_freight_app:contracts:staff_accept:container", + }, + requestChanges: { + bulk: "edr_freight_app:contracts:request_changes:bulk", + container: "edr_freight_app:contracts:request_changes:container", + }, + reject: { + bulk: "edr_freight_app:contracts:reject:bulk", + container: "edr_freight_app:contracts:reject:container", + }, approveLineStaff: "edr_freight_app:contracts:approve_line_staff", approveDirector: "edr_freight_app:contracts:approve_director", approveCeo: "edr_freight_app:contracts:approve_ceo", diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx index 3057f9c11..582dcef88 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx @@ -13,6 +13,7 @@ import { Loader, Modal, Stack, + Tabs, Text, Tooltip, } from "@mantine/core"; @@ -94,7 +95,14 @@ const yardOptionsForLegEnd = ( let country: string | undefined; if (appliesTo === "INTERCITY") { country = "Ethiopia"; - } else if (appliesTo === "CONTAINER" || appliesTo === "BULK") { + } else if ( + appliesTo === "CONTAINER" || + appliesTo === "BULK" || + // Customs clearance + empty-container return are sold per direction + + // route, so their yard dropdowns narrow exactly like base freight. + (appliesTo === "OTHER" && + ["CUSTOMS_CLEARANCE", "WITH_RETURN"].includes(String(values.trigger ?? ""))) + ) { const direction = String(values.tradeDirection ?? ""); // Direction is what decides the countries, so offer nothing until it is set // rather than defaulting to one and letting it read as a real choice. @@ -124,6 +132,10 @@ const RuleEngineResourcePage = () => { const { pagination, setPagination } = usePagination({ pageSize: 10 }); const [search, setSearch] = useState(""); + // Category tabs (rates page): the active tab's filters go to the backend. + const [activeTab, setActiveTab] = useState( + config?.listTabs?.[0]?.key ?? "", + ); const [formOpen, setFormOpen] = useState(false); const [editing, setEditing] = useState(null); const [deleteTarget, setDeleteTarget] = useState( @@ -153,10 +165,13 @@ const RuleEngineResourcePage = () => { sortOrder: "ASC" as const, } : {}), + ...(config?.listTabs?.find((t) => t.key === activeTab)?.filters ?? {}), }), [ config?.orderConfig, config?.supportsSearch, + config?.listTabs, + activeTab, search, pagination.pageIndex, pagination.pageSize, @@ -166,7 +181,8 @@ const RuleEngineResourcePage = () => { useEffect(() => { setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize })); setSearch(""); - }, [config?.slug, setPagination]); + setActiveTab(config?.listTabs?.[0]?.key ?? ""); + }, [config?.slug, config?.listTabs, setPagination]); const { data, isLoading, isError, error } = useRuleEngineList( config?.slug ?? DEFAULT_CONFIGURATION_SLUG, @@ -685,6 +701,25 @@ const RuleEngineResourcePage = () => { + {config.listTabs && ( + { + setActiveTab(v ?? config.listTabs![0].key); + setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); + }} + px="md" + pt="sm" + > + + {config.listTabs.map((tab) => ( + + {tab.label} + + ))} + + + )} ) => ["BULK", "CONTAINER", "INTERCITY"].includes(String(values.appliesTo ?? "")); +/** + * Rates priced per leg: base rail freight, plus the customs clearance fee and + * the empty-container return surcharge (sold per route + container type). + */ +const isRouteScopedRate = (values: Record) => + isBaseFreightRate(values) || + (String(values.appliesTo ?? "") === "OTHER" && + ["CUSTOMS_CLEARANCE", "WITH_RETURN"].includes(String(values.trigger ?? ""))); + const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value }); /** @@ -604,6 +632,37 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ subtitle: "Freight rates and approval workflow", searchPlaceholder: "Search rates by type or status...", supportsSearch: true, + // Category tabs — each filters server-side by appliesTo / trigger. + listTabs: [ + { key: "all", label: "All", filters: {} }, + { key: "container", label: "Container", filters: { appliesTo: "CONTAINER" } }, + { key: "bulk", label: "Bulk", filters: { appliesTo: "BULK" } }, + { key: "intercity", label: "Intercity", filters: { appliesTo: "INTERCITY" } }, + { + key: "trucking", + label: "First / Last mile", + filters: { appliesTo: "FIRST_MILE,LAST_MILE" }, + }, + { + key: "customs", + label: "Customs clearance", + filters: { trigger: "CUSTOMS_CLEARANCE" }, + }, + { + key: "return", + label: "Container return", + filters: { trigger: "WITH_RETURN" }, + }, + { + key: "surcharges", + label: "Surcharges", + filters: { + appliesTo: "OTHER", + trigger: + "HAZARDOUS,OVERWEIGHT,REEFER,SHIPPING_LINE,CONSOLIDATION,LASHING,CANCELLATION,DEMURRAGE,PIL_EXTRA_FEE", + }, + }, + ], columns: [ { id: "appliesTo", header: "Applies to", accessorKey: "appliesTo", format: "code" }, { id: "trigger", header: "Trigger", accessorKey: "trigger" }, @@ -640,14 +699,23 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ placeholder: "What makes this surcharge apply?", showWhen: { field: "appliesTo", equals: ["OTHER"] }, }, - // ── Trade direction — Bulk & Container only (intercity is domestic) ─── + // ── Trade direction — Bulk & Container base freight, plus the route- + // scoped surcharges (customs clearance; empty-container return, which is + // import-only for now so export is not offered) ──────────────────────── { name: "tradeDirection", label: "Trade direction", type: "select", required: true, - options: TRADE_DIRECTIONS.filter((d) => d.value !== "BOTH"), - showWhen: { field: "appliesTo", equals: ["BULK", "CONTAINER"] }, + optionsFromValues: (v: Record) => + String(v.trigger ?? "") === "WITH_RETURN" && + String(v.appliesTo ?? "") === "OTHER" + ? TRADE_DIRECTIONS.filter((d) => d.value === "IMPORT") + : TRADE_DIRECTIONS.filter((d) => d.value !== "BOTH"), + showIf: (v) => + ["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) || + (String(v.appliesTo ?? "") === "OTHER" && + ["CUSTOMS_CLEARANCE", "WITH_RETURN"].includes(String(v.trigger ?? ""))), }, // ── Cargo kind — Intercity only (import/export get it from appliesTo) ─ { @@ -664,7 +732,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ getInitialValue: (record) => record.rateType === "INTERCITY_BULK" ? "BULK" : "CONTAINER", }, - // ── Container type — Container freight, and container-kind intercity ── + // ── Container type — Container freight, container-kind intercity, and + // the empty-container return surcharge (20ft vs 40ft price differently) ─ { name: "containerTypeId", label: "Container type", @@ -673,7 +742,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ placeholder: "Select container type (optional)", showIf: (v) => v.appliesTo === "CONTAINER" || - (v.appliesTo === "INTERCITY" && v.intercityKind === "CONTAINER"), + (v.appliesTo === "INTERCITY" && v.intercityKind === "CONTAINER") || + (v.appliesTo === "OTHER" && v.trigger === "WITH_RETURN"), }, // ── Bulk cargo (leaf commodity) — Bulk freight, and bulk-kind intercity ─ { @@ -696,7 +766,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ type: "select", required: true, placeholder: "Where the leg starts", - showIf: isBaseFreightRate, + showIf: isRouteScopedRate, }, { name: "destinationYardId", @@ -704,7 +774,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ type: "select", required: true, placeholder: "Where the leg ends", - showIf: isBaseFreightRate, + showIf: isRouteScopedRate, }, { name: "rateValue", label: "Rate value", type: "number", required: true, suffix: "USD" }, // Unit choices are driven by the rate shape (appliesTo + trigger). Overweight diff --git a/apps/edr-freight-web/backoffice/src/services/ruleEngine/ruleEngine.service.ts b/apps/edr-freight-web/backoffice/src/services/ruleEngine/ruleEngine.service.ts index a1e36ce0a..4e646e426 100644 --- a/apps/edr-freight-web/backoffice/src/services/ruleEngine/ruleEngine.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/ruleEngine/ruleEngine.service.ts @@ -17,6 +17,9 @@ export interface RuleEngineListParams { sortBy?: string; sortOrder?: "ASC" | "DESC"; requiresDirectorApproval?: boolean; + /** Rates category tabs — comma-separated appliesTo / trigger filters. */ + appliesTo?: string; + trigger?: string; } export interface RuleEngineReorderPayload { @@ -205,6 +208,8 @@ export const ruleEngineService = { sortBy: params?.sortBy, sortOrder: params?.sortOrder, requiresDirectorApproval: params?.requiresDirectorApproval, + appliesTo: params?.appliesTo, + trigger: params?.trigger, }, }); return normalizeList(response.data, page, pageSize); diff --git a/e2e/freight/cypress/e2e/flows/bulk_import_window_reopen.cy.ts b/e2e/freight/cypress/e2e/flows/bulk_import_window_reopen.cy.ts index 6304f78f9..90d5e7f84 100644 --- a/e2e/freight/cypress/e2e/flows/bulk_import_window_reopen.cy.ts +++ b/e2e/freight/cypress/e2e/flows/bulk_import_window_reopen.cy.ts @@ -73,11 +73,16 @@ describe("bulk import: dead first cycle — expire all, reopen, book again", { r ["BRA", "BRB"].forEach((suffix) => forceReservationExpiry(suffix)); withSchedule(DEPARTURE, (s) => { endPaymentPhase(s.id); + // When the reopen instant falls inside office hours, the 10s tick's + // guard-loop chains PRE_WINDOW straight into OPEN within the SAME tick + // (booking-window.service.ts advanceSchedule), so PRE_WINDOW is not a + // reliably observable resting state — assert the cycle left PAYMENT + // without concluding FULL/DONE, whichever phase it lands on. pollDb( - "window reopens (PRE_WINDOW, cycle 2 pending)", + "window concludes not-full (PRE_WINDOW or fast-forwarded to OPEN)", `SELECT window_phase FROM freight.train_schedules WHERE id = $1`, [s.id], - (row) => row?.window_phase === "PRE_WINDOW", + (row) => !!row && row.window_phase !== "PAYMENT" && row.window_phase !== "DONE", ); }); }); diff --git a/e2e/freight/cypress/e2e/flows/general_drawdown.cy.ts b/e2e/freight/cypress/e2e/flows/general_drawdown.cy.ts new file mode 100644 index 000000000..02fdb3efe --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/general_drawdown.cy.ts @@ -0,0 +1,140 @@ +/** + * GENERAL drawdown ledger (D+25) — one GENERAL contract capped at 60×20ft + * draws down across many bookings; the cap is enforced at CREATE, releases + * when a booking dies, and the contract stays CONTRACT_ACTIVE throughout: + * + * B1 30 → B2 20 → B3 asking 20 REJECTED ("only 10 remain") → B3' 10 → cap + * exhausted → B4 2 REJECTED → B3' expires → its 10 return → B5 10 accepted. + * + * Sequential steps of one journey — retries off. + */ + +import { + bookContainers, + createImportSchedule, + db, + departureAt, + eatDayStr, + ensureCorridorRoute, + forceWindowOpen, + pollBookingStatus, + resetCorridorDay, + seedImportContract, + withSchedule, +} from "./import-utils"; + +const DEPARTURE = departureAt(25); +const BOOKING_DAY = eatDayStr(DEPARTURE); + +const stamp = String(Date.now()); +const SUFFIX = "GDCAP"; +const REF = `CTR-IMP-${stamp}-${SUFFIX}`; + +describe("GENERAL drawdown: a 60×20ft cap across many bookings", { retries: 0 }, () => { + before(() => { + cy.task("db:seedFile", "seed-import-corridor.sql"); + seedImportContract({ suffix: SUFFIX, reference: REF, kind: "GENERAL", cap20: 60 }); + }); + + it("operations prepares the corridor train with an open window (bookings need an open day)", () => { + ensureCorridorRoute(); + resetCorridorDay(DEPARTURE); + createImportSchedule({ departure: DEPARTURE, locoPair: ["LOCO-IMP-17", "LOCO-IMP-18"] }); + withSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 60)); + }); + + it("B1 draws 30 and B2 draws 20 — 50 of 60 held", () => { + bookContainers({ + suffix: SUFFIX, + runStamp: stamp, + isoSeed: 18_000, + twenty: 30, + scheduledDate: BOOKING_DAY, + }); + bookContainers({ + suffix: SUFFIX, + runStamp: stamp, + isoSeed: 18_100, + twenty: 20, + scheduledDate: BOOKING_DAY, + }); + db<{ n: string }>( + `SELECT count(*) AS n FROM freight.bookings b + JOIN freight.contracts ct ON ct.id = b.contract_id + WHERE ct.reference = $1`, + [REF], + ).then(({ rows }) => expect(Number(rows[0].n), "two live drawdowns").to.eq(2)); + }); + + it("B3 asking 20 is REJECTED — only 10 of 60 remain", () => { + bookContainers({ + suffix: SUFFIX, + runStamp: stamp, + isoSeed: 18_200, + twenty: 20, + scheduledDate: BOOKING_DAY, + expectFailure: "remain on this contract", + }); + }); + + it("B3' takes exactly the remaining 10 — the contract COMPLETES and B4 is rejected", () => { + bookContainers({ + suffix: SUFFIX, + runStamp: stamp, + isoSeed: 18_300, + twenty: 10, + scheduledDate: BOOKING_DAY, + }); + // Booking the last of the cap flips the GENERAL contract to + // CONTRACT_CLOSED (fully drawn) — the next booking is rejected as such. + db<{ status: string }>( + `SELECT status FROM freight.contracts WHERE reference = $1`, + [REF], + ).then(({ rows }) => + expect(rows[0].status, "fully drawn contract completes").to.eq("CONTRACT_CLOSED"), + ); + bookContainers({ + suffix: SUFFIX, + runStamp: stamp, + isoSeed: 18_400, + twenty: 2, + scheduledDate: BOOKING_DAY, + expectFailure: "completed", + }); + }); + + it("an EXPIRED booking releases its draw — B5 books the freed 10 and the ledger closes again", () => { + // Kill the newest live drawdown (B3', 10×20ft) — expiry releases its hold. + db( + `UPDATE freight.bookings b SET status = 'EXPIRED' + FROM freight.contracts ct + WHERE ct.id = b.contract_id AND ct.reference = $1 + AND b.id = ( + SELECT b2.id FROM freight.bookings b2 + JOIN freight.contracts c2 ON c2.id = b2.contract_id + WHERE c2.reference = $1 AND b2.status NOT IN ('EXPIRED','CANCELLED','REJECTED') + ORDER BY b2.created_at DESC LIMIT 1 + )`, + [REF], + ); + bookContainers({ + suffix: SUFFIX, + runStamp: stamp, + isoSeed: 18_500, + twenty: 10, + scheduledDate: BOOKING_DAY, + }); + pollBookingStatus(SUFFIX, "AWAITING_DOCUMENTS", 5); + + // Completion tracks the outstanding quantity: the released 10 were + // rebooked, so the ledger is full again and the contract stays CLOSED. + db<{ status: string }>( + `SELECT status FROM freight.contracts WHERE reference = $1`, + [REF], + ).then(({ rows }) => + expect(rows[0].status, "re-drawn contract closed").to.eq("CONTRACT_CLOSED"), + ); + }); +}); + +export {}; diff --git a/e2e/freight/cypress/e2e/flows/general_mixed_reopen.cy.ts b/e2e/freight/cypress/e2e/flows/general_mixed_reopen.cy.ts new file mode 100644 index 000000000..3eca27de7 --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/general_mixed_reopen.cy.ts @@ -0,0 +1,136 @@ +/** + * GENERAL mixed reopen (D+26) — the same two GENERAL contracts (one + * container, one bulk) book AGAIN after their first bookings die: cycle 1 + * reserves a container + a bulk drawdown, nobody pays, both expire, the + * window reopens — and the SAME contracts issue fresh drawdowns in cycle 2 + * that pay and allocate typed. GENERAL contracts survive dead bookings and + * dead cycles alike. + * + * Sequential steps of one journey — retries off. + */ + +import { + bookBulk, + bookContainers, + clearGeneralBooking, + closeBookingWindow, + completeDocReview, + createImportSchedule, + departureAt, + eatDayStr, + endPaymentPhase, + ensureCorridorRoute, + expectWagonType, + forceReservationExpiry, + forceWindowOpen, + markPaid, + pollAllocations, + pollBookingStatus, + pollDb, + resetCorridorDay, + seedImportContract, + withBooking, + withSchedule, + type ScheduleRow, +} from "./import-utils"; + +const DEPARTURE = departureAt(26); +const BOOKING_DAY = eatDayStr(DEPARTURE); + +const stamp = String(Date.now()); +const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; + +describe("GENERAL mixed reopen: the same contracts book again after a dead cycle", { retries: 0 }, () => { + before(() => { + cy.task("db:seedFile", "seed-import-corridor.sql"); + seedImportContract({ suffix: "GMC", reference: stampedRef("GMC"), kind: "GENERAL" }); + seedImportContract({ + suffix: "GMB", + reference: stampedRef("GMB"), + kind: "GENERAL", + freight: "BULK", + }); + }); + + it("operations prepares the corridor train — first window opens (cycle 1)", () => { + ensureCorridorRoute(); + resetCorridorDay(DEPARTURE); + createImportSchedule({ departure: DEPARTURE, locoPair: ["LOCO-IMP-19", "LOCO-IMP-20"] }); + withSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 45)); + withSchedule(DEPARTURE, (s) => expect(s.booking_cycle_no, "cycle 1").to.eq(1)); + }); + + it("cycle 1: a container and a bulk GENERAL drawdown clear per booking and are reserved", () => { + bookContainers({ + suffix: "GMC", + runStamp: stamp, + isoSeed: 19_000, + twenty: 12, // 6 wagons + scheduledDate: BOOKING_DAY, + }); + clearGeneralBooking("GMC", BOOKING_DAY); + bookBulk({ suffix: "GMB", tons: 700, scheduledDate: BOOKING_DAY }); // 10 wagons + clearGeneralBooking("GMB", BOOKING_DAY); + + withSchedule(DEPARTURE, (s) => { + closeBookingWindow(s.id); + completeDocReview(s.id); + }); + ["GMC", "GMB"].forEach((suffix) => + pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]), + ); + }); + + it("nobody pays — both drawdowns expire and the window reopens", () => { + ["GMC", "GMB"].forEach((suffix) => forceReservationExpiry(suffix)); + withSchedule(DEPARTURE, (s) => { + endPaymentPhase(s.id); + // Same-tick guard-loop can chain PRE_WINDOW straight into OPEN when the + // reopen instant falls inside office hours — assert it left PAYMENT + // without concluding FULL/DONE, whichever phase it lands on. + pollDb( + "window concludes not-full (PRE_WINDOW or fast-forwarded to OPEN)", + `SELECT window_phase FROM freight.train_schedules WHERE id = $1`, + [s.id], + (row) => !!row && row.window_phase !== "PAYMENT" && row.window_phase !== "DONE", + ); + }); + }); + + it("cycle 2: the SAME contracts issue fresh drawdowns that pay and allocate typed", () => { + withSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 45)); + withSchedule(DEPARTURE, (s) => expect(s.booking_cycle_no, "cycle 2").to.eq(2)); + + bookContainers({ + suffix: "GMC", + runStamp: stamp, + isoSeed: 19_200, + twenty: 12, + scheduledDate: BOOKING_DAY, + }); + clearGeneralBooking("GMC", BOOKING_DAY); + bookBulk({ suffix: "GMB", tons: 700, scheduledDate: BOOKING_DAY }); + clearGeneralBooking("GMB", BOOKING_DAY); + + withSchedule(DEPARTURE, (s) => { + closeBookingWindow(s.id); + completeDocReview(s.id); + }); + ["GMC", "GMB"].forEach((suffix) => + pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]), + ); + markPaid("GMC"); + pollAllocations("GMC", 6); + expectWagonType("GMC", "NW5", 6); + markPaid("GMB"); + pollAllocations("GMB", 10); + expectWagonType("GMB", "CW4", 10); + + // The cycle-1 corpses stay dead; both contracts remain ACTIVE. + ["GMC", "GMB"].forEach((suffix) => + withBooking(suffix, (b) => expect(b.status, `${suffix} cycle-2 rides`).to.eq("PAID")), + ); + }); +}); + +export {}; diff --git a/e2e/freight/cypress/e2e/flows/general_rush_hour.cy.ts b/e2e/freight/cypress/e2e/flows/general_rush_hour.cy.ts new file mode 100644 index 000000000..298929c00 --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/general_rush_hour.cy.ts @@ -0,0 +1,179 @@ +/** + * GENERAL rush hour — 20 "users" (20 GENERAL contracts) book one 54-wagon + * import train in the SAME first window (D+23). Every GENERAL booking clears + * PER BOOKING (upload → GL approve → finalize → proceed → ops accept) before + * it may enter the pool. Then: + * + * – a 21st booking whose operation request is never accepted is EXPIRED by + * the batch at doc-review end (it can no longer make the train) + * – the batch reserves the top 9 (9 × 6w = 54/54); 11 wait + * – the payment race: 5 pay, 4 miss their deadline → the freed 24 wagons + * promote the next 4 waiters live (payment phase extends); they pay → + * the train still departs FULL + * – the 7 left-over waiters expire in the day-end sweep; every contract + * stays CONTRACT_ACTIVE (GENERAL survives its bookings) + * + * Sequential steps of one journey — retries off. + */ + +import { + bookContainers, + clearGeneralBooking, + closeBookingWindow, + completeDocReview, + createImportSchedule, + db, + departureAt, + eatDayStr, + endPaymentPhase, + ensureCorridorRoute, + expectWagonType, + forceReservationExpiry, + forceWindowOpen, + markPaid, + pollAllocations, + pollBookingStatus, + pollDb, + resetCorridorDay, + seedImportContract, + setPriority, + withBooking, + withSchedule, + type ScheduleRow, +} from "./import-utils"; + +const DEPARTURE = departureAt(23); +const BOOKING_DAY = eatDayStr(DEPARTURE); + +const stamp = String(Date.now()); +const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; + +/** 20 virtual users — one GENERAL contract each, 12×20ft = 6 wagons per booking. */ +const USERS = Array.from({ length: 20 }, (_, i) => + `GR${String(i + 1).padStart(2, "0")}`, +); +const SELECTED = USERS.slice(0, 9); // 9 × 6w = 54 +const PAYERS = SELECTED.slice(0, 5); +const DEFAULTERS = SELECTED.slice(5, 9); +const PROMOTED = USERS.slice(9, 13); // take the defaulters' 24 wagons +const LEFTOVER = USERS.slice(13); // 7 expire with the day + +describe("GENERAL rush hour: 20 users, one train, one window", { retries: 0 }, () => { + before(() => { + cy.task("db:seedFile", "seed-import-corridor.sql"); + [...USERS, "GRNA"].forEach((suffix) => + seedImportContract({ suffix, reference: stampedRef(suffix), kind: "GENERAL" }), + ); + }); + + it("operations prepares the corridor train with an open first window", () => { + ensureCorridorRoute(); + resetCorridorDay(DEPARTURE); + createImportSchedule({ departure: DEPARTURE, locoPair: ["LOCO-IMP-11", "LOCO-IMP-12"] }); + withSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 60)); + }); + + it("20 users book at once — each GENERAL booking clears PER BOOKING into the pool", () => { + USERS.forEach((suffix, i) => { + bookContainers({ + suffix, + runStamp: stamp, + isoSeed: 16_000 + i * 15, + twenty: 12, + scheduledDate: BOOKING_DAY, + }); + clearGeneralBooking(suffix, BOOKING_DAY); + }); + USERS.forEach((suffix, i) => setPriority(suffix, i + 1)); + }); + + it("a 21st booking never accepted by operations is expired when doc review ends", () => { + bookContainers({ + suffix: "GRNA", + runStamp: stamp, + isoSeed: 16_500, + twenty: 12, + scheduledDate: BOOKING_DAY, + }); + // Clearance done, operation requested — but ops never accept it. + withBooking("GRNA", (b) => { + cy.log(`GRNA ${b.reference} stays OPERATION_REQUEST_PENDING`); + }); + db( + `UPDATE freight.bookings b SET status = 'OPERATION_REQUEST_PENDING' + FROM freight.contracts ct + WHERE ct.id = b.contract_id AND ct.reference LIKE 'CTR-IMP-%-GRNA' + AND b.status = 'AWAITING_DOCUMENTS'`, + [], + ); + + withSchedule(DEPARTURE, (s) => { + closeBookingWindow(s.id); + completeDocReview(s.id); + }); + // Never-accepted bookings are swept BEFORE the batch runs. + pollBookingStatus("GRNA", "EXPIRED"); + }); + + it("the batch reserves the top 9 (54/54); 11 wait with no pay window", () => { + SELECTED.forEach((suffix) => + pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]), + ); + [...PROMOTED, ...LEFTOVER].forEach((suffix) => + withBooking(suffix, (b) => { + expect(b.status, `${suffix} waiting`).to.eq("FULLY_EXECUTED"); + expect(b.payment_deadline, `${suffix} no pay window yet`).to.be.null; + }), + ); + }); + + it("payment race: 5 pay, 4 default — the freed 24 wagons promote the next 4 waiters live", () => { + PAYERS.forEach((suffix) => { + markPaid(suffix); + pollAllocations(suffix, 6); + }); + DEFAULTERS.forEach((suffix) => forceReservationExpiry(suffix)); + PROMOTED.forEach((suffix) => + pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]), + ); + }); + + it("the promoted 4 pay — the train departs FULL at 54/54, typed", () => { + PROMOTED.forEach((suffix) => { + markPaid(suffix); + pollAllocations(suffix, 6); + expectWagonType(suffix, "NW5", 6); + }); + withSchedule(DEPARTURE, (s) => { + endPaymentPhase(s.id); + pollDb( + "window FULL + DONE", + `SELECT window_phase, booking_window_status FROM freight.train_schedules WHERE id = $1`, + [s.id], + (row) => row?.booking_window_status === "FULL" && row?.window_phase === "DONE", + ); + db<{ n: string }>( + `SELECT count(DISTINCT wba.train_set_wagon_id) AS n + FROM freight.wagon_booking_allocations wba + JOIN freight.train_schedule_bookings tsb + ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1 + WHERE wba.deleted_at IS NULL AND tsb.deleted_at IS NULL`, + [s.id], + ).then(({ rows }) => expect(Number(rows[0].n), "54 wagons allocated").to.eq(54)); + }); + }); + + it("the 7 leftover waiters expire in ONE day-end sweep; every contract stays ACTIVE", () => { + LEFTOVER.forEach((suffix) => pollBookingStatus(suffix, "EXPIRED")); + db<{ n: string }>( + `SELECT count(*) AS n FROM freight.contracts + WHERE reference LIKE 'CTR-IMP-%-GR%' AND status = 'CONTRACT_ACTIVE' + AND deleted_at IS NULL AND created_at > now() - interval '1 hour'`, + [], + ).then(({ rows }) => + expect(Number(rows[0].n), "GENERAL contracts survive their bookings").to.be.at.least(21), + ); + }); +}); + +export {}; diff --git a/e2e/freight/cypress/e2e/flows/general_two_trains.cy.ts b/e2e/freight/cypress/e2e/flows/general_two_trains.cy.ts new file mode 100644 index 000000000..861bf4620 --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/general_two_trains.cy.ts @@ -0,0 +1,269 @@ +/** + * GENERAL two-trains-one-day — 20 GENERAL bookings against TWO 54-wagon + * trains sharing one route-day (D+24): ONE window timeline, ONE batch + * release, overflow cascading earliest-departure-first, and a full DUAL + * lifecycle (both trains dispatch, run the corridor and arrive the same day). + * + * – both schedules share the group window (identical clocks); one + * doc-review-complete releases the WHOLE day + * – 20 × 6w = 120w demand vs 108: train 1 takes 9 bookings, the overflow + * lands on train 2 (9 more), 2 wait + * – all 18 pay → 54/54 on EACH train; the 2 waiters expire only after BOTH + * trains conclude (the sweep defers while a sibling is open) + * – both trains finalize, gate-pass, dispatch, checkpoint and arrive — + * every booking ARRIVED under its own train, movements ledger per train + * + * Sequential steps of one journey — retries off. + */ + +import { + apiPost, + bookContainers, + clearGeneralBooking, + completeDocReview, + createImportSchedule, + db, + departureAt, + eatDayStr, + ensureCorridorRoute, + forceReservationExpiry, + markPaid, + opsStaff, + pollAllocations, + pollBookingStatus, + pollDb, + resetCorridorDay, + seedImportContract, + setPriority, + withBooking, + type ScheduleRow, +} from "./import-utils"; + +const DEPARTURE_1 = departureAt(24); +const DEPARTURE_2 = new Date(DEPARTURE_1.getTime() + 2 * 3_600_000); // same EAT day +const BOOKING_DAY = eatDayStr(DEPARTURE_1); + +const stamp = String(Date.now()); +const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; + +const USERS = Array.from({ length: 20 }, (_, i) => + `GT${String(i + 1).padStart(2, "0")}`, +); +const RIDERS = USERS.slice(0, 18); // 9 per train +const WAITERS = USERS.slice(18); // 2 expire after both trains conclude + +interface DayScheduleRow { + id: string; + window_phase: string; + booking_window_status: string; + status: string; + scheduled_departure_date: string; +} + +/** Both schedules of the day, earliest departure first. */ +function dayShedules() { + return db( + `SELECT ts.id, ts.window_phase, ts.booking_window_status, ts.status, + ts.scheduled_departure_date + FROM freight.train_schedules ts + JOIN freight.yards o ON o.id = ts.origin_station_id AND o.code = 'DJIB_PORT' + JOIN freight.yards d ON d.id = ts.destination_station_id AND d.code = 'KALITY' + WHERE ts.deleted_at IS NULL + AND abs(extract(epoch FROM (ts.scheduled_departure_date - $1::timestamptz))) < 14400 + ORDER BY ts.scheduled_departure_date ASC`, + [DEPARTURE_1.toISOString()], + ); +} + +function withBothSchedules(fn: (first: DayScheduleRow, second: DayScheduleRow) => void) { + dayShedules().then(({ rows }) => { + expect(rows, "two schedules on the day").to.have.length(2); + fn(rows[0], rows[1]); + }); +} + +describe("GENERAL two trains, one day: shared window, overflow, dual lifecycle", { retries: 0 }, () => { + before(() => { + cy.task("db:seedFile", "seed-import-corridor.sql"); + USERS.forEach((suffix) => + seedImportContract({ suffix, reference: stampedRef(suffix), kind: "GENERAL" }), + ); + }); + + it("operations schedules TWO 54-wagon trains on one day — one shared window, forced open", () => { + ensureCorridorRoute(); + resetCorridorDay(DEPARTURE_1); + createImportSchedule({ departure: DEPARTURE_1, locoPair: ["LOCO-IMP-13", "LOCO-IMP-14"] }); + createImportSchedule({ departure: DEPARTURE_2, locoPair: ["LOCO-IMP-15", "LOCO-IMP-16"] }); + + // One clock for the whole day: force IDENTICAL window timestamps on both + // siblings (the group rule's shared timeline, arranged deterministically). + withBothSchedules((first, second) => { + db( + `UPDATE freight.train_schedules + SET window_opens_at = now() - interval '1 minute', + window_closes_at = now() + interval '60 minutes' + WHERE id = ANY($1::uuid[])`, + [[first.id, second.id]], + ); + [first.id, second.id].forEach((id) => + pollDb( + `schedule ${id} OPEN`, + `SELECT window_phase, booking_window_status FROM freight.train_schedules WHERE id = $1`, + [id], + (row) => row?.window_phase === "OPEN" && row?.booking_window_status === "OPEN", + ), + ); + }); + }); + + it("20 users book into the day (never a specific train); every booking clears per booking", () => { + USERS.forEach((suffix, i) => { + bookContainers({ + suffix, + runStamp: stamp, + isoSeed: 17_000 + i * 15, + twenty: 12, + scheduledDate: BOOKING_DAY, + }); + clearGeneralBooking(suffix, BOOKING_DAY); + }); + USERS.forEach((suffix, i) => setPriority(suffix, i + 1)); + }); + + it("ONE doc-review-complete releases the whole day — 18 reserved across both trains, 2 wait", () => { + withBothSchedules((first, second) => { + db( + `UPDATE freight.train_schedules + SET window_closes_at = now() - interval '1 second' + WHERE id = ANY($1::uuid[]) AND window_phase = 'OPEN'`, + [[first.id, second.id]], + ); + [first.id, second.id].forEach((id) => + pollDb( + `schedule ${id} DOC_REVIEW`, + `SELECT window_phase FROM freight.train_schedules WHERE id = $1`, + [id], + (row) => row?.window_phase === "DOC_REVIEW", + ), + ); + // Staff complete doc review on ONE train — the group stamp releases both. + completeDocReview(first.id); + pollDb( + "sibling released by the same action", + `SELECT window_phase FROM freight.train_schedules WHERE id = $1`, + [second.id], + (row) => row?.window_phase === "PAYMENT" || row?.window_phase === "DONE", + ); + }); + RIDERS.forEach((suffix) => + pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]), + ); + WAITERS.forEach((suffix) => + withBooking(suffix, (b) => { + expect(b.status, `${suffix} waiting`).to.eq("FULLY_EXECUTED"); + }), + ); + }); + + it("all 18 pay — 54/54 on EACH train, overflow filled earliest-departure-first", () => { + RIDERS.forEach((suffix) => { + markPaid(suffix); + pollAllocations(suffix, 6); + }); + withBothSchedules((first, second) => { + [first.id, second.id].forEach((id) => { + pollDb<{ n: string }>( + `train ${id} carries 9 bookings`, + `SELECT count(*) AS n FROM freight.train_schedule_bookings + WHERE train_schedule_id = $1 AND deleted_at IS NULL`, + [id], + (row) => Number(row?.n) === 9, + 15, + ); + db<{ n: string }>( + `SELECT count(DISTINCT wba.train_set_wagon_id) AS n + FROM freight.wagon_booking_allocations wba + JOIN freight.train_schedule_bookings tsb + ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1 + WHERE wba.deleted_at IS NULL AND tsb.deleted_at IS NULL`, + [id], + ).then(({ rows }) => expect(Number(rows[0].n), "54 wagons").to.eq(54)); + }); + // The top-priority bookings ride the EARLIEST departure. + withBooking("GT01", (b) => + expect(b.train_schedule_id, "GT01 on the first train").to.eq(first.id), + ); + }); + }); + + it("both trains conclude FULL — only then do the 2 waiters expire (sweep defers for siblings)", () => { + withBothSchedules((first, second) => { + db( + `UPDATE freight.train_schedules + SET payment_phase_ends_at = now() - interval '1 second' + WHERE id = ANY($1::uuid[]) AND window_phase = 'PAYMENT'`, + [[first.id, second.id]], + ); + [first.id, second.id].forEach((id) => + pollDb( + `schedule ${id} FULL + DONE`, + `SELECT window_phase, booking_window_status FROM freight.train_schedules WHERE id = $1`, + [id], + (row) => row?.booking_window_status === "FULL" && row?.window_phase === "DONE", + ), + ); + }); + WAITERS.forEach((suffix) => pollBookingStatus(suffix, "EXPIRED")); + }); + + it("dual lifecycle: both trains gate-pass, dispatch, run the corridor and arrive", () => { + withBothSchedules((first, second) => { + [first.id, second.id].forEach((id) => { + pollDb( + `schedule ${id} finalized`, + `SELECT status FROM freight.train_schedules WHERE id = $1`, + [id], + (row) => row?.status === "SCHEDULED", + 10, + ); + apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/import-djibouti/gatepass-granted`) + .its("status") + .should("be.oneOf", [200, 201]); + apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/dispatch`) + .its("status") + .should("be.oneOf", [200, 201]); + [1, 2, 3, 4].forEach((seq) => { + apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/checkpoints`, { + sequenceNo: seq, + kind: "PASSED", + }) + .its("status") + .should("be.oneOf", [200, 201]); + }); + apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/checkpoints`, { + sequenceNo: 5, + kind: "ARRIVED", + }) + .its("status") + .should("be.oneOf", [200, 201]); + pollDb( + `schedule ${id} ARRIVED`, + `SELECT status FROM freight.train_schedules WHERE id = $1`, + [id], + (row) => row?.status === "ARRIVED", + 20, + ); + db<{ n: string }>( + `SELECT count(*) AS n FROM freight.wagon_movements WHERE train_schedule_id = $1`, + [id], + ).then(({ rows }) => + expect(Number(rows[0].n), `train ${id} movements`).to.be.at.least(54), + ); + }); + }); + RIDERS.forEach((suffix) => pollBookingStatus(suffix, "ARRIVED", 20)); + }); +}); + +export {}; diff --git a/e2e/freight/cypress/e2e/flows/import-utils.ts b/e2e/freight/cypress/e2e/flows/import-utils.ts index 62ba74635..67291b0c0 100644 --- a/e2e/freight/cypress/e2e/flows/import-utils.ts +++ b/e2e/freight/cypress/e2e/flows/import-utils.ts @@ -120,6 +120,10 @@ export interface SeedContractOpts { freight?: "CONTAINER" | "BULK"; originCode?: string; destCode?: string; + /** GENERAL = multi-booking drawdown contract (status CONTRACT_ACTIVE). */ + kind?: "ONE_TIME" | "GENERAL"; + /** GENERAL only: quantity cap on the 20ft scope row (40ft stays uncapped). */ + cap20?: number; } export function seedImportContract(opts: SeedContractOpts) { @@ -127,6 +131,8 @@ export function seedImportContract(opts: SeedContractOpts) { const customs = opts.customs ?? false; const direction = opts.direction ?? "IMPORT"; const freight = opts.freight ?? "CONTAINER"; + const kind = opts.kind ?? "ONE_TIME"; + const status = kind === "GENERAL" ? "CONTRACT_ACTIVE" : "FULLY_EXECUTED"; // Pre-booking boundary milestone for Path B contracts differs by direction. const boundary = direction === "EXPORT" ? "EXPORT_RELEASED" : "DO_COLLECTED"; db( @@ -146,11 +152,11 @@ export function seedImportContract(opts: SeedContractOpts) { ELSE 1 END LIMIT 1), - 'ONE_TIME', $2::text, $9::text, + $11::text, $2::text, $9::text, (SELECT st.id FROM freight.service_types st ORDER BY st.created_at LIMIT 1), $3, $4, CASE WHEN $4 THEN 'CLEARANCE_READY_FOR_BOOKING' ELSE 'NOT_APPLICABLE' END, - 'FULLY_EXECUTED', now(), now() - interval '1 day', + $12::text, now(), now() - interval '1 day', now() + interval '60 days', 'E2E import-corridor fixture contract' FROM freight.companies comp WHERE comp.tin = $5 @@ -175,8 +181,10 @@ export function seedImportContract(opts: SeedContractOpts) { RETURNING id ), scope_container AS ( INSERT INTO freight.contract_cargo_scope - (contract_id, container_size, cargo_free_text) - SELECT c.id, v.size, 'E2E import corridor cargo' + (contract_id, container_size, quantity_cap, cargo_free_text) + SELECT c.id, v.size, + CASE WHEN v.size = '20ft' THEN $13::numeric END, + 'E2E import corridor cargo' FROM c CROSS JOIN (VALUES ('20ft'), ('40ft')) AS v(size) WHERE $9::text = 'CONTAINER' ), scope_bulk AS ( @@ -203,6 +211,9 @@ export function seedImportContract(opts: SeedContractOpts) { opts.suffix, freight, boundary, + kind, + status, + opts.cap20 ?? null, ], ); // Backfill the boundary milestone when the insert above was skipped because @@ -414,6 +425,34 @@ export function bookBulk(opts: { }); } +/** + * Walk a GENERAL booking through its PER-BOOKING clearance chain (Path A): + * AWAITING_DOCUMENTS → upload one doc → GL approves it → finalize → + * CLEARANCE_READY → customer proceeds with the shipment day → + * OPERATION_REQUEST_PENDING → ops accept → FULLY_EXECUTED (pool). The e2e + * seed configures no required documents, so one ad-hoc doc satisfies the + * 100%-approved gate. + */ +export function clearGeneralBooking(suffix: string, scheduledDate: string) { + withBooking(suffix, (b) => { + expect(b.status, `${suffix} starts in the clearance gate`).to.eq("AWAITING_DOCUMENTS"); + glUpload(`/api/bookings/${b.id}/clearance/documents`, {}, "custom_e2e"); + apiPost(superAdmin, `/api/bookings/${b.id}/clearance/review`, { + fileKey: "custom_e2e", + status: "APPROVED", + }) + .its("status") + .should("be.oneOf", [200, 201]); + apiPost(superAdmin, `/api/bookings/${b.id}/clearance/finalize`) + .its("status") + .should("be.oneOf", [200, 201]); + apiPost(customer, `/api/bookings/${b.id}/clearance/proceed`, { scheduledDate }) + .its("status") + .should("be.oneOf", [200, 201]); + }); + acceptOperation(suffix); +} + /** Ops accepts the operation request → FULLY_EXECUTED (enters the day pool). */ export function acceptOperation(suffix: string) { withBooking(suffix, (b) => { diff --git a/e2e/freight/cypress/e2e/flows/mixed_import_reopen_matrix.cy.ts b/e2e/freight/cypress/e2e/flows/mixed_import_reopen_matrix.cy.ts index 4d73acd81..9be4636c3 100644 --- a/e2e/freight/cypress/e2e/flows/mixed_import_reopen_matrix.cy.ts +++ b/e2e/freight/cypress/e2e/flows/mixed_import_reopen_matrix.cy.ts @@ -111,11 +111,14 @@ describe("mixed import: dead mixed cycle, mixed recovery, mixed ride-alongs", { ["MRC", "MRB"].forEach((suffix) => forceReservationExpiry(suffix)); withSchedule(DEPARTURE, (s) => { endPaymentPhase(s.id); + // Same-tick guard-loop can chain PRE_WINDOW straight into OPEN when the + // reopen instant falls inside office hours — assert it left PAYMENT + // without concluding FULL/DONE, whichever phase it lands on. pollDb( - "window reopens (PRE_WINDOW)", + "window concludes not-full (PRE_WINDOW or fast-forwarded to OPEN)", `SELECT window_phase FROM freight.train_schedules WHERE id = $1`, [s.id], - (row) => row?.window_phase === "PRE_WINDOW", + (row) => !!row && row.window_phase !== "PAYMENT" && row.window_phase !== "DONE", ); }); }); diff --git a/e2e/freight/cypress/fixtures/seed-company.sql b/e2e/freight/cypress/fixtures/seed-company.sql index 73b01bee1..f48bfaf81 100644 --- a/e2e/freight/cypress/fixtures/seed-company.sql +++ b/e2e/freight/cypress/fixtures/seed-company.sql @@ -38,11 +38,13 @@ WHERE c.tin = '0102030405' ); -- 2c. Link the demo portal user to the company, onboarding already done. +-- (active_profile_type was dropped by migration 2450 — the "active mode" +-- column no longer exists; bookings resolve the profile per shipment.) INSERT INTO freight.external_profiles (id, user_id, company_id, first_name, last_name, is_primary_contact, - active_profile_type, onboarding_step, onboarding_completed) + onboarding_step, onboarding_completed) SELECT gen_random_uuid(), u.id, c.id, 'Demo', 'User', true, - 'importer', 'done', true + 'done', true FROM iam.users u JOIN freight.companies c ON c.tin = '0102030405' WHERE u.email = 'user@gmail.com' From 73f36fe46e30a4dc5f003043dcda17b47ce25420 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 23 Jul 2026 11:31:00 +0000 Subject: [PATCH 03/16] =?UTF-8?q?Export=20interchange=20document=20?= =?UTF-8?q?=E2=80=94=20a=20generation=20error=20after=20Djibouti=20unloadi?= =?UTF-8?q?ng=20failed=20the=20whole=20request=20and=20left=20the=20docume?= =?UTF-8?q?nt=20missing=20until=20a=20manual=20rerun.=20Generation=20is=20?= =?UTF-8?q?now=20best-effort=20(unload=20never=20fails=20on=20paperwork)?= =?UTF-8?q?=20and=20reruns=20backfill=20the=20document.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...2810000000000-AddMaintenanceServiceItem.ts | 47 +++ .../maintenance/dto/create-maintenance.dto.ts | 35 ++ .../entities/maintenance-interval.entity.ts | 15 +- .../entities/maintenance-schedule.entity.ts | 4 + .../maintenance/maintenance-auto-next.spec.ts | 99 +++++ .../maintenance-interval.repository.ts | 33 +- .../maintenance/maintenance.controller.ts | 28 +- .../maintenance/maintenance.repository.ts | 6 +- .../maintenance/maintenance.service.ts | 98 +++-- .../warehouses/warehouse-inventory.service.ts | 48 ++- .../backoffice/src/constants/QUERY_KEYS.ts | 2 + .../src/pages/fleet/MaintenancePage.tsx | 392 +++++++++++++++--- 12 files changed, 694 insertions(+), 113 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2810000000000-AddMaintenanceServiceItem.ts create mode 100644 apps/edr-freight-api/src/modules/maintenance/maintenance-auto-next.spec.ts diff --git a/apps/edr-freight-api/src/migrations/2810000000000-AddMaintenanceServiceItem.ts b/apps/edr-freight-api/src/migrations/2810000000000-AddMaintenanceServiceItem.ts new file mode 100644 index 000000000..983aa6e64 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2810000000000-AddMaintenanceServiceItem.ts @@ -0,0 +1,47 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Named service items for KM-based maintenance ("oil change", "tires", …). + * The coarse maintenance_type enum (PREVENTIVE/…) allowed only one interval + * per type per vehicle, so oil and tire intervals could not coexist. Interval + * identity becomes (vehicle, maintenance_type, service_item); schedules carry + * the item so completion re-finds the right interval for auto-scheduling. + */ +export class AddMaintenanceServiceItem2810000000000 implements MigrationInterface { + name = 'AddMaintenanceServiceItem2810000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.maintenance_intervals ADD COLUMN IF NOT EXISTS service_item varchar(120);`, + ); + await queryRunner.query( + `ALTER TABLE freight.maintenance_schedules ADD COLUMN IF NOT EXISTS service_item varchar(120);`, + ); + // Re-key interval uniqueness on (vehicle, type, item). COALESCE folds the + // item-less legacy rows into one slot; soft-deleted rows are ignored. + await queryRunner.query( + `DROP INDEX IF EXISTS freight."UQ_maintenance_intervals_vehicle_type";`, + ); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_maintenance_intervals_vehicle_type_item" + ON freight.maintenance_intervals (vehicle_id, maintenance_type, COALESCE(service_item, '')) + WHERE deleted_at IS NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS freight."UQ_maintenance_intervals_vehicle_type_item";`, + ); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_maintenance_intervals_vehicle_type" + ON freight.maintenance_intervals (vehicle_id, maintenance_type); + `); + await queryRunner.query( + `ALTER TABLE freight.maintenance_schedules DROP COLUMN IF EXISTS service_item;`, + ); + await queryRunner.query( + `ALTER TABLE freight.maintenance_intervals DROP COLUMN IF EXISTS service_item;`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/maintenance/dto/create-maintenance.dto.ts b/apps/edr-freight-api/src/modules/maintenance/dto/create-maintenance.dto.ts index d3e70acff..129262ef0 100644 --- a/apps/edr-freight-api/src/modules/maintenance/dto/create-maintenance.dto.ts +++ b/apps/edr-freight-api/src/modules/maintenance/dto/create-maintenance.dto.ts @@ -8,6 +8,11 @@ export class CreateMaintenanceScheduleDto { @IsEnum(MaintenanceType) maintenanceType!: MaintenanceType; + /** What is serviced — matched against the interval for auto-scheduling. */ + @IsOptional() + @IsString() + serviceItem?: string; + @IsString() description!: string; @@ -81,7 +86,37 @@ export class UpdateMaintenanceScheduleDto { @IsNumber() actualCost?: number; + /** Odometer at completion — drives KM-based auto-scheduling of the next service. */ + @IsOptional() + @IsNumber() + odometerReading?: number; + @IsOptional() @IsString() notes?: string; } + +export class UpsertMaintenanceIntervalDto { + @IsUUID() + vehicleId!: string; + + @IsEnum(MaintenanceType) + maintenanceType!: MaintenanceType; + + /** What is serviced — "oil change", "tires", … Distinguishes intervals of the same type. */ + @IsOptional() + @IsString() + serviceItem?: string; + + @IsOptional() + @IsNumber() + intervalKm?: number; + + @IsOptional() + @IsNumber() + intervalDays?: number; + + @IsOptional() + @IsString() + description?: string; +} diff --git a/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-interval.entity.ts b/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-interval.entity.ts index 095123c09..640c3e26e 100644 --- a/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-interval.entity.ts +++ b/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-interval.entity.ts @@ -1,15 +1,18 @@ import { BaseEntity } from '@edr/api-common'; -import { Entity, Column, ManyToOne, JoinColumn, Index, Unique } from 'typeorm'; +import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm'; import { Vehicle } from '../../vehicles/entities/vehicle.entity'; import { MaintenanceType } from './maintenance-schedule.entity'; /** - * Maintenance interval configuration. Defines how often a vehicle/type needs maintenance. - * Each vehicle can have different intervals for different maintenance types (e.g., oil every 10k km, tires every 50k km). + * Maintenance interval configuration. Defines how often a vehicle needs a + * given service. Identity is (vehicle, maintenanceType, serviceItem) — a + * vehicle carries several intervals of the same coarse type with different + * items (oil every 10k km, tires every 50k km, both PREVENTIVE). Uniqueness + * is enforced by a COALESCE expression index in the migration (nullable + * service_item), not a TypeORM @Unique. */ @Entity({ name: 'maintenance_intervals', schema: 'freight' }) @Index(['vehicleId', 'maintenanceType']) -@Unique(['vehicleId', 'maintenanceType']) export class MaintenanceInterval extends BaseEntity { @Column({ name: 'vehicle_id', type: 'uuid' }) vehicleId!: string; @@ -21,6 +24,10 @@ export class MaintenanceInterval extends BaseEntity { @Column({ name: 'maintenance_type', type: 'varchar' }) maintenanceType!: MaintenanceType; + /** What is serviced — "oil change", "tires", … Null = generic for the type. */ + @Column({ name: 'service_item', type: 'varchar', length: 120, nullable: true }) + serviceItem?: string | null; + /** Maintenance interval in kilometers. E.g., 10000 for oil changes every 10k km. */ @Column({ name: 'interval_km', type: 'numeric', precision: 14, scale: 2, nullable: true }) intervalKm?: number | null; diff --git a/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-schedule.entity.ts b/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-schedule.entity.ts index 7d3ab7a95..5a0cc074a 100644 --- a/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-schedule.entity.ts +++ b/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-schedule.entity.ts @@ -30,6 +30,10 @@ export class MaintenanceSchedule extends BaseEntity { @Column({ name: 'maintenance_type', type: 'varchar' }) maintenanceType!: MaintenanceType; + /** What is serviced — matches the interval's service_item for auto-scheduling. */ + @Column({ name: 'service_item', type: 'varchar', length: 120, nullable: true }) + serviceItem?: string | null; + @Column({ name: 'description' }) description!: string; diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance-auto-next.spec.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance-auto-next.spec.ts new file mode 100644 index 000000000..1b020455c --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance-auto-next.spec.ts @@ -0,0 +1,99 @@ +import { MaintenanceService } from './maintenance.service'; +import { MaintenanceStatus } from './entities/maintenance-schedule.entity'; + +/** + * KM-based auto-scheduling: completing a maintenance with an odometer reading + * creates the next SCHEDULED item at completedKm + intervalKm, matched on the + * schedule's (type, serviceItem) interval. Re-completing must not duplicate. + */ +function makeService(opts: { + before: Record | null; + after: Record | null; + interval: Record | null; +}) { + const saved: Array> = []; + const service = Object.create(MaintenanceService.prototype) as Record; + service.scheduleRepository = { + findOneBy: jest + .fn() + .mockResolvedValueOnce(opts.before) + .mockResolvedValueOnce(opts.after), + update: jest.fn(), + create: jest.fn((v: Record) => v), + save: jest.fn(async (v: Record) => { + saved.push(v); + return v; + }), + }; + service.intervalRepository = { + getByVehicleAndType: jest.fn().mockResolvedValue(opts.interval), + }; + service.dataSource = { + getRepository: jest.fn().mockReturnValue({ update: jest.fn() }), + }; + service.logger = { error: jest.fn() }; + return { service: service as unknown as MaintenanceService, saved }; +} + +const base = { + id: 's-1', + vehicleId: 'v-1', + maintenanceType: 'PREVENTIVE', + serviceItem: 'oil change', + description: 'Oil and filter', +}; + +describe('MaintenanceService auto-next scheduling', () => { + it('completing at 50,000 km with a 10,000 km interval schedules the next at 60,000', async () => { + const { service, saved } = makeService({ + before: { ...base, status: MaintenanceStatus.SCHEDULED }, + after: { ...base, status: MaintenanceStatus.COMPLETED, odometerReading: 50000 }, + interval: { serviceItem: 'oil change', intervalKm: '10000.00', intervalDays: null, description: 'Oil and filter' }, + }); + + await service.updateMaintenanceSchedule('s-1', { + status: MaintenanceStatus.COMPLETED, + odometerReading: 50000, + }); + + expect(saved).toHaveLength(1); + expect(saved[0]).toMatchObject({ + vehicleId: 'v-1', + serviceItem: 'oil change', + nextDueKm: 60000, + status: MaintenanceStatus.SCHEDULED, + }); + }); + + it('re-completing an already COMPLETED schedule does not duplicate the next one', async () => { + const { service, saved } = makeService({ + before: { ...base, status: MaintenanceStatus.COMPLETED, odometerReading: 50000 }, + after: { ...base, status: MaintenanceStatus.COMPLETED, odometerReading: 50000 }, + interval: { serviceItem: 'oil change', intervalKm: '10000.00', intervalDays: null }, + }); + + await service.updateMaintenanceSchedule('s-1', { + status: MaintenanceStatus.COMPLETED, + odometerReading: 50000, + }); + + expect(saved).toHaveLength(0); + }); + + it('a km + days interval produces ONE next schedule carrying both thresholds', async () => { + const { service, saved } = makeService({ + before: { ...base, status: MaintenanceStatus.IN_PROGRESS }, + after: { ...base, status: MaintenanceStatus.COMPLETED, odometerReading: 20000 }, + interval: { serviceItem: 'oil change', intervalKm: '10000.00', intervalDays: 180 }, + }); + + await service.updateMaintenanceSchedule('s-1', { + status: MaintenanceStatus.COMPLETED, + odometerReading: 20000, + }); + + expect(saved).toHaveLength(1); + expect(saved[0].nextDueKm).toBe(30000); + expect(saved[0].nextDueDate).toBeInstanceOf(Date); + }); +}); diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance-interval.repository.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance-interval.repository.ts index eded86274..d884cdf55 100644 --- a/apps/edr-freight-api/src/modules/maintenance/maintenance-interval.repository.ts +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance-interval.repository.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { BaseRepository } from '@edr/api-common'; -import { Repository } from 'typeorm'; +import { IsNull, Repository } from 'typeorm'; import { MaintenanceInterval } from './entities/maintenance-interval.entity'; import { MaintenanceType } from './entities/maintenance-schedule.entity'; @@ -14,27 +14,44 @@ export class MaintenanceIntervalRepository extends BaseRepository { + /** + * Resolve the interval for a completed service. Prefers the exact + * (type, serviceItem) match; a completion without an item falls back to the + * type's item-less interval only, so "oil" completions never consume the + * "tires" interval. + */ + async getByVehicleAndType( + vehicleId: string, + maintenanceType: MaintenanceType, + serviceItem?: string | null, + ): Promise { return this.intervalRepository.findOne({ - where: { vehicleId, maintenanceType, isActive: true }, + where: { + vehicleId, + maintenanceType, + isActive: true, + serviceItem: serviceItem?.trim() ? serviceItem.trim() : IsNull(), + }, }); } async getActiveIntervals(vehicleId: string): Promise { return this.intervalRepository.find({ where: { vehicleId, isActive: true }, - order: { maintenanceType: 'ASC' }, + order: { maintenanceType: 'ASC', serviceItem: 'ASC' }, }); } async upsertInterval( vehicleId: string, maintenanceType: MaintenanceType, + serviceItem?: string | null, intervalKm?: number | null, intervalDays?: number | null, description?: string | null, ): Promise { - const existing = await this.getByVehicleAndType(vehicleId, maintenanceType); + const item = serviceItem?.trim() || null; + const existing = await this.getByVehicleAndType(vehicleId, maintenanceType, item); if (existing) { await this.intervalRepository.update(existing.id, { @@ -50,6 +67,7 @@ export class MaintenanceIntervalRepository extends BaseRepository { + await this.intervalRepository.update(id, { isActive: false }); + } } diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts index 9324ad694..32e5dfba6 100644 --- a/apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts @@ -4,7 +4,12 @@ import { BookingStaff } from '../../common/booking-guards'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { MaintenanceService } from './maintenance.service'; import { MaintenanceDepthService } from './maintenance-depth.service'; -import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto'; +import { + CreateMaintenanceScheduleDto, + CreateMaintenanceCostDto, + UpdateMaintenanceScheduleDto, + UpsertMaintenanceIntervalDto, +} from './dto/create-maintenance.dto'; import { CreateWorkOrderDto, UpdateWorkOrderDto, @@ -51,6 +56,27 @@ export class MaintenanceController { return this.maintenanceService.getDueBoard(); } + @Post('intervals') + @BookingStaff(FREIGHT_PERMS.maintenance.create) + @ApiOperation({ summary: 'Define/adjust a service interval (e.g. oil change every 10,000 km)' }) + async upsertInterval(@Body() dto: UpsertMaintenanceIntervalDto) { + return this.maintenanceService.upsertInterval(dto); + } + + @Get('intervals/:vehicleId') + @BookingStaff(FREIGHT_PERMS.maintenance.view) + @ApiOperation({ summary: "A vehicle's active service intervals" }) + async getIntervals(@Param('vehicleId') vehicleId: string) { + return this.maintenanceService.getIntervals(vehicleId); + } + + @Delete('intervals/:id') + @BookingStaff(FREIGHT_PERMS.maintenance.delete) + @ApiOperation({ summary: 'Deactivate a service interval (stops auto-scheduling)' }) + async deactivateInterval(@Param('id') id: string) { + return this.maintenanceService.deactivateInterval(id); + } + @Get('upcoming/:vehicleId') @BookingStaff(FREIGHT_PERMS.maintenance.view) @ApiOperation({ summary: 'Get upcoming maintenance' }) diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.repository.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.repository.ts index 8e58702ac..b417e72cf 100644 --- a/apps/edr-freight-api/src/modules/maintenance/maintenance.repository.ts +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.repository.ts @@ -61,6 +61,7 @@ export class MaintenanceRepository extends BaseRepository { vehicleId: string; plateNumber: string; maintenanceType: string; + serviceItem: string | null; description: string; scheduledDate: Date; nextDueDate: Date | null; @@ -71,12 +72,15 @@ export class MaintenanceRepository extends BaseRepository { overdue: boolean; }> > { + // Every SCHEDULED item, not one per vehicle — a truck legitimately holds + // several (oil vs tires intervals differ). return this.scheduleRepository.manager.query(` - SELECT DISTINCT ON (s.vehicle_id) + SELECT s.id AS "scheduleId", s.vehicle_id AS "vehicleId", v.plate_number AS "plateNumber", s.maintenance_type AS "maintenanceType", + s.service_item AS "serviceItem", s.description, s.scheduled_date AS "scheduledDate", s.next_due_date AS "nextDueDate", diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts index 283ca5eea..dd9da55a8 100644 --- a/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts @@ -8,7 +8,12 @@ import { MaintenanceIntervalRepository } from './maintenance-interval.repository import { MaintenanceSchedule, MaintenanceStatus, MaintenanceType } from './entities/maintenance-schedule.entity'; import { MaintenanceCost } from './entities/maintenance-cost.entity'; import { Vehicle, VehicleAvailability, VehicleStatus } from '../vehicles/entities/vehicle.entity'; -import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto'; +import { + CreateMaintenanceScheduleDto, + CreateMaintenanceCostDto, + UpdateMaintenanceScheduleDto, + UpsertMaintenanceIntervalDto, +} from './dto/create-maintenance.dto'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; @Injectable() @@ -107,6 +112,10 @@ export class MaintenanceService { id: string, dto: UpdateMaintenanceScheduleDto, ): Promise { + // Status BEFORE the write: completing an already-COMPLETED schedule again + // must not auto-create a second "next" schedule. + const before = await this.scheduleRepository.findOneBy({ id }); + await this.scheduleRepository.update(id, { ...dto, completedDate: dto.completedDate ? new Date(dto.completedDate) : undefined, @@ -122,8 +131,12 @@ export class MaintenanceService { // Maintenance finished/aborted → vehicle back in service. await this.setVehicleMaintenanceState(updated.vehicleId, false); - // If completed, schedule the next maintenance based on interval - if (dto.status === MaintenanceStatus.COMPLETED && updated.odometerReading != null) { + // First transition into COMPLETED with an odometer → auto-schedule next. + if ( + dto.status === MaintenanceStatus.COMPLETED && + before?.status !== MaintenanceStatus.COMPLETED && + updated.odometerReading != null + ) { await this.scheduleNextMaintenance(updated); } } else if (dto.status === MaintenanceStatus.IN_PROGRESS) { @@ -135,52 +148,75 @@ export class MaintenanceService { return updated!; } + /** Define/adjust how often a vehicle needs a service ("oil change every 10,000 km"). */ + async upsertInterval(dto: UpsertMaintenanceIntervalDto) { + return this.intervalRepository.upsertInterval( + dto.vehicleId, + dto.maintenanceType, + dto.serviceItem ?? null, + dto.intervalKm ?? null, + dto.intervalDays ?? null, + dto.description ?? null, + ); + } + + async getIntervals(vehicleId: string) { + return this.intervalRepository.getActiveIntervals(vehicleId); + } + + async deactivateInterval(id: string): Promise<{ id: string; deactivated: boolean }> { + await this.intervalRepository.deactivate(id); + return { id, deactivated: true }; + } + + /** + * Auto-schedule the next service after a completion: matched on the + * completed schedule's (type, serviceItem) interval; one SCHEDULED row + * carrying BOTH thresholds when the interval defines km and days — + * whichever is crossed first makes it due. + */ private async scheduleNextMaintenance(completed: MaintenanceSchedule): Promise { try { - // Get maintenance interval for this type const interval = await this.intervalRepository.getByVehicleAndType( completed.vehicleId, completed.maintenanceType as MaintenanceType, + completed.serviceItem, ); if (!interval) return; // No interval defined, skip auto-scheduling const now = new Date(); const completedKm = Number(completed.odometerReading ?? 0); + const intervalKm = Number(interval.intervalKm ?? 0); + const intervalDays = Number(interval.intervalDays ?? 0); + if (intervalKm <= 0 && intervalDays <= 0) return; - // Calculate next due based on KM interval - if (interval.intervalKm && interval.intervalKm > 0) { - const nextDueKm = completedKm + Number(interval.intervalKm); + const nextDueKm = intervalKm > 0 ? completedKm + intervalKm : undefined; + const nextDueDate = + intervalDays > 0 + ? new Date(now.getTime() + intervalDays * 24 * 60 * 60 * 1000) + : undefined; - // Create next scheduled maintenance - const nextSchedule = this.scheduleRepository.create({ + const label = interval.serviceItem ? `${interval.serviceItem}: ` : ''; + const due = [ + nextDueKm != null ? `${nextDueKm} km` : null, + nextDueDate != null ? nextDueDate.toISOString().slice(0, 10) : null, + ] + .filter(Boolean) + .join(' / '); + + await this.scheduleRepository.save( + this.scheduleRepository.create({ vehicleId: completed.vehicleId, maintenanceType: completed.maintenanceType, - description: `${interval.description || completed.description} (Next interval: ${nextDueKm} km)`, + serviceItem: completed.serviceItem ?? interval.serviceItem ?? null, + description: `${label}${interval.description || completed.description} (next due: ${due})`, scheduledDate: now, nextDueKm, + nextDueDate, status: MaintenanceStatus.SCHEDULED, - }); - await this.scheduleRepository.save(nextSchedule); - } - - // Calculate next due based on date interval - if (interval.intervalDays && interval.intervalDays > 0) { - const nextDueDate = new Date(now.getTime() + interval.intervalDays * 24 * 60 * 60 * 1000); - - // If no KM-based next maintenance was created, use date-based - if (!interval.intervalKm) { - const nextSchedule = this.scheduleRepository.create({ - vehicleId: completed.vehicleId, - maintenanceType: completed.maintenanceType, - description: completed.description, - scheduledDate: now, - nextDueDate, - status: MaintenanceStatus.SCHEDULED, - }); - await this.scheduleRepository.save(nextSchedule); - } - } + }), + ); } catch (err) { this.logger.error( `Failed to schedule next maintenance for vehicle ${completed.vehicleId}: ${(err as Error).message}`, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index e42cbdcea..582face93 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -2504,27 +2504,37 @@ export class WarehouseInventoryService { }); if (result.unloadedCount > 0) { - let document = await this.interchangeDocuments.generateFromSchedule({ - scheduleId, - direction: 'EXPORT', - handoverLocation: schedule.destinationName ?? 'Djibouti Port', - handoverFrom: 'EDR', - handoverTo: 'Djibouti Port Operator', - portOperatorName: 'Doraleh Multipurpose Port', - generatedBy: performedBy ?? 'EDR Operations', - remarks: 'Generated after export unloading at Djibouti Port; signed by EDR and Djibouti Port Operator.', - }); - if (document.status !== 'ACKNOWLEDGED') { - document = await this.interchangeDocuments.acknowledge(document.id, { - acknowledgedBy: 'Djibouti Port Operator', - remarks: 'Auto acknowledged after Djibouti export unloading.', + // Best-effort: the unload is already committed — a paperwork failure must + // not fail the response (it did once: items unloaded, request 500'd, and + // the document only appeared after a manual retry days later). The doc + // backfills on any retry since already-unloaded items count as unloaded. + try { + let document = await this.interchangeDocuments.generateFromSchedule({ + scheduleId, + direction: 'EXPORT', + handoverLocation: schedule.destinationName ?? 'Djibouti Port', + handoverFrom: 'EDR', + handoverTo: 'Djibouti Port Operator', + portOperatorName: 'Doraleh Multipurpose Port', + generatedBy: performedBy ?? 'EDR Operations', + remarks: 'Generated after export unloading at Djibouti Port; signed by EDR and Djibouti Port Operator.', }); + if (document.status !== 'ACKNOWLEDGED') { + document = await this.interchangeDocuments.acknowledge(document.id, { + acknowledgedBy: 'Djibouti Port Operator', + remarks: 'Auto acknowledged after Djibouti export unloading.', + }); + } + result.interchangeDocument = { + id: document.id, + documentNo: document.documentNo, + status: document.status, + }; + } catch (err) { + this.logger.warn( + `Export interchange document generation failed for schedule ${scheduleId}: ${(err as Error).message} — rerun the Djibouti unloading to regenerate it`, + ); } - result.interchangeDocument = { - id: document.id, - documentNo: document.documentNo, - status: document.status, - }; } return result; diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index 80645d256..c57a607d5 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -208,6 +208,8 @@ export const QUERY_KEYS = { ["maintenance", "history", vehicleId ?? "all"] as const, stats: (vehicleId?: string) => ["maintenance", "stats", vehicleId ?? "all"] as const, + intervals: (vehicleId?: string) => + ["maintenance", "intervals", vehicleId ?? "all"] as const, }, FINANCIAL_REPORTS: { diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx index 0fcb35a27..12f5181d6 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx @@ -14,8 +14,10 @@ import { Text, Title, Container, + ActionIcon, + Tooltip, } from '@mantine/core'; -import { Plus } from 'lucide-react'; +import { CheckCircle2, Plus, Trash2 } from 'lucide-react'; import Breadcrumbs from '@/components/ui/Breadcrumbs'; import { useToast } from '@/hooks/use-toast'; import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; @@ -26,6 +28,7 @@ interface MaintenanceSchedule { id: string; vehicleId: string; maintenanceType: string; + serviceItem?: string | null; description: string; scheduledDate: string; completedDate?: string; @@ -40,6 +43,7 @@ interface DueBoardRow { vehicleId: string; plateNumber: string; maintenanceType: string; + serviceItem: string | null; description: string; scheduledDate: string; nextDueDate: string | null; @@ -50,8 +54,20 @@ interface DueBoardRow { overdue: boolean; } +interface MaintenanceInterval { + id: string; + vehicleId: string; + maintenanceType: string; + serviceItem: string | null; + intervalKm: number | null; + intervalDays: number | null; + description: string | null; + isActive: boolean; +} + const emptyForm = { maintenanceType: 'PREVENTIVE', + serviceItem: '', description: '', scheduledDate: new Date().toISOString().split('T')[0], estimatedCost: 0, @@ -59,12 +75,24 @@ const emptyForm = { notes: '', }; +const emptyIntervalForm = { + maintenanceType: 'PREVENTIVE', + serviceItem: '', + intervalKm: '' as number | '', + intervalDays: '' as number | '', + description: '', +}; + export function MaintenancePage() { const { toast } = useToast(); const queryClient = useQueryClient(); const [selectedVehicle, setSelectedVehicle] = useState(null); const [openScheduleModal, setOpenScheduleModal] = useState(false); const [formData, setFormData] = useState(emptyForm); + const [intervalForm, setIntervalForm] = useState(emptyIntervalForm); + const [completeTarget, setCompleteTarget] = useState(null); + const [completeOdometer, setCompleteOdometer] = useState(''); + const [completeCost, setCompleteCost] = useState(''); // Maintenance is driven by time AND km, not a picked-then-scheduled action — // this is the fleet-wide board of what's actually due, by date or mileage. @@ -94,7 +122,32 @@ export function MaintenancePage() { enabled: !!selectedVehicle, }); + const { data: intervals } = useQuery({ + queryKey: QUERY_KEYS.MAINTENANCE.intervals(selectedVehicle || ''), + queryFn: async () => { + if (!selectedVehicle) return []; + const res = await api.get(`/maintenance/intervals/${selectedVehicle}`); + return (res.data || []) as MaintenanceInterval[]; + }, + enabled: !!selectedVehicle, + }); + const upcomingList: MaintenanceSchedule[] = Array.isArray(upcoming) ? upcoming : []; + const intervalList: MaintenanceInterval[] = Array.isArray(intervals) ? intervals : []; + + const invalidateVehicle = () => { + void queryClient.invalidateQueries({ queryKey: QUERY_KEYS.MAINTENANCE.ROOT }); + }; + + const onError = (err: unknown) => { + toast({ + title: 'Error', + description: + (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? + 'Failed', + variant: 'destructive', + }); + }; const scheduleMutation = useMutation({ mutationFn: async () => { @@ -102,24 +155,79 @@ export function MaintenancePage() { const res = await api.post('/maintenance/schedules', { vehicleId: selectedVehicle, ...formData, + serviceItem: formData.serviceItem.trim() || undefined, }); return res.data; }, onSuccess: () => { toast({ title: 'Maintenance scheduled' }); - queryClient.invalidateQueries({ - queryKey: QUERY_KEYS.MAINTENANCE.upcoming(selectedVehicle || ''), - }); + invalidateVehicle(); setOpenScheduleModal(false); setFormData(emptyForm); }, - onError: (err: any) => { - toast({ - title: 'Error', - description: err?.response?.data?.message ?? 'Failed', - variant: 'destructive', + onError, + }); + + // Interval upsert: "oil change every 10,000 km" — drives the auto-scheduling + // of the next service when a maintenance completes with an odometer reading. + const intervalMutation = useMutation({ + mutationFn: async () => { + if (!selectedVehicle) return; + const res = await api.post('/maintenance/intervals', { + vehicleId: selectedVehicle, + maintenanceType: intervalForm.maintenanceType, + serviceItem: intervalForm.serviceItem.trim() || undefined, + intervalKm: intervalForm.intervalKm === '' ? undefined : Number(intervalForm.intervalKm), + intervalDays: + intervalForm.intervalDays === '' ? undefined : Number(intervalForm.intervalDays), + description: intervalForm.description.trim() || undefined, }); + return res.data; }, + onSuccess: () => { + toast({ title: 'Interval saved' }); + invalidateVehicle(); + setIntervalForm(emptyIntervalForm); + }, + onError, + }); + + const deactivateIntervalMutation = useMutation({ + mutationFn: async (id: string) => api.delete(`/maintenance/intervals/${id}`), + onSuccess: () => { + toast({ title: 'Interval deactivated' }); + invalidateVehicle(); + }, + onError, + }); + + // Completion with odometer: the reading is what advances KM-based + // scheduling — the API auto-creates the next SCHEDULED item from it. + const completeMutation = useMutation({ + mutationFn: async () => { + if (!completeTarget) return; + const res = await api.patch(`/maintenance/schedules/${completeTarget.id}`, { + status: 'COMPLETED', + completedDate: new Date().toISOString(), + odometerReading: completeOdometer === '' ? undefined : Number(completeOdometer), + actualCost: completeCost === '' ? undefined : Number(completeCost), + }); + return res.data; + }, + onSuccess: () => { + toast({ + title: 'Maintenance completed', + description: + completeOdometer === '' + ? 'No odometer recorded — next service was NOT auto-scheduled.' + : 'Next service auto-scheduled from the recorded odometer.', + }); + invalidateVehicle(); + setCompleteTarget(null); + setCompleteOdometer(''); + setCompleteCost(''); + }, + onError, }); const vehicleOptions = @@ -170,6 +278,7 @@ export function MaintenancePage() { Vehicle Type + Service Item Next Due Date Next Due Km Current Km @@ -186,6 +295,7 @@ export function MaintenancePage() { > {row.plateNumber} {row.maintenanceType} + {row.serviceItem ?? '—'} {row.nextDueDate ? new Date(row.nextDueDate).toLocaleDateString() : '—'} @@ -231,50 +341,179 @@ export function MaintenancePage() { ) : ( - - - Upcoming Maintenance - - - {isLoading ? ( - Loading... - ) : upcomingList.length > 0 ? ( - - - - Type - Description - Scheduled - Est. Cost - Status - - - - {upcomingList.map((m) => ( - - {m.maintenanceType} - {m.description} - {new Date(m.scheduledDate).toLocaleDateString()} - - {m.estimatedCost != null - ? `ETB ${Number(m.estimatedCost).toLocaleString('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })}` - : '—'} - - - {m.status} - + <> + + + Service Intervals — drives auto-scheduling + + e.g. oil change every 10,000 km. On completion with an odometer reading, the + next service is scheduled automatically at reading + interval. + + + + + {intervalList.length > 0 && ( +
+ + + Type + Service Item + Every (km) + Every (days) + Description + + + + + {intervalList.map((i) => ( + + {i.maintenanceType} + {i.serviceItem ?? '—'} + {i.intervalKm ?? '—'} + {i.intervalDays ?? '—'} + {i.description ?? '—'} + + + deactivateIntervalMutation.mutate(i.id)} + > + + + + + + ))} + +
+ )} + + + @@ -129,9 +187,15 @@ export default function AdminFormModal({ name="name.am" render={({ field }) => ( - {t("orgAdmins.form.nameAm")} + + {t("orgAdmins.form.nameAm")} + + - + @@ -143,9 +207,15 @@ export default function AdminFormModal({ name="username" render={({ field }) => ( - {t("orgAdmins.form.username")} + + {t("orgAdmins.form.username")} + + - + @@ -156,11 +226,14 @@ export default function AdminFormModal({ name="email" render={({ field }) => ( - {t("orgAdmins.form.email")} + + {t("orgAdmins.form.email")} + + @@ -173,10 +246,14 @@ export default function AdminFormModal({ name="phoneNumber" render={({ field }) => ( - {t("orgAdmins.form.phoneNumber")} + + {t("orgAdmins.form.phoneNumber")} + + @@ -185,6 +262,98 @@ export default function AdminFormModal({ )} /> + {!isEdit && ( + <> + ( + + + {t("organization.organization")} + + + + + + )} + /> + {(isLoadingUnits || units.length > 0 || !selectedOrgId) && ( + ( + + + {t("orgAdmins.form.unit")} + + + + + + )} + /> + )} +

+ {!isLoadingUnits && selectedOrgId && units.length === 0 + ? t("orgAdmins.add.noUnitsOrgAdmin") + : t("orgAdmins.add.inviteNote")} +

+ + )} + + {apiError && ( +
+ {apiError} +
+ )} + - diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/AssignExistingAdminModal.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/AssignExistingAdminModal.tsx index 0390f2671..870d75486 100644 --- a/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/AssignExistingAdminModal.tsx +++ b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/AssignExistingAdminModal.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Check, Loader2, UserPlus } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Button } from "@/shared/common/ui/button"; @@ -11,28 +11,41 @@ import { DialogTitle, } from "@/shared/common/ui/dialog"; import { Input } from "@/shared/common/ui/input"; +import { Label } from "@/shared/common/ui/label"; import { ScrollArea } from "@/shared/common/ui/scroll-area"; import { Badge } from "@/shared/common/ui/badge"; import { cn } from "@/super-admin/lib/utils"; import { useLocalizedName } from "@/shared/common/localizedName"; import { useEmployees } from "@/user-management/hooks/useEmployees"; +import { useUnit } from "@/user-management/hooks/useUnit"; +import { useOrganizations } from "@/super-admin/hooks/useOrganizations"; interface AssignExistingAdminModalProps { isOpen: boolean; onClose: () => void; + /** org preselected in the panel (the page's current org) */ organizationId: string; - /** user ids that are already admins — shown disabled */ + /** user ids that are already admins of the page's org — shown disabled */ existingAdminIds: string[]; - onAssign: (userId: string) => void; + /** server-side error from the last assign attempt, shown inline */ + apiError: string | null; + /** unitId set → grant unit-admin of that unit instead of org-admin */ + onAssign: (userId: string, organizationId: string, unitId?: string) => void; isAssigning: boolean; } -/** Promote an existing employee of the selected organization to org admin. */ +/** + * Promote an existing employee to admin. Three panels like the old + * AssignAdminDialog: pick an org (page org preselected), pick a unit (or + * none → org admin), then pick a user — unit selection also filters the + * employee list to that unit. + */ export default function AssignExistingAdminModal({ isOpen, onClose, organizationId, existingAdminIds, + apiError, onAssign, isAssigning, }: AssignExistingAdminModalProps) { @@ -40,9 +53,28 @@ export default function AssignExistingAdminModal({ const localizedName = useLocalizedName(); const [selectedUserId, setSelectedUserId] = useState(""); const [search, setSearch] = useState(""); + const [orgId, setOrgId] = useState(organizationId); + // "" → org admin (all org users listed); set → unit admin of that unit + const [unitId, setUnitId] = useState(""); - const { employeesResponseByOrg, isLoadingEmployeesByOrg } = useEmployees({ - organizationId: isOpen ? organizationId : undefined, + const { organizationsResponse, isLoading: isLoadingOrgs } = useOrganizations( + "Org", + { take: 300 }, + ); + const orgs = organizationsResponse?.items ?? []; + + const { data: unitsResponse, isLoading: isLoadingUnits } = + useUnit().getList(orgId, { take: 300, skip: 0 }, isOpen); + const units = unitsResponse?.data?.items ?? []; + + const { + employeesResponseByOrg, + isLoadingEmployeesByOrg, + isErrorEmployeesByOrg, + refetchEmployeesByOrg, + } = useEmployees({ + organizationId: isOpen ? orgId : undefined, + unitId: unitId || undefined, params: { take: 3000, skip: 0 }, }); @@ -57,16 +89,41 @@ export default function AssignExistingAdminModal({ }); }, [employeesResponseByOrg, localizedName, search]); + const selectOrg = (id: string) => { + setOrgId(id); + setUnitId(""); + setSelectedUserId(""); + }; + + const selectUnit = (id: string) => { + setUnitId(id); + setSelectedUserId(""); + }; + const handleClose = () => { if (isAssigning) return; setSelectedUserId(""); setSearch(""); + setOrgId(organizationId); + setUnitId(""); onClose(); }; + // already-admin info only covers the page's org + const knownAdminIds = orgId === organizationId ? existingAdminIds : []; + + useEffect(() => { + if (isOpen) { + setOrgId(organizationId); + setUnitId(""); + setSelectedUserId(""); + setSearch(""); + } + }, [isOpen, organizationId]); + return ( !open && handleClose()}> - + {t("orgAdmins.assign.title")} @@ -74,69 +131,179 @@ export default function AssignExistingAdminModal({ -
- setSearch(event.target.value)} - placeholder={t("orgAdmins.assign.searchUsers")} - /> - {isLoadingEmployeesByOrg ? ( -
- -
- ) : ( - -
- {filteredEmployees.map((employee: any) => { - const userId = employee.user?.id; - if (!userId) return null; - const isAlreadyAdmin = existingAdminIds.includes(userId); - return ( +
+ {/* Step 1: organization (page org preselected) */} +
+ + {isLoadingOrgs ? ( +
+ +
+ ) : ( + +
+ {orgs.map((org) => ( - ); - })} - {filteredEmployees.length === 0 && ( -

- {t("orgAdmins.assign.noUsersFound")} -

- )} + ))} +
+
+ )} +
+ + {/* Step 2: unit (or none → org admin) */} +
+ + {isLoadingUnits ? ( +
+
- - )} + ) : ( + +
+ + {units.map((unit: any) => ( + + ))} +
+
+ )} +
+ + {/* Step 3: user */} +
+ + setSearch(event.target.value)} + placeholder={t("orgAdmins.assign.searchUsers")} + /> + {isLoadingEmployeesByOrg ? ( +
+ +
+ ) : isErrorEmployeesByOrg ? ( +
+ {t("orgAdmins.assign.loadError")} + +
+ ) : ( + +
+ {filteredEmployees.map((employee: any) => { + const userId = employee.user?.id; + if (!userId) return null; + const isAlreadyAdmin = knownAdminIds.includes(userId); + return ( + + ); + })} + {filteredEmployees.length === 0 && ( +

+ {t("orgAdmins.assign.noUsersFound")} +

+ )} +
+
+ )} +
+ {apiError && ( +
+ {apiError} +
+ )} +
) : ( <> - {!isLoading && adminCount === 0 && ( + {isError && ( +
+ {t("orgAdmins.loadError")} + +
+ )} + {!isLoading && !isError && adminCount === 0 && (
{t("orgAdmins.noAdminsHint", { name: localizedName(selectedOrg.name), @@ -250,7 +288,7 @@ export default function OrgAdminsPage() { }} > - {t("orgAdmins.inviteAdmin")} + {t("orgAdmins.addAdmin")}
} @@ -260,25 +298,32 @@ export default function OrgAdminsPage() { - {/* Invite / Edit modal */} + {/* Add / Edit modal */} { setFormOpen(false); setEditAdmin(null); + clearErrors(); }} admin={editAdmin} + organizationId={selectedOrg?.id} + apiError={formError} onSubmit={handleFormSubmit} - isSubmitting={isInviting || isUpdatingProfile} + isSubmitting={isAdding || isUpdatingProfile} /> {/* Assign existing employee modal */} {selectedOrg && ( setAssignOpen(false)} + onClose={() => { + setAssignOpen(false); + clearErrors(); + }} organizationId={selectedOrg.id} existingAdminIds={existingAdminIds} + apiError={assignError} onAssign={handleAssign} isAssigning={isAssigning} /> @@ -287,7 +332,12 @@ export default function OrgAdminsPage() { {/* Remove admin confirm */} !open && setRemoveTarget(null)} + onOpenChange={(open) => { + if (!open) { + setRemoveTarget(null); + clearErrors(); + } + }} > @@ -303,6 +353,11 @@ export default function OrgAdminsPage() { })} + {removeError && ( +
+ {removeError} +
+ )} {t("common.cancel")} @@ -323,7 +378,12 @@ export default function OrgAdminsPage() { {/* Activate / Deactivate confirm */} !open && setToggleTarget(null)} + onOpenChange={(open) => { + if (!open) { + setToggleTarget(null); + clearErrors(); + } + }} > @@ -339,6 +399,11 @@ export default function OrgAdminsPage() { })} + {toggleError && ( +
+ {toggleError} +
+ )} {t("common.cancel")} @@ -352,6 +417,7 @@ export default function OrgAdminsPage() { : "bg-emerald-600 hover:bg-emerald-700 text-white" } > + {isToggling && } {toggleTarget?.isActive ? t("orgAdmins.actions.deactivate") : t("orgAdmins.actions.activate")} diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgPicker.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgPicker.tsx index bff9172c7..cc32d4a5d 100644 --- a/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgPicker.tsx +++ b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgPicker.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { useDebouncedValue } from "@mantine/hooks"; import { Building2, Check, ChevronsUpDown, Loader2 } from "lucide-react"; @@ -36,7 +36,12 @@ export function OrgPicker({ value, onChange }: OrgPickerProps) { const [search, setSearch] = useState(""); const [debouncedSearch] = useDebouncedValue(search, 300); - const { data: orgsResponse, isLoading } = useQuery({ + const { + data: orgsResponse, + isLoading, + isError, + refetch, + } = useQuery({ queryKey: [ORG_PICKER_KEY, debouncedSearch], queryFn: async () => { const { data } = await getOrganizationsWithAdminFlag({ @@ -55,6 +60,15 @@ export function OrgPicker({ value, onChange }: OrgPickerProps) { const orgs = orgsResponse?.items ?? []; + // default to the first org that already has admins (initial load only, + // never while the user is searching) + useEffect(() => { + if (value || debouncedSearch || !orgs.length) return; + const firstWithAdmins = orgs.find((org) => (org.adminsCount ?? 0) > 0); + if (firstWithAdmins) onChange(firstWithAdmins); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [orgsResponse]); + return ( @@ -89,6 +103,13 @@ export function OrgPicker({ value, onChange }: OrgPickerProps) {
+ ) : isError ? ( +
+ {t("orgAdmins.pickerError")} + +
) : ( <> {t("orgAdmins.noOrgsFound")} diff --git a/apps/edr-freight-web/backoffice/src/super-admin/hooks/useOrgAdmins.ts b/apps/edr-freight-web/backoffice/src/super-admin/hooks/useOrgAdmins.ts index 6c50a8907..57c5cddc2 100644 --- a/apps/edr-freight-web/backoffice/src/super-admin/hooks/useOrgAdmins.ts +++ b/apps/edr-freight-web/backoffice/src/super-admin/hooks/useOrgAdmins.ts @@ -1,14 +1,16 @@ +import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { useTranslation } from "react-i18next"; import { useErrorHandler } from "@/shared/hooks/useErrorHandler"; import { assignOrganizationAdmin, + assignUnitAdmin, fetchAllUnitAdminById, - OrganizationAdminPayload, } from "@/super-admin/services/api/organizationAdminService"; import { assignOrgAdminRole, + assignUnitAdminRole, removeOrgAdminRole, removeUnitAdminRole, } from "@/super-admin/services/api/userRoleService"; @@ -54,13 +56,42 @@ export interface RemoveAdminInput { unitId?: string; } +export interface AddAdminInput { + organizationId: string; + /** set → create as unit admin of this unit instead of org admin */ + unitId?: string; + name: { am: string; en: string }; + username: string; + email: string; + phoneNumber: string; +} + export const useOrgAdmins = ( orgId?: string, params?: { take?: number; skip?: number }, ) => { const queryClient = useQueryClient(); const { t } = useTranslation(); - const { handleError } = useErrorHandler(t); + const { handleError, getErrorMessage } = useErrorHandler(t); + + // dialog mutations surface their API errors inline, not via toast + const [formError, setFormError] = useState(null); + const [assignError, setAssignError] = useState(null); + const [removeError, setRemoveError] = useState(null); + const [toggleError, setToggleError] = useState(null); + + const inlineError = + (set: (message: string | null) => void) => async (err: unknown) => { + console.error(err); + set(await getErrorMessage(err)); + }; + + const clearErrors = () => { + setFormError(null); + setAssignError(null); + setRemoveError(null); + setToggleError(null); + }; const { data: adminsResponse, @@ -89,28 +120,49 @@ export const useOrgAdmins = ( queryClient.invalidateQueries({ queryKey: ["organizationAdmins"] }); }; - const { mutate: inviteAdmin, isPending: isInviting } = useMutation({ - mutationFn: async (payload: OrganizationAdminPayload) => { - const { data } = await assignOrganizationAdmin(payload); + const { mutate: addAdmin, isPending: isAdding } = useMutation({ + mutationFn: async ({ + organizationId, + unitId, + ...person + }: AddAdminInput) => { + // both iam invite endpoints create the user (employee + role + + // SET_PASSWORD OTP in one tx); unitId decides the admin scope + const { data } = unitId + ? await assignUnitAdmin({ unitId, ...person }) + : await assignOrganizationAdmin({ organizationId, ...person }); return data; }, + onMutate: () => setFormError(null), onSuccess: () => { - toast.success(t("orgAdmins.toasts.invited")); + toast.success(t("orgAdmins.toasts.added")); invalidate(); }, - onError: handleError, + onError: inlineError(setFormError), }); const { mutate: assignAdmin, isPending: isAssigning } = useMutation({ - mutationFn: async (payload: { organizationId: string; userId: string }) => { - const { data } = await assignOrgAdminRole(payload); + mutationFn: async ({ + organizationId, + userId, + unitId, + }: { + organizationId: string; + userId: string; + /** set → grant unit-admin of this unit instead of org-admin */ + unitId?: string; + }) => { + const { data } = unitId + ? await assignUnitAdminRole({ unitId, userId }) + : await assignOrgAdminRole({ organizationId, userId }); return data; }, + onMutate: () => setAssignError(null), onSuccess: () => { toast.success(t("orgAdmins.toasts.assigned")); invalidate(); }, - onError: handleError, + onError: inlineError(setAssignError), }); const { mutate: removeAdmin, isPending: isRemoving } = useMutation({ @@ -123,11 +175,12 @@ export const useOrgAdmins = ( }); return data; }, + onMutate: () => setRemoveError(null), onSuccess: () => { toast.success(t("orgAdmins.toasts.removed")); invalidate(); }, - onError: handleError, + onError: inlineError(setRemoveError), }); const { mutate: resendInvite, isPending: isResending } = useMutation({ @@ -148,6 +201,7 @@ export const useOrgAdmins = ( : await deactivateUser(id); return data; }, + onMutate: () => setToggleError(null), onSuccess: (_data, variables) => { toast.success( variables.activate @@ -156,7 +210,7 @@ export const useOrgAdmins = ( ); invalidate(); }, - onError: handleError, + onError: inlineError(setToggleError), }); const { mutate: updateAdminProfile, isPending: isUpdatingProfile } = @@ -171,11 +225,12 @@ export const useOrgAdmins = ( const { data } = await updateProfile(payload, id); return data; }, + onMutate: () => setFormError(null), onSuccess: () => { toast.success(t("orgAdmins.toasts.profileUpdated")); invalidate(); }, - onError: handleError, + onError: inlineError(setFormError), }); return { @@ -183,8 +238,8 @@ export const useOrgAdmins = ( isLoading, isError, refetch, - inviteAdmin, - isInviting, + addAdmin, + isAdding, assignAdmin, isAssigning, removeAdmin, @@ -195,5 +250,10 @@ export const useOrgAdmins = ( isToggling, updateAdminProfile, isUpdatingProfile, + formError, + assignError, + removeError, + toggleError, + clearErrors, }; }; From 79cbddb7e6b0471a6f9a818175b8f6c6ebcc4472 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Thu, 23 Jul 2026 15:00:25 +0300 Subject: [PATCH 05/16] fix: orgs size --- .../shared/services/organizationsService.ts | 50 +++++++++---------- 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/shared/services/organizationsService.ts b/apps/edr-freight-web/backoffice/src/shared/services/organizationsService.ts index 3a560a08d..f0bd42337 100644 --- a/apps/edr-freight-web/backoffice/src/shared/services/organizationsService.ts +++ b/apps/edr-freight-web/backoffice/src/shared/services/organizationsService.ts @@ -37,7 +37,7 @@ export enum FilterEnum { } export const getOrganizations = async ( - params?: OrgQueryParams + params?: OrgQueryParams, ): Promise => { return axiosInstance.get("/organizations/filter", { ...{ headers: withHeaders() }, @@ -45,29 +45,27 @@ export const getOrganizations = async ( }); }; -export const getMyAdminOrganizations = async (): Promise => { - //my-admin-organizations - return axiosInstance.get("/organizations/with-admin-flag", { - headers: withHeaders(), - }); -}; export const getOrganizationsWithAdminFlag = async ( - params?: OrgQueryParams + params?: OrgQueryParams, ): Promise => { return axiosInstance.get("/organizations/with-admin-flag", { ...{ headers: withHeaders() }, - params, + params: { + take: 100, + ...params, + }, }); }; +export const getMyAdminOrganizations = getOrganizationsWithAdminFlag; export const getOrganizationById = async ( - id: string | number + id: string | number, ): Promise => { return axiosInstance.get(`/organizations/${id}`, { headers: withHeaders() }); }; export const getChildren = async ( - id: string | number + id: string | number, ): Promise => { return axiosInstance.get(`/organizations/${id}/children`, { headers: withHeaders(), @@ -76,7 +74,7 @@ export const getChildren = async ( export const getEmployeesUnderOrg = async ( id: string | number, - params?: OrgQueryParams + params?: OrgQueryParams, ): Promise => { return axiosInstance.get(`/organizations/current/${id}/employees`, { headers: withHeaders(), @@ -89,7 +87,7 @@ export const getEmployeesUnderOrg = async ( }; export const getEmployeeCountByOrgId = async ( - id: string | number + id: string | number, ): Promise => { // Try the endpoint from your curl example return axiosInstance.get(`/organizations/${id}/employees/count`, { @@ -98,13 +96,13 @@ export const getEmployeeCountByOrgId = async ( }; export const createOrganization = async ( - data: OrganizationPayload + data: OrganizationPayload, ): Promise => { return axiosInstance.post("/organizations", data, { headers: withHeaders() }); }; export const activateOrganization = async ( - id: string | number + id: string | number, ): Promise => { return axiosInstance.patch(`/organizations/${id}/activate`, null, { headers: withHeaders(), @@ -112,7 +110,7 @@ export const activateOrganization = async ( }; export const deActivateOrganization = async ( - id: string | number + id: string | number, ): Promise => { return axiosInstance.patch(`/organizations/${id}/debar`, null, { headers: withHeaders(), @@ -121,7 +119,7 @@ export const deActivateOrganization = async ( export const updateOrganization = async ( id: string | number, - data: OrganizationPayload + data: OrganizationPayload, ): Promise => { return axiosInstance.put(`/organizations/${id}`, data, { headers: withHeaders(), @@ -129,7 +127,7 @@ export const updateOrganization = async ( }; export const deleteOrganization = async ( - id: string | number + id: string | number, ): Promise => { return axiosInstance.delete(`/organizations/${id}`, { headers: withHeaders(), @@ -137,7 +135,7 @@ export const deleteOrganization = async ( }; export const softDeleteOrganization = async ( - id: string | number + id: string | number, ): Promise => { return axiosInstance.delete(`/organizations/${id}/soft`, { headers: withHeaders(), @@ -149,7 +147,7 @@ export const softDeleteOrganization = async ( // GET /organizations/archived // PATCH /organizations/{id}/restore export const getArchivedOrganizations = async ( - params?: OrgQueryParams + params?: OrgQueryParams, ): Promise => { return axiosInstance.get(`/organizations/archived`, { headers: withHeaders(), @@ -158,7 +156,7 @@ export const getArchivedOrganizations = async ( }; export const restoreOrganization = async ( - id: string | number + id: string | number, ): Promise => { return axiosInstance.patch(`/organizations/${id}/restore`, null, { headers: withHeaders(), @@ -171,20 +169,20 @@ export const getDocumentRequirements = async (): Promise => { }); }; export const getDocumentRequirementsById = async ( - id: string + id: string, ): Promise => { return axiosInstance.get(`/documentary-requirements/${id}`, { headers: withHeaders(), }); }; export const getDocumentRequirementsByFilter = async ( - filter: FilterEnum + filter: FilterEnum, ): Promise => { return axiosInstance.get(`/documentary-requirements/${filter}/type`); }; export const postDocumentRequirements = async ( - data: DocumentRequirementDto + data: DocumentRequirementDto, ): Promise => { return axiosInstance.post(`/documentary-requirements`, data, { headers: withHeaders(), @@ -193,7 +191,7 @@ export const postDocumentRequirements = async ( export const giveResponse = async ( id: string, - data: ResponseActionDto + data: ResponseActionDto, ): Promise => { return axiosInstance.post(`user-documents/${id}/response`, data, { headers: withHeaders(), @@ -202,7 +200,7 @@ export const giveResponse = async ( export const getArchivedUserId = async ( unitId: string, - params?: OrgQueryParams + params?: OrgQueryParams, ): Promise => { return axiosInstance.get(`/employees/archived/${unitId}/with-unit`, { headers: withHeaders(), From cee3a4f429d12719da081b0364d6812fdf9f6883 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 23 Jul 2026 12:02:57 +0000 Subject: [PATCH 06/16] disputes only before acknowledgement, registered disputes immutable (view-only), cancel action removed end-to-end, and internal UUIDs removed from the document, detail view, and list. --- .../interchange-documents.controller.ts | 7 ---- .../interchange-documents.service.ts | 18 +++----- .../src/hooks/useInterchangeDocuments.ts | 7 ---- .../warehouses/InterchangeDocumentsPage.tsx | 41 ++++--------------- .../services/interchange-documents.service.ts | 2 - 5 files changed, 13 insertions(+), 62 deletions(-) diff --git a/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.controller.ts b/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.controller.ts index 03797f3b7..61a13744e 100644 --- a/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.controller.ts +++ b/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.controller.ts @@ -54,11 +54,4 @@ export class InterchangeDocumentsController { dispute(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DisputeInterchangeDocumentDto) { return this.service.dispute(id, dto); } - - @Patch(':id/cancel') - @BookingStaff(FREIGHT_PERMS.interchangeDocuments.cancel) - @ApiOperation({ summary: 'Cancel a draft/generated interchange document' }) - cancel(@Param('id', ParseUUIDPipe) id: string) { - return this.service.cancel(id); - } } diff --git a/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.service.ts b/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.service.ts index f91e942c0..e5fb5dc4d 100644 --- a/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.service.ts +++ b/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.service.ts @@ -203,11 +203,12 @@ export class InterchangeDocumentsService { async dispute(id: string, dto: DisputeInterchangeDocumentDto): Promise { const document = await this.findOne(id); - // A dispute can only be raised on a live handover — a GENERATED or already - // ACKNOWLEDGED document. CANCELLED and already-DISPUTED are terminal here. - if (!['GENERATED', 'ACKNOWLEDGED'].includes(document.status)) { + // A dispute can only be raised BEFORE the handover is acknowledged — an + // acknowledged document is settled. DISPUTED itself is terminal and + // read-only: the registered dispute cannot be re-raised or overwritten. + if (document.status !== 'GENERATED') { throw new BadRequestException( - `Interchange document in ${document.status} status cannot be disputed (must be GENERATED or ACKNOWLEDGED)`, + `Interchange document in ${document.status} status cannot be disputed (must be GENERATED — an acknowledged handover is settled, a registered dispute is read-only)`, ); } await this.dataSource.getRepository(InterchangeDocument).update(id, { @@ -217,15 +218,6 @@ export class InterchangeDocumentsService { return this.findOne(id); } - async cancel(id: string): Promise { - const document = await this.findOne(id); - if (!['DRAFT', 'GENERATED'].includes(document.status)) { - throw new BadRequestException(`Interchange document ${document.status} cannot be cancelled`); - } - await this.dataSource.getRepository(InterchangeDocument).update(id, { status: 'CANCELLED' }); - return this.findOne(id); - } - private async getScheduleSnapshot(scheduleId: string): Promise { const [schedule] = await this.dataSource.query( `SELECT ts.id, diff --git a/apps/edr-freight-web/backoffice/src/hooks/useInterchangeDocuments.ts b/apps/edr-freight-web/backoffice/src/hooks/useInterchangeDocuments.ts index 67cf70f24..ba1f10b73 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/useInterchangeDocuments.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/useInterchangeDocuments.ts @@ -67,10 +67,3 @@ export function useDisputeInterchangeDocument() { }); } -export function useCancelInterchangeDocument() { - const onSuccess = useInterchangeInvalidation(); - return useMutation({ - mutationFn: (id: string) => interchangeDocumentsService.cancel(id), - onSuccess, - }); -} diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/InterchangeDocumentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/InterchangeDocumentsPage.tsx index 4bd647106..04fe38970 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/InterchangeDocumentsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/InterchangeDocumentsPage.tsx @@ -12,7 +12,7 @@ import { Text, TextInput, } from '@mantine/core'; -import { CheckCircle2, Download, Eye, FileText, Printer, Search, XCircle } from 'lucide-react'; +import { CheckCircle2, Download, Eye, FileText, Printer, Search } from 'lucide-react'; import type { ReactNode } from 'react'; import { DataTable, type ColumnDef } from '@edr/ui-common'; @@ -21,7 +21,6 @@ import { PageContainer, PageHeader } from '@/components/page'; import { VisualEmptyState, formatDate, formatNumber } from '@/components/warehouses'; import { useAcknowledgeInterchangeDocument, - useCancelInterchangeDocument, useDisputeInterchangeDocument, useInterchangeDocument, useInterchangeDocuments, @@ -66,7 +65,7 @@ const buildPrintableInterchangeHtml = (document: InterchangeDocument) => { (item, index) => ` ${index + 1} - ${escapeHtml(item.bookingReference ?? item.bookingId?.slice(0, 8))} + ${escapeHtml(item.bookingReference)} ${escapeHtml(item.itemType)} ${escapeHtml(item.containerNumber)} ${escapeHtml(item.sealNumber)} @@ -118,7 +117,6 @@ const buildPrintableInterchangeHtml = (document: InterchangeDocument) => {
Direction
${escapeHtml(document.direction)}
Train No
${escapeHtml(document.trainNo)}
-
Schedule
${escapeHtml(document.scheduleId)}
Handover Location
${escapeHtml(document.handoverLocation)}
Handover From
${escapeHtml(document.handoverFrom)}
Handover To
${escapeHtml(document.handoverTo)}
@@ -191,7 +189,6 @@ function InterchangeDocumentDetail({ id }: { id: string }) { - @@ -232,7 +229,7 @@ function InterchangeDocumentDetail({ id }: { id: string }) { {items.map((item) => ( - {item.bookingReference ?? item.bookingId?.slice(0, 8) ?? '-'} + {item.bookingReference ?? '-'} {item.itemType} {item.containerNumber ?? '-'} {item.sealNumber ?? '-'} @@ -268,7 +265,6 @@ export default function InterchangeDocumentsPage() { const { data: documents = [], isLoading } = useInterchangeDocuments(filter); const acknowledge = useAcknowledgeInterchangeDocument(); const dispute = useDisputeInterchangeDocument(); - const cancel = useCancelInterchangeDocument(); const getPrintableDocument = async (interchangeDocument: InterchangeDocument) => { if (interchangeDocument.items?.length) return interchangeDocument; @@ -336,19 +332,7 @@ export default function InterchangeDocumentsPage() { ), }, { id: 'direction', header: 'Direction', cell: ({ row }) => row.original.direction }, - { - id: 'train', - header: 'Train No / Schedule', - cell: ({ row }) => ( - - {row.original.trainNo ?? '-'} - - {row.original.scheduleId?.slice(0, 8) ?? '-'} - - - ), - }, - { id: 'route', header: 'Route', cell: ({ row }) => row.original.routeId?.slice(0, 8) ?? '-' }, + { id: 'train', header: 'Train No', cell: ({ row }) => row.original.trainNo ?? '-' }, { id: 'handoverLocation', header: 'Handover Location', cell: ({ row }) => row.original.handoverLocation }, { id: 'handoverFrom', header: 'Handover From', cell: ({ row }) => row.original.handoverFrom }, { id: 'handoverTo', header: 'Handover To', cell: ({ row }) => row.original.handoverTo }, @@ -390,7 +374,7 @@ export default function InterchangeDocumentsPage() { > View - {doc.status !== 'ACKNOWLEDGED' && doc.status !== 'CANCELLED' ? ( + {doc.status === 'GENERATED' ? ( - ) : null} ); }, diff --git a/apps/edr-freight-web/backoffice/src/services/interchange-documents.service.ts b/apps/edr-freight-web/backoffice/src/services/interchange-documents.service.ts index 6e2fd3474..0996b053d 100644 --- a/apps/edr-freight-web/backoffice/src/services/interchange-documents.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/interchange-documents.service.ts @@ -28,6 +28,4 @@ export const interchangeDocumentsService = { apiClient.patch(URL_CONSTANTS.INTERCHANGE_DOCUMENTS.ACKNOWLEDGE(id), payload), dispute: (id: string, payload: { remarks: string }) => apiClient.patch(URL_CONSTANTS.INTERCHANGE_DOCUMENTS.DISPUTE(id), payload), - cancel: (id: string) => - apiClient.patch(URL_CONSTANTS.INTERCHANGE_DOCUMENTS.CANCEL(id), {}), }; From 063a8799f51ef070c2048e509171e12f293f9e38 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 23 Jul 2026 13:41:39 +0000 Subject: [PATCH 07/16] fix(backoffice): make position type optional for unit departments Creating a department under a unit required picking a position type, which is not always known at that point. Sub-department creation is unchanged and still requires one. PositionPayload.positionTypeId becomes optional and the field is omitted from the request body when unset rather than sent as an empty string. Co-Authored-By: Claude Opus 4.8 --- .../services/api/positionService.ts | 2 +- .../userManagement/forms/AddDepartmentForm.tsx | 16 +++++----------- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/user-management/services/api/positionService.ts b/apps/edr-freight-web/backoffice/src/user-management/services/api/positionService.ts index 9e7e4c1d4..84782b38f 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/services/api/positionService.ts +++ b/apps/edr-freight-web/backoffice/src/user-management/services/api/positionService.ts @@ -21,7 +21,7 @@ export interface PositionPayload { organizationId: string; parentPositionId?: string; projectId?: string; - positionTypeId: string; + positionTypeId?: string; } export interface PositionQueryParams { orderBy?: string; diff --git a/apps/edr-freight-web/backoffice/src/user-management/userManagement/forms/AddDepartmentForm.tsx b/apps/edr-freight-web/backoffice/src/user-management/userManagement/forms/AddDepartmentForm.tsx index d16745327..872a82c9d 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/userManagement/forms/AddDepartmentForm.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/userManagement/forms/AddDepartmentForm.tsx @@ -48,8 +48,6 @@ export function AddDepartmentForm({ if (!nameAm.trim()) newErrors.nameAm = t("organization.amharicNameRequired"); if (!key.trim()) newErrors.key = t("contentManagement.keyRequired"); - if (!positionTypeId) - newErrors.positionTypeId = t("contentManagement.selectPosType"); setErrors(newErrors); return Object.keys(newErrors).length === 0; @@ -71,7 +69,7 @@ export function AddDepartmentForm({ key: key.trim().toLowerCase().replace(/\s+/g, "-"), unitId, organizationId, - positionTypeId, + ...(positionTypeId ? { positionTypeId } : {}), }; createPosition({ @@ -90,12 +88,11 @@ export function AddDepartmentForm({ return (
- + - {errors.positionTypeId && ( -

{errors.positionTypeId}

- )}
From 346d4718bf779aed0ed0647fdb20db88dfe7bdf8 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 23 Jul 2026 13:42:08 +0000 Subject: [PATCH 08/16] fix(backoffice): scope permission copying to the selected organization "Copy permissions from" listed every position type in every organization, because the dropdown read an unfiltered GET /position-types. IAM exposes no organization-scoped route and carries no organizationId on a position type, so the list is now narrowed client-side to the built-in (isSystem) types plus those whose unit belongs to the selected organization, with the type being edited excluded. Also in position management: - Invalidate every position-type cache key root after a mutation. React Query matches prefixes element by element, so ["position-type"] never reached ["position-types-common", ...] and the department pickers kept serving a stale list. invalidatePositionTypeQueries() covers all three roots and is shared by the hook and the form. - Drop getByOrganizationId and getCommonTypesByOrganizationId. Both issued the same requests as their unit counterparts and had no callers. - Surface errors that were being swallowed. Three mutations had empty onError handlers, hiding IAM's 403 for built-in position types, and CreatePositionForm's bare catch discarded the reason for every failure. - Move organization and unit into the zod schema so they validate with translated messages and inline errors instead of an ad-hoc toast, and keep submit disabled through the permission-assignment call that follows the save. - Report the two outcomes the form used to hide: a save that succeeded while permission assignment failed, and clearing every permission, which assign-seconds-for-first cannot express. - Fix the list page's loading and error states, which rendered the "Add User" string as a spinner, ignored the unit-scoped query, and left the export button stuck after a failed download. - Halve PermissionSearch's requests. It fetched 50 rows, read the total off the response and immediately refetched, and it re-filtered results on the undebounced term, blanking the list while typing. Remove the three record toggles. They never worked: IAM's PositionTypeConfiguration holds only { id, organizationId, positionTypeId, timeframe } in every published build, canAssignRecord and canCreateBankRecord exist nowhere in the package, and the global ValidationPipe runs with forbidNonWhitelisted, so every write was a 400. The reads were broken too, passing a positionTypeId to a route that filters on organizationId. A TODO records where the real flag lives: PositionConfiguration.canReceiveRecord, keyed by positionId. Delete ActionsColumn.tsx, which had no references. Co-Authored-By: Claude Opus 4.8 --- .../src/locales/am/translation.json | 13 + .../src/locales/en/translation.json | 13 + .../src/locales/fr/translation.json | 13 + .../position-management/ActionsColumn.tsx | 94 -- .../CreatePositionForm.tsx | 984 ++++++++++-------- .../position-management/EditPositionForm.tsx | 353 ++++--- .../position-management/PermissionSearch.tsx | 272 +++-- .../position-management/PositionLists.tsx | 529 +++++----- .../PositionTypeActions.tsx | 644 +++++------- .../PositionTypeColumnDefn.tsx | 9 - .../dto/positions/positionType.ts | 12 +- .../user-management/hooks/usePositionTypes.ts | 105 +- .../services/api/positionTypesService.ts | 31 +- 13 files changed, 1444 insertions(+), 1628 deletions(-) delete mode 100644 apps/edr-freight-web/backoffice/src/user-management/components/position-management/ActionsColumn.tsx diff --git a/apps/edr-freight-web/backoffice/src/locales/am/translation.json b/apps/edr-freight-web/backoffice/src/locales/am/translation.json index dcf029204..99c1e0329 100644 --- a/apps/edr-freight-web/backoffice/src/locales/am/translation.json +++ b/apps/edr-freight-web/backoffice/src/locales/am/translation.json @@ -2039,6 +2039,7 @@ "setting": "ቅንብሮች", "loadingAdmins": "አስተዳዳሪዎችን በመጫን ላይ...", "errorLoadingAdmins": "የአስተዳዳሪ መረጃን ማጫን ላይ ስህተት ተፈጥሯል", + "errorLoadingUnits": "ክፍሎችን ማጫን ላይ ስህተት ተፈጥሯል", "retry": "ደግመው ይሞክሩ", "assignAdmin": "አስተዳዳሪ መመደብ", "addAdmin": "አስተዳዳሪ ያክሉ", @@ -2651,6 +2652,18 @@ "selectApplicationToLoadPermissions": "ፍቃዶቹን ለማስገንዘብ አፕሊኬሽኑን ይምረጡ", "copyPermissionsHint": "የነበረ የቦታ አይነት ይምረጡ፤ ፍቃዶቹ አስቀድመው ይሞላሉ፣ ከታች ማስተካከል ይችላሉ።", "copyPermissionsFailed": "ፍቃዶችን መቅዳት አልተቻለም", + "selectOrganizationToCopy": "መቅዳት የሚችሏቸውን የቦታ ዓይነቶች ለማየት መጀመሪያ ድርጅት ይምረጡ", + "cannotClearAllPermissions": "ተቀምጧል። ፍቃዶቹ አልተቀየሩም — ይህ የቦታ ዓይነት ቢያንስ አንድ ፍቃድ ሊኖረው ይገባል።", + "permissionsSelected": "{{count}} ተመርጠዋል", + "positionTypeCreated": "የቦታ ዓይነት ተፈጥሯል", + "positionTypeUpdated": "የቦታ ዓይነት ተሻሽሏል", + "positionTypeDeleted": "የቦታ ዓይነት ተሰርዟል", + "positionTypeMigrated": "የቦታ ዓይነት ዝውውር ተሻሽሏል", + "positionTypeNotFound": "የቦታ ዓይነት አልተገኘም", + "permissionsAssignFailed": "የቦታ ዓይነቱ ተቀምጧል፣ ነገር ግን ፍቃዶቹን መመደብ አልተቻለም። እንደገና ለመሞከር ደግመው ይክፈቱት።", + "failedToLoadPermissions": "ፍቃዶችን መጫን አልተቻለም", + "failedToLoadPositionTypes": "የቦታ ዓይነቶችን መጫን አልተቻለም", + "exportFailed": "የቦታ ዓይነት ቁልፎችን መላክ አልተቻለም", "perFailed": "ፍቃድ መፍጠር አልተቻለም", "perSuccess": "የፍቃድ አይነት ተፈጠረና ፍቃዶች ተመደቡ", "updatePerSuccess": "ፍቃድ በትክክል ተዘምኗል", diff --git a/apps/edr-freight-web/backoffice/src/locales/en/translation.json b/apps/edr-freight-web/backoffice/src/locales/en/translation.json index 81bf4d519..f1357c2bf 100644 --- a/apps/edr-freight-web/backoffice/src/locales/en/translation.json +++ b/apps/edr-freight-web/backoffice/src/locales/en/translation.json @@ -2057,6 +2057,7 @@ "setting": "Setting", "loadingAdmins": "Loading admins...", "errorLoadingAdmins": "Error loading admin data", + "errorLoadingUnits": "Error loading units", "retry": "Retry", "assignAdmin": "Assign Admin", "addAdmin": "Add Admin", @@ -2760,6 +2761,18 @@ "selectApplicationToLoadPermissions": "Select an application to load its permissions", "copyPermissionsHint": "Pick an existing position type to pre-fill its permissions, then edit below.", "copyPermissionsFailed": "Failed to copy permissions", + "selectOrganizationToCopy": "Select an organization to see the position types you can copy from", + "cannotClearAllPermissions": "Saved. Permissions were left unchanged — this position type must keep at least one permission.", + "permissionsSelected": "{{count}} selected", + "positionTypeCreated": "Position type created", + "positionTypeUpdated": "Position type updated", + "positionTypeDeleted": "Position type deleted", + "positionTypeMigrated": "Position type migration updated", + "positionTypeNotFound": "Position type not found", + "permissionsAssignFailed": "Position type saved, but assigning its permissions failed. Reopen it to try again.", + "failedToLoadPermissions": "Failed to load permissions", + "failedToLoadPositionTypes": "Failed to load position types", + "exportFailed": "Failed to export position type keys", "perFailed": "Failed To Create Permission", "perSuccess": "Permission type created and permissions assigned", "updatePerSuccess": "Permission updated successfully", diff --git a/apps/edr-freight-web/backoffice/src/locales/fr/translation.json b/apps/edr-freight-web/backoffice/src/locales/fr/translation.json index 7f3d4e679..bd5e17fae 100644 --- a/apps/edr-freight-web/backoffice/src/locales/fr/translation.json +++ b/apps/edr-freight-web/backoffice/src/locales/fr/translation.json @@ -1525,6 +1525,7 @@ "setting": "Paramètre", "loadingAdmins": "Chargement des administrateurs...", "errorLoadingAdmins": "Erreur lors du chargement des données administrateur", + "errorLoadingUnits": "Erreur lors du chargement des unités", "retry": "Réessayer", "assignAdmin": "Assigner un administrateur", "addAdmin": "Ajouter un administrateur", @@ -1886,6 +1887,18 @@ "selectApplicationToLoadPermissions": "Sélectionner une application pour charger ses autorisations", "copyPermissionsHint": "Choisissez un type de poste existant pour préremplir ses autorisations, puis modifiez ci-dessous.", "copyPermissionsFailed": "Échec de la copie des autorisations", + "selectOrganizationToCopy": "Sélectionnez une organisation pour voir les types de poste que vous pouvez copier", + "cannotClearAllPermissions": "Enregistré. Les autorisations n'ont pas été modifiées — ce type de poste doit conserver au moins une autorisation.", + "permissionsSelected": "{{count}} sélectionné(s)", + "positionTypeCreated": "Type de poste créé", + "positionTypeUpdated": "Type de poste mis à jour", + "positionTypeDeleted": "Type de poste supprimé", + "positionTypeMigrated": "Migration du type de poste mise à jour", + "positionTypeNotFound": "Type de poste introuvable", + "permissionsAssignFailed": "Type de poste enregistré, mais l'attribution de ses autorisations a échoué. Rouvrez-le pour réessayer.", + "failedToLoadPermissions": "Échec du chargement des autorisations", + "failedToLoadPositionTypes": "Échec du chargement des types de poste", + "exportFailed": "Échec de l'exportation des clés de type de poste", "perFailed": "Échec de la création de l’autorisation", "perSuccess": "Type d’autorisation créé et autorisations assignées", "updatePerSuccess": "Autorisation mise à jour avec succès", diff --git a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/ActionsColumn.tsx b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/ActionsColumn.tsx deleted file mode 100644 index e7613d2e3..000000000 --- a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/ActionsColumn.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { useState } from "react"; -import { useNavigate } from "react-router-dom"; -import { Button } from "@/shared/common/ui/button"; -import { - AlertDialog, - AlertDialogTrigger, - AlertDialogContent, - AlertDialogHeader, - AlertDialogFooter, - AlertDialogTitle, - AlertDialogDescription, - AlertDialogCancel, - AlertDialogAction, -} from "@/shared/common/ui/alert-dialog"; -import { usePositionTypes } from "@/user-management/hooks/usePositionTypes"; -import { PositionTypeDto } from "@/user-management/dto/positions/positionType"; -import { t } from "i18next"; - -type ActionsColumnProps = { - row: PositionTypeDto; -}; - -const ActionsColumn: React.FC = ({ row }) => { - const navigate = useNavigate(); - const [openDialog, setOpenDialog] = useState(false); - const [deletingId, setDeletingId] = useState(null); - - const { deletePositionType } = usePositionTypes({ id: "" }); - - const handleDeleteClick = (id: string) => { - setDeletingId(id); - setOpenDialog(true); - }; - - const handleDeleteConfirm = async () => { - if (!deletingId) return; - await deletePositionType.mutateAsync(deletingId); - setOpenDialog(false); - setDeletingId(null); - }; - - return ( -
- - - - - - - - - - {t("contentManagement.delMsg")} - - {t("contentManagement.delMsg2")} - - - - { - setOpenDialog(false); - setDeletingId(null); - }} - > - {t("common.Cancel")} - - - {deletePositionType.isPending - ? t("organization.deleting") - : t("organization.delete")} - - - - -
- ); -}; - -export default ActionsColumn; diff --git a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/CreatePositionForm.tsx b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/CreatePositionForm.tsx index d525aa4f8..f8fc0cfdd 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/CreatePositionForm.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/CreatePositionForm.tsx @@ -1,455 +1,529 @@ -import { useForm } from "react-hook-form"; -import { z } from "zod"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { Input } from "@/shared/common/ui/input"; -import { Button } from "@/shared/common/ui/button"; -import { - Form, - FormField, - FormItem, - FormLabel, - FormControl, - FormMessage, -} from "@/shared/common/ui/form"; -import { toast } from "sonner"; -import { usePositionTypes } from "@/user-management/hooks/usePositionTypes"; -import { positionTypePermissionService } from "@/user-management/services/api/positionTypePermissionService"; -import { useNavigate } from "react-router-dom"; -import { t } from "i18next"; -import { useAuth } from "@/shared/context/AuthContext"; -import { useUnit } from "@/user-management/hooks/useUnit"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/shared/common/ui/select"; -import { SingleSelect } from "@/shared/common/ui/single-select"; -import { UnitDto } from "@/user-management/dto/unit/unitDto"; -import { PositionTypeDto } from "@/user-management/dto/positions/positionType"; -import { useLocalizedName } from "@/shared/common/localizedName"; -import { useOrganizations } from "@/super-admin/hooks/useOrganizations"; -import { OrganizationDto } from "@/shared/dto/organization/organizationDto"; -import i18n from "@/i18n"; -import { PermissionSearch } from "./PermissionSearch"; -import { useApplications } from "@/user-management/hooks/useApplications"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; - -const formSchema = z.object({ - nameAm: z.string().min(2), - nameEn: z.string().min(2), - permissions: z.array(z.string()), -}); - -type FormValues = z.infer; - -export interface CreatePositionFormProps { - mode?: "create" | "edit"; - positionTypeId?: string; - initialValues?: { - nameAm: string; - nameEn: string; - unitId: string; - key?: string; - }; - onSuccess?: () => void; - onCancel?: () => void; -} - -export const CreatePositionForm = ({ - mode = "create", - positionTypeId, - initialValues, - onSuccess, - onCancel, -}: CreatePositionFormProps = {}) => { - const navigate = useNavigate(); - const { - createPositionType, - updatePositionType, - positionTypes, - isLoading: isLoadingPositionTypes, - } = usePositionTypes(); - const { user } = useAuth(); - const { getList, getById } = useUnit(); - const localizedName = useLocalizedName(); - const userOrganizationId = - user?.employee && user.employee.length > 0 - ? user.employee[0].organizationId - : undefined; - const [selectedOrganizationId, setSelectedOrganizationId] = useState( - userOrganizationId ?? "", - ); - const [selectedUnitId, setSelectedUnitId] = useState( - initialValues?.unitId ?? "", - ); - const [selectedApplicationId, setSelectedApplicationId] = - useState(""); - const [copyFromPositionId, setCopyFromPositionId] = useState(""); - const [isCopying, setIsCopying] = useState(false); - const [isLoadingEditData, setIsLoadingEditData] = useState(mode === "edit"); - const hasLoadedEditData = useRef(false); - const lang = i18n.language; - const { applications, isLoading: isLoadingApplications } = useApplications(); - const queryClient = useQueryClient(); - - const { organizationsResponse, isLoading: isLoadingOrgs } = useOrganizations( - "Org", - { take: 3000 }, - ); - - const { data: unitsResponse, isLoading: isLoadingUnits } = getList( - selectedOrganizationId, - { take: 3000, skip: 0 }, - ); - - const organizationOptions = useMemo( - () => - (organizationsResponse?.items ?? []).map((org: OrganizationDto) => ({ - value: org.id, - label: localizedName(org.name) || org.id, - })), - [organizationsResponse, localizedName], - ); - - const unitOptions = useMemo( - () => - (unitsResponse?.data?.items ?? []).map((unit: UnitDto) => ({ - value: unit.id, - label: localizedName(unit.name) || unit.id, - })), - [unitsResponse, localizedName], - ); - - const { - data: editUnitResponse, - isSuccess: isUnitSuccess, - isError: isUnitError, - } = getById(initialValues?.unitId ?? ""); - - const { - data: permissionsResponse, - isSuccess: isPermissionsSuccess, - isError: isPermissionsError, - } = useQuery({ - queryKey: ["position-type-permissions", positionTypeId], - queryFn: () => - positionTypePermissionService.getPermissionsByPositionTypeId( - positionTypeId!, - ), - enabled: mode === "edit" && !!positionTypeId, - }); - // Reset the selected unit when the organization changes so a unit from a - // different org can't be submitted by mistake. - useEffect(() => { - if (mode === "edit") return; - setSelectedUnitId(""); - }, [selectedOrganizationId, mode]); - - const form = useForm({ - resolver: zodResolver(formSchema), - defaultValues: { - nameAm: initialValues?.nameAm ?? "", - nameEn: initialValues?.nameEn ?? "", - permissions: [], - }, - }); - - useEffect(() => { - if (mode !== "edit" || !initialValues || !positionTypeId) return; - if (hasLoadedEditData.current) return; - - const isUnitDone = !initialValues.unitId || isUnitSuccess || isUnitError; - const isPermissionsDone = isPermissionsSuccess || isPermissionsError; - - if (isUnitDone && isPermissionsDone) { - hasLoadedEditData.current = true; - - const unit = editUnitResponse?.data; - if (unit) { - setSelectedOrganizationId(unit.organizationId); - setSelectedUnitId(unit.id); - } else if (initialValues.unitId) { - setSelectedUnitId(initialValues.unitId); - } - - const ids = permissionsResponse?.data?.items?.map((p) => p.id) ?? []; - form.reset({ - nameAm: initialValues.nameAm, - nameEn: initialValues.nameEn, - permissions: ids, - }); - - setIsLoadingEditData(false); - } - }, [ - mode, - initialValues, - positionTypeId, - isUnitSuccess, - isUnitError, - isPermissionsSuccess, - isPermissionsError, - editUnitResponse, - permissionsResponse, - form, - ]); - - const handlePermissionChange = (permissionId: string, checked: boolean) => { - const currentPermissions = form.getValues("permissions"); - if (checked) { - form.setValue("permissions", [...currentPermissions, permissionId]); - } else { - form.setValue( - "permissions", - currentPermissions.filter((id) => id !== permissionId), - ); - } - }; - - const handleCopyFrom = async (positionTypeId: string) => { - setCopyFromPositionId(positionTypeId); - if (!positionTypeId) { - form.setValue("permissions", []); - return; - } - setIsCopying(true); - try { - const response = - await positionTypePermissionService.getPermissionsByPositionTypeId( - positionTypeId, - ); - const ids = response.data.items?.map((p) => p.id) ?? []; - form.setValue("permissions", ids); - } catch { - toast.error(t("contentManagement.copyPermissionsFailed")); - } finally { - setIsCopying(false); - } - }; - - const onSubmit = async (values: FormValues) => { - try { - if (!selectedUnitId) { - toast.error(t("organization.selectUnit")); - return; - } - - const payload = { - name: { - am: values.nameAm, - en: values.nameEn, - }, - key: values.nameEn.toLowerCase().replace(/\s+/g, "-"), - unitId: selectedUnitId, - }; - - let targetId = positionTypeId; - - if (mode === "edit" && positionTypeId) { - await updatePositionType.mutateAsync({ - id: positionTypeId, - data: payload, - }); - } else { - const response = await createPositionType.mutateAsync(payload); - targetId = response.data.id; - } - - if (targetId && values.permissions.length > 0) { - await positionTypePermissionService.assignPermissionsToPositionType({ - firstId: targetId, - secondIds: values.permissions, - }); - } - - queryClient.invalidateQueries({ - queryKey: ["position-type"], - }); - queryClient.invalidateQueries({ queryKey: ["position-types"] }); - queryClient.invalidateQueries({ - queryKey: ["position-type-permissions"], - }); - toast.success(t("contentManagement.permissionSuccess")); - - if (onSuccess) { - onSuccess(); - } else { - navigate("/user-management/position-management"); - } - } catch { - toast.error(t("contentManagement.permissionFailed")); - } - }; - - if (isLoadingEditData) { - return ( -
- {t("common.loading")} -
- ); - } - - return ( - - - ( - - {t("contentManagement.englishName")} - - - - - - )} - /> - - ( - - {t("contentManagement.amharicName")} - - - - - - )} - /> - - {/* ✅ Organization (searchable, all orgs) */} -
- - -
- - {/* ✅ Unit Selector — searchable, scoped to picked org */} -
- - -
- -
- - -
- -
- - -

- {t("contentManagement.copyPermissionsHint")} -

-
- - ( - - {t("contentManagement.permission")} - - - - )} - /> - -
- - -
- - - ); -}; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Input } from "@/shared/common/ui/input"; +import { Button } from "@/shared/common/ui/button"; +import { + Form, + FormField, + FormItem, + FormLabel, + FormControl, + FormMessage, +} from "@/shared/common/ui/form"; +import { toast } from "sonner"; +import { + invalidatePositionTypeQueries, + usePositionTypes, +} from "@/user-management/hooks/usePositionTypes"; +import { positionTypePermissionService } from "@/user-management/services/api/positionTypePermissionService"; +import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { useAuth } from "@/shared/context/AuthContext"; +import { useUnit } from "@/user-management/hooks/useUnit"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/common/ui/select"; +import { SingleSelect } from "@/shared/common/ui/single-select"; +import { UnitDto } from "@/user-management/dto/unit/unitDto"; +import { PositionTypeDto } from "@/user-management/dto/positions/positionType"; +import { useLocalizedName } from "@/shared/common/localizedName"; +import { useOrganizations } from "@/super-admin/hooks/useOrganizations"; +import { OrganizationDto } from "@/shared/dto/organization/organizationDto"; +import { useErrorHandler } from "@/shared/hooks/useErrorHandler"; +import { PermissionSearch } from "./PermissionSearch"; +import { useApplications } from "@/user-management/hooks/useApplications"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; + +export interface CreatePositionFormProps { + mode?: "create" | "edit"; + positionTypeId?: string; + initialValues?: { + nameAm: string; + nameEn: string; + unitId: string; + key?: string; + }; + onSuccess?: () => void; + onCancel?: () => void; +} + +export const CreatePositionForm = ({ + mode = "create", + positionTypeId, + initialValues, + onSuccess, + onCancel, +}: CreatePositionFormProps = {}) => { + const navigate = useNavigate(); + const { t } = useTranslation(); + const { handleError } = useErrorHandler(t); + const { + createPositionType, + updatePositionType, + positionTypes, + isLoading: isLoadingPositionTypes, + isError: isErrorPositionTypes, + } = usePositionTypes(); + const { user } = useAuth(); + const { getList, getById } = useUnit(); + const localizedName = useLocalizedName(); + const userOrganizationId = + user?.employee && user.employee.length > 0 + ? user.employee[0].organizationId + : undefined; + const [selectedApplicationId, setSelectedApplicationId] = + useState(""); + const [copyFromPositionId, setCopyFromPositionId] = useState(""); + const [isCopying, setIsCopying] = useState(false); + const [isLoadingEditData, setIsLoadingEditData] = useState(mode === "edit"); + const hasLoadedEditData = useRef(false); + // Permissions the position type had when the form opened. Needed because the + // API cannot represent "no permissions" (see onSubmit). + const loadedPermissionCount = useRef(0); + const { applications, isLoading: isLoadingApplications } = useApplications(); + const queryClient = useQueryClient(); + + const formSchema = useMemo( + () => + z.object({ + nameEn: z.string().trim().min(2, t("organization.englishNameRequired")), + nameAm: z.string().trim().min(2, t("organization.amharicNameRequired")), + organizationId: z.string().min(1, t("organization.organizationRequired")), + unitId: z.string().min(1, t("contentManagement.unitRequired")), + permissions: z.array(z.string()), + }), + [t], + ); + + type FormValues = z.infer; + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + nameAm: initialValues?.nameAm ?? "", + nameEn: initialValues?.nameEn ?? "", + organizationId: userOrganizationId ?? "", + unitId: initialValues?.unitId ?? "", + permissions: [], + }, + }); + + const selectedOrganizationId = form.watch("organizationId"); + + const { organizationsResponse, isLoading: isLoadingOrgs } = useOrganizations( + "Org", + { take: 3000 }, + ); + + const { data: unitsResponse, isLoading: isLoadingUnits } = getList( + selectedOrganizationId, + { take: 3000, skip: 0 }, + ); + + const organizationOptions = useMemo( + () => + (organizationsResponse?.items ?? []).map((org: OrganizationDto) => ({ + value: org.id, + label: localizedName(org.name) || org.id, + })), + [organizationsResponse, localizedName], + ); + + const unitOptions = useMemo( + () => + (unitsResponse?.data?.items ?? []).map((unit: UnitDto) => ({ + value: unit.id, + label: localizedName(unit.name) || unit.id, + })), + [unitsResponse, localizedName], + ); + + const { + data: editUnitResponse, + isSuccess: isUnitSuccess, + isError: isUnitError, + } = getById(initialValues?.unitId ?? ""); + + const { + data: permissionsResponse, + isSuccess: isPermissionsSuccess, + isError: isPermissionsError, + } = useQuery({ + queryKey: ["position-type-permissions", positionTypeId], + queryFn: () => + positionTypePermissionService.getPermissionsByPositionTypeId( + positionTypeId!, + ), + enabled: mode === "edit" && !!positionTypeId, + }); + + // A position type belongs to a unit, and a unit to an organization — IAM has + // no organizationId on the type itself and no organization-scoped route, so + // the picked org narrows the list through its units. isSystem types are the + // shared "commons" and stay available to every organization. + const orgUnitIds = useMemo( + () => + new Set( + (unitsResponse?.data?.items ?? []).map((unit: UnitDto) => unit.id), + ), + [unitsResponse], + ); + + const copyFromOptions = useMemo(() => { + if (!selectedOrganizationId) return []; + return positionTypes.filter( + (type: PositionTypeDto) => + type.id !== positionTypeId && + (type.isSystem || (!!type.unitId && orgUnitIds.has(type.unitId))), + ); + }, [positionTypes, orgUnitIds, selectedOrganizationId, positionTypeId]); + + // Reset the selected unit when the organization changes so a unit from a + // different org can't be submitted by mistake. The copy source is cleared + // too — it is scoped to the old organization. + useEffect(() => { + if (mode === "edit") return; + form.setValue("unitId", ""); + setCopyFromPositionId(""); + }, [selectedOrganizationId, mode, form]); + + useEffect(() => { + if (mode !== "edit" || !initialValues || !positionTypeId) return; + if (hasLoadedEditData.current) return; + + const isUnitDone = !initialValues.unitId || isUnitSuccess || isUnitError; + const isPermissionsDone = isPermissionsSuccess || isPermissionsError; + + if (isUnitDone && isPermissionsDone) { + hasLoadedEditData.current = true; + + const unit = editUnitResponse?.data; + const ids = permissionsResponse?.data?.items?.map((p) => p.id) ?? []; + loadedPermissionCount.current = ids.length; + + form.reset({ + nameAm: initialValues.nameAm, + nameEn: initialValues.nameEn, + organizationId: unit?.organizationId ?? userOrganizationId ?? "", + unitId: unit?.id ?? initialValues.unitId ?? "", + permissions: ids, + }); + + setIsLoadingEditData(false); + } + }, [ + mode, + initialValues, + positionTypeId, + isUnitSuccess, + isUnitError, + isPermissionsSuccess, + isPermissionsError, + editUnitResponse, + permissionsResponse, + userOrganizationId, + form, + ]); + + const handlePermissionChange = (permissionId: string, checked: boolean) => { + const currentPermissions = form.getValues("permissions"); + form.setValue( + "permissions", + checked + ? [...currentPermissions, permissionId] + : currentPermissions.filter((id) => id !== permissionId), + ); + }; + + const handleCopyFrom = async (sourcePositionTypeId: string) => { + setCopyFromPositionId(sourcePositionTypeId); + setIsCopying(true); + try { + const response = + await positionTypePermissionService.getPermissionsByPositionTypeId( + sourcePositionTypeId, + ); + const ids = response.data.items?.map((p) => p.id) ?? []; + form.setValue("permissions", ids); + } catch (error) { + handleError(error); + toast.error(t("contentManagement.copyPermissionsFailed")); + } finally { + setIsCopying(false); + } + }; + + const onSubmit = async (values: FormValues) => { + const payload = { + name: { am: values.nameAm, en: values.nameEn }, + key: values.nameEn.toLowerCase().replace(/\s+/g, "-"), + unitId: values.unitId, + }; + + // Save the position type first. If this fails nothing else runs, and the + // mutation's own onError surfaces the reason (403 for built-in types, + // conflict on the globally-unique key, ...). + let targetId = positionTypeId; + try { + if (mode === "edit" && positionTypeId) { + await updatePositionType.mutateAsync({ + id: positionTypeId, + data: payload, + }); + } else { + const response = await createPositionType.mutateAsync(payload); + targetId = response.data.id; + } + } catch { + return; // already reported by the mutation's onError + } + + // assign-seconds-for-first replaces the whole set, but an empty secondIds + // fails server-side — so "unassign everything" is not expressible. Keep the + // save and tell the user their permissions were left alone. + const mustClearAll = + values.permissions.length === 0 && loadedPermissionCount.current > 0; + + if (targetId && values.permissions.length > 0) { + try { + await positionTypePermissionService.assignPermissionsToPositionType({ + firstId: targetId, + secondIds: values.permissions, + }); + } catch (error) { + handleError(error); + invalidatePositionTypeQueries(queryClient); + toast.error(t("contentManagement.permissionsAssignFailed")); + return; + } + } + + invalidatePositionTypeQueries(queryClient); + queryClient.invalidateQueries({ queryKey: ["position-type-permissions"] }); + + if (mustClearAll) { + toast.warning(t("contentManagement.cannotClearAllPermissions")); + } else { + toast.success(t("contentManagement.permissionSuccess")); + } + + if (onSuccess) { + onSuccess(); + } else { + navigate("/user-management/position-management"); + } + }; + + if (isLoadingEditData) { + return ( +
+ {t("common.loading")} +
+ ); + } + + const selectedPermissionCount = form.watch("permissions").length; + // form.formState.isSubmitting stays true for the whole async handler, so it + // also covers the permission-assignment call that follows the save. + const isBusy = form.formState.isSubmitting || isCopying; + + const copyFromPlaceholder = !selectedOrganizationId + ? t("contentManagement.selectOrganizationToCopy") + : isCopying || isLoadingPositionTypes || isLoadingUnits + ? t("common.loading") + : isErrorPositionTypes + ? t("contentManagement.failedToLoadPositionTypes") + : t("contentManagement.selectPositionToCopy"); + + return ( +
+ + ( + + {t("contentManagement.englishName")} + + + + + + )} + /> + + ( + + {t("contentManagement.amharicName")} + + + + + + )} + /> + + {/* Organization (searchable, all orgs) */} + ( + + {t("organization.organization")} + + + + )} + /> + + {/* Unit Selector — searchable, scoped to picked org */} + ( + + {t("organization.selectUnit")} + + + + )} + /> + +
+ + +
+ +
+ + +

+ {t("contentManagement.copyPermissionsHint")} +

+
+ + ( + + + {t("contentManagement.permission")} + {selectedPermissionCount > 0 && ( + + ( + {t("contentManagement.permissionsSelected", { + count: selectedPermissionCount, + })} + ) + + )} + + + + + )} + /> + +
+ + +
+ + + ); +}; diff --git a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/EditPositionForm.tsx b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/EditPositionForm.tsx index e69a6eedb..befe2f7d8 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/EditPositionForm.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/EditPositionForm.tsx @@ -1,160 +1,193 @@ -import { useEffect, useState } from "react"; -import { Button } from "@/shared/common/ui/button"; -import { Input } from "@/shared/common/ui/input"; -import { Label } from "@/shared/common/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/shared/common/ui/select"; -import { useNavigate } from "react-router-dom"; - -import { useUnit } from "@/user-management/hooks/useUnit"; -import { useAuth } from "@/shared/context/AuthContext"; -import { positionTypePermissionService } from "@/user-management/services/api/positionTypePermissionService"; -import { usePositionTypes } from "@/user-management/hooks/usePositionTypes"; -import { useApplications } from "@/user-management/hooks/useApplications"; -import { PermissionSearch } from "./PermissionSearch"; -import { PermissionDto } from "@/user-management/dto/permissions/permissonDto"; -import { useLocalizedName } from "@/shared/common/localizedName"; -import { UnitDto } from "@/user-management/dto/unit/unitDto"; -import { t } from "i18next"; - -export const EditPositionForm = ({ id }: { id: string }) => { - const navigate = useNavigate(); - const { user } = useAuth(); - const { getList } = useUnit(); - const localizedName = useLocalizedName(); - - const { positionType, isLoadingSingle } = usePositionTypes({ id }); - - const organizationId = - user?.employee && user.employee.length > 0 - ? user.employee[0].organizationId - : undefined; - - const { applications, isLoading: isLoadingApplications } = useApplications(); - - const { data: unitsResponse } = getList(organizationId || "", { - take: 300, - skip: 0, - }); - - const [selectedApplicationId, setSelectedApplicationId] = - useState(""); - const [assignedPermissions, setAssignedPermissions] = useState< - PermissionDto[] - >([]); - const [isLoadingPermissions, setIsLoadingPermissions] = useState(false); - - useEffect(() => { - const load = async () => { - if (!positionType) return; - setIsLoadingPermissions(true); - try { - const assigned = - await positionTypePermissionService.getPermissionsByPositionTypeId( - positionType.id, - ); - setAssignedPermissions(assigned.data.items ?? []); - } finally { - setIsLoadingPermissions(false); - } - }; - load(); - }, [positionType]); - - if (isLoadingSingle) return

Loading...

; - if (!positionType) return null; - - const unit = unitsResponse?.data?.items?.find( - (u: UnitDto) => u.id === positionType.unitId, - ); - const unitName = unit ? unit.name.en || unit.name.am : positionType.unitId; - - return ( -
-
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - {selectedApplicationId ? ( - perm.id)} - onPermissionChange={() => { - // view-only mode in edit form - }} - applicationId={selectedApplicationId} - disabled - /> - ) : ( -
- {isLoadingPermissions ? ( -
Loading...
- ) : assignedPermissions.length === 0 ? ( -
- {t("contentManagement.noPermissionsAvailable")} -
- ) : ( -
    - {assignedPermissions.map((perm) => ( -
  • - {localizedName(perm.name)} -
  • - ))} -
- )} -
- )} -
- -
- -
-
- ); -}; +import { useState } from "react"; +import { Button } from "@/shared/common/ui/button"; +import { Input } from "@/shared/common/ui/input"; +import { Label } from "@/shared/common/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/common/ui/select"; +import { useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; + +import { useUnit } from "@/user-management/hooks/useUnit"; +import { useAuth } from "@/shared/context/AuthContext"; +import { positionTypePermissionService } from "@/user-management/services/api/positionTypePermissionService"; +import { usePositionTypes } from "@/user-management/hooks/usePositionTypes"; +import { useApplications } from "@/user-management/hooks/useApplications"; +import { PermissionSearch } from "./PermissionSearch"; +import { useLocalizedName } from "@/shared/common/localizedName"; +import { UnitDto } from "@/user-management/dto/unit/unitDto"; +import { t } from "i18next"; + +export const EditPositionForm = ({ id }: { id: string }) => { + const navigate = useNavigate(); + const { user } = useAuth(); + const { getList } = useUnit(); + const localizedName = useLocalizedName(); + + const { positionType, isLoadingSingle, isErrorSingle } = usePositionTypes({ + id, + }); + + const organizationId = + user?.employee && user.employee.length > 0 + ? user.employee[0].organizationId + : undefined; + + const { applications, isLoading: isLoadingApplications } = useApplications(); + + const { data: unitsResponse } = getList(organizationId || "", { + take: 300, + skip: 0, + }); + + const [selectedApplicationId, setSelectedApplicationId] = + useState(""); + + // Shares the cache key CreatePositionForm writes under, so editing a position + // type's permissions refreshes this view too. + const { + data: assignedResponse, + isLoading: isLoadingPermissions, + isError: isErrorPermissions, + } = useQuery({ + queryKey: ["position-type-permissions", id], + queryFn: () => + positionTypePermissionService.getPermissionsByPositionTypeId(id), + enabled: !!id, + }); + + const assignedPermissions = assignedResponse?.data?.items ?? []; + + if (isLoadingSingle) { + return ( +

+ {t("common.loading")} +

+ ); + } + + if (isErrorSingle || !positionType) { + return ( +
+

+ {t("contentManagement.positionTypeNotFound")} +

+
+ +
+
+ ); + } + + const unit = unitsResponse?.data?.items?.find( + (u: UnitDto) => u.id === positionType.unitId, + ); + const unitName = unit ? unit.name.en || unit.name.am : positionType.unitId; + + return ( +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + {selectedApplicationId ? ( + perm.id)} + onPermissionChange={() => { + // view-only mode in edit form + }} + applicationId={selectedApplicationId} + disabled + /> + ) : ( +
+ {isLoadingPermissions ? ( +
+ {t("common.loading")} +
+ ) : isErrorPermissions ? ( +
+ {t("contentManagement.failedToLoadPermissions")} +
+ ) : assignedPermissions.length === 0 ? ( +
+ {t("contentManagement.noPermissionsAvailable")} +
+ ) : ( +
    + {assignedPermissions.map((perm) => ( +
  • + {localizedName(perm.name)} +
  • + ))} +
+ )} +
+ )} +
+ +
+ +
+
+ ); +}; diff --git a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PermissionSearch.tsx b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PermissionSearch.tsx index bfc645fc7..8d853a3b0 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PermissionSearch.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PermissionSearch.tsx @@ -1,152 +1,120 @@ -import React, { useState, useEffect, useMemo, useRef } from "react"; -import { Input } from "@/shared/common/ui/input"; -import { Checkbox } from "@/shared/common/ui/checkbox"; -import { usePermissionManager } from "@/user-management/hooks/usePermissionManager"; -import { PermissionDto } from "@/user-management/dto/permissions/permissonDto"; -import { useLocalizedName } from "@/shared/common/localizedName"; -import { t } from "i18next"; -import { Search, Loader2 } from "lucide-react"; - -interface PermissionSearchProps { - selectedPermissions: string[]; - onPermissionChange: (permissionId: string, checked: boolean) => void; - applicationId?: string; - disabled?: boolean; -} - -const INITIAL_TAKE = 50; // Initial number of items to fetch - -export const PermissionSearch: React.FC = ({ - selectedPermissions, - onPermissionChange, - applicationId, - disabled = false, -}) => { - const [searchTerm, setSearchTerm] = useState(""); - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); - const [take, setTake] = useState(INITIAL_TAKE); // Start with 50 - const hasSetTotalCount = useRef(false); // Track if we've set the total count - - const scrollContainerRef = useRef(null); - - const localizedName = useLocalizedName(); - - /** ------------------ 1. Debounce Search ------------------ */ - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearchTerm(searchTerm); - setTake(INITIAL_TAKE); // Reset to 50 - hasSetTotalCount.current = false; // Reset the flag - }, 300); - - return () => clearTimeout(timer); - }, [searchTerm]); - - /** ------------------ 2. Fetch Permissions ------------------ */ - const { permissions, isPermissionsLoading } = usePermissionManager({ - params: applicationId - ? { - take, - skip: 0, // Always skip 0, we fetch everything at once - search: debouncedSearchTerm || undefined, - applicationId, - } - : undefined, - }); - - /** ------------------ 3. Update take to total count after first fetch ------------------ */ - useEffect(() => { - if ( - permissions?.count && - !hasSetTotalCount.current && - take !== permissions.count - ) { - hasSetTotalCount.current = true; - setTake(permissions.count); // Fetch all items - } - }, [permissions?.count, take]); - - /** ------------------ 4. Client-side Filtering (Optional) ------------------ */ - const filteredPermissions = useMemo(() => { - if (!permissions?.items?.length) return []; - if (!searchTerm.trim()) return permissions.items; - - return permissions.items.filter((perm: PermissionDto) => { - const name = localizedName(perm.name).toLowerCase(); - const key = perm.key.toLowerCase(); - const search = searchTerm.toLowerCase(); - return name.includes(search) || key.includes(search); - }); - }, [permissions?.items, searchTerm, localizedName]); - - /** ------------------ Render ------------------ */ - return ( -
- {/* Search Input */} -
- - setSearchTerm(e.target.value)} - className="pl-10" - /> -
- - {/* Permission List Container */} - {!applicationId ? ( -
- {t("contentManagement.selectApplicationToLoadPermissions") || - "Select an application to load permissions."} -
- ) : isPermissionsLoading ? ( -
- -
- ) : ( -
- {filteredPermissions.length === 0 ? ( -
- {searchTerm - ? t("contentManagement.noPermissionsFound") - : t("contentManagement.noPermissionsAvailable")} -
- ) : ( -
- {filteredPermissions.map((perm: PermissionDto) => ( -
- { - if (!disabled) onPermissionChange(perm.id, !!checked); - }} - /> - -
- ))} -
- )} -
- )} - - {/* Footer Info */} - {filteredPermissions.length > 0 && ( -
- {t("contentManagement.showingPermissions", { - count: filteredPermissions.length, - total: permissions?.count || 0, - })} -
- )} -
- ); -}; +import React, { useState, useEffect } from "react"; +import { Input } from "@/shared/common/ui/input"; +import { Checkbox } from "@/shared/common/ui/checkbox"; +import { usePermissionManager } from "@/user-management/hooks/usePermissionManager"; +import { PermissionDto } from "@/user-management/dto/permissions/permissonDto"; +import { useLocalizedName } from "@/shared/common/localizedName"; +import { t } from "i18next"; +import { Search, Loader2 } from "lucide-react"; + +interface PermissionSearchProps { + selectedPermissions: string[]; + onPermissionChange: (permissionId: string, checked: boolean) => void; + applicationId?: string; + disabled?: boolean; +} + +// One request per application. This used to fetch 50, read `count` off the +// response and immediately refetch with take = count — two round trips on every +// mount for the same list. +const TAKE = 1000; + +export const PermissionSearch: React.FC = ({ + selectedPermissions, + onPermissionChange, + applicationId, + disabled = false, +}) => { + const [searchTerm, setSearchTerm] = useState(""); + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); + + const localizedName = useLocalizedName(); + + /** ------------------ 1. Debounce Search ------------------ */ + useEffect(() => { + const timer = setTimeout(() => setDebouncedSearchTerm(searchTerm), 300); + return () => clearTimeout(timer); + }, [searchTerm]); + + /** ------------------ 2. Fetch Permissions ------------------ */ + // The API does the filtering. Filtering the result again on the *undebounced* + // term used to blank the list for 300ms on every keystroke. + const { permissions, isPermissionsLoading } = usePermissionManager({ + params: applicationId + ? { + take: TAKE, + skip: 0, + search: debouncedSearchTerm || undefined, + applicationId, + } + : undefined, + }); + + const items = permissions?.items ?? []; + + /** ------------------ Render ------------------ */ + return ( +
+ {/* Search Input */} +
+ + setSearchTerm(e.target.value)} + className="pl-10" + /> +
+ + {/* Permission List Container */} + {!applicationId ? ( +
+ {t("contentManagement.selectApplicationToLoadPermissions")} +
+ ) : isPermissionsLoading ? ( +
+ +
+ ) : ( +
+ {items.length === 0 ? ( +
+ {debouncedSearchTerm + ? t("contentManagement.noPermissionsFound") + : t("contentManagement.noPermissionsAvailable")} +
+ ) : ( +
+ {items.map((perm: PermissionDto) => ( +
+ { + if (!disabled) onPermissionChange(perm.id, !!checked); + }} + /> + +
+ ))} +
+ )} +
+ )} + + {/* Footer Info */} + {items.length > 0 && ( +
+ {t("contentManagement.showingPermissions", { + count: items.length, + total: permissions?.count || 0, + })} +
+ )} +
+ ); +}; diff --git a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionLists.tsx b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionLists.tsx index d15d82799..142b97f83 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionLists.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionLists.tsx @@ -1,276 +1,253 @@ -import { useEffect, useState, useMemo } from "react"; -import { Button } from "@/shared/common/ui/button"; -import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable"; - -import { Link } from "react-router-dom"; -import { Plus } from "lucide-react"; -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from "@/shared/common/ui/card"; -import { usePositionTypes } from "@/user-management/hooks/usePositionTypes"; -import { createPositionTypeColumns } from "./PositionTypeColumnDefn"; -import { positionTypeService } from "@/user-management/services/api/positionTypesService"; -import { t } from "i18next"; -import { useUnit } from "@/user-management/hooks/useUnit"; -import { useAuth } from "@/shared/context/AuthContext"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/shared/common/ui/select"; -import { UnitDto } from "@/user-management/dto/unit/unitDto"; -import { usePositionTypeConfiguration } from "@/user-management/hooks/usePostionType"; - -export default function PositionManagement() { - const [pageIndex, setPageIndex] = useState(0); - const pageSize = 10; - const [isExporting, setIsExporting] = useState(false); - const [searchTerm, setSearchTerm] = useState(""); - const { createConfiguration } = usePositionTypeConfiguration(); - - const { user } = useAuth(); - - const { getAccessibleList } = useUnit(); - - const organizationId = user?.employee?.[0]?.organizationId; - - const { data: unitsResponse } = getAccessibleList(organizationId ?? "", { - take: 300, - skip: 0, - }); - - // Add state for selected unitId - // Default: if super_admin => "All", otherwise wait for units - const [selectedUnitId, setSelectedUnitId] = useState("All"); - - useEffect(() => { - // If there’s no selectedUnitId yet, default to first unit (if any), otherwise keep "All" - if (!selectedUnitId) { - if (unitsResponse?.data?.items?.length) { - setSelectedUnitId(unitsResponse.data.items[0].id); - } else { - setSelectedUnitId("All"); - } - } - }, [unitsResponse, selectedUnitId]); - - // Reset to first page whenever the search term or unit changes so users - // land on the first page of matches instead of an empty later page. - useEffect(() => { - setPageIndex(0); - }, [searchTerm, selectedUnitId]); - - const handlePageChange = (newPage: number) => { - setPageIndex(newPage); - }; - const { - positionTypeResponse, - isLoading, - positionTypeByUnitId, - refetch, - refetchPosition, - } = usePositionTypes({ - params: { - take: 1000, - skip: 0, - orderBy: "updatedAt:DESC", - }, - unitId: selectedUnitId === "All" ? undefined : selectedUnitId, - }); - - // Fetch position types without unitId for migration options - const { - positionTypeResponse: globalPositionTypes, - refetch: refetchGlobalPositionTypes, - } = usePositionTypes({ - params: { - take: 1000, // Get all global position types - skip: 0, - orderBy: "updatedAt:DESC", - }, - unitId: undefined, // Explicitly fetch position types without unitId - }); - - // Create a combined refetch function for the onDelete callback - const handlePositionTypeDeleted = async () => { - await Promise.all([ - selectedUnitId === "All" ? refetch() : refetchPosition(), - refetchGlobalPositionTypes(), - ]); - }; - const handleToggle = async ( - positionTypeId: string, - checked: boolean, - field: "canReceiveRecord" | "canAssignRecord" | "canCreateBankRecord", - ) => { - if (!selectedUnitId || selectedUnitId === "All") return; - - await createConfiguration({ - positionTypeId, - timeframe: "yearly", - organizationId: organizationId!, - canReceiveRecord: field === "canReceiveRecord" ? checked : false, - canAssignRecord: field === "canAssignRecord" ? checked : false, - canCreateBankRecord: field === "canCreateBankRecord" ? checked : false, - }); - - await handlePositionTypeDeleted(); - }; - - // Create columns with positionTypeResponse - const columns = useMemo( - () => - createPositionTypeColumns( - selectedUnitId === "All" ? positionTypeResponse : positionTypeByUnitId, - globalPositionTypes, - handlePositionTypeDeleted, - handlePositionTypeDeleted, - handleToggle, // ← pass toggle handler - selectedUnitId === "All", // ← isGlobal: hide toggle when "All" - ), - [ - selectedUnitId, - positionTypeResponse, - positionTypeByUnitId, - globalPositionTypes, - ], - ); - const allItems = useMemo( - () => - (selectedUnitId === "All" - ? positionTypeResponse?.items - : positionTypeByUnitId?.items) || [], - [selectedUnitId, positionTypeResponse?.items, positionTypeByUnitId?.items], - ); - - const filteredItems = useMemo(() => { - const trimmed = searchTerm.trim().toLowerCase(); - if (!trimmed) return allItems; - return allItems.filter((item: any) => { - const en = (item?.name?.en || "").toLowerCase(); - const am = (item?.name?.am || "").toLowerCase(); - const key = (item?.key || "").toLowerCase(); - return ( - en.includes(trimmed) || am.includes(trimmed) || key.includes(trimmed) - ); - }); - }, [allItems, searchTerm]); - - const paginatedItems = useMemo(() => { - const start = pageIndex * pageSize; - return filteredItems.slice(start, start + pageSize); - }, [filteredItems, pageIndex, pageSize]); - - if (isLoading) { - return
{t("contentManagement.addUser")}
; - } - - const exportTypes = () => { - setIsExporting(true); - positionTypeService - .getAll({ - take: 3000, - }) - .then((allPositionKeys) => { - // Get the position type keys - const positionTypeKeys = allPositionKeys.data?.items?.map((p) => p.key); - - if (positionTypeKeys && positionTypeKeys.length > 0) { - // Convert the array of keys into a string, with each key on a new line - const fileContent = positionTypeKeys.join("\n"); - - // Create a Blob from the string content - const blob = new Blob([fileContent], { type: "text/plain" }); - - // Create a link element to trigger the download - const link = document.createElement("a"); - - // Create an object URL for the Blob - link.href = URL.createObjectURL(blob); - - // Set the download attribute with a file name - link.download = "position_keys.txt"; - - // Programmatically trigger a click on the link to start the download - link.click(); - - // Clean up by revoking the object URL - URL.revokeObjectURL(link.href); - } else { - console.error("No position type keys found."); - } - setIsExporting(false); - }); - }; - - return ( -
- - - - {t("contentManagement.permissionType")} - - - - {unitsResponse?.data?.items?.length > 0 && ( -
- - -
- )} - - - - - } - pageIndex={pageIndex} - onPageChange={handlePageChange} - nextFunction={() => handlePageChange(pageIndex + 1)} - prevFunction={() => handlePageChange(Math.max(pageIndex - 1, 0))} - /> - -
-
- ); -} +import { useEffect, useState, useMemo, useCallback } from "react"; +import { Button } from "@/shared/common/ui/button"; +import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable"; + +import { Link } from "react-router-dom"; +import { Plus } from "lucide-react"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/shared/common/ui/card"; +import { usePositionTypes } from "@/user-management/hooks/usePositionTypes"; +import { createPositionTypeColumns } from "./PositionTypeColumnDefn"; +import { positionTypeService } from "@/user-management/services/api/positionTypesService"; +import { t } from "i18next"; +import { toast } from "sonner"; +import { useUnit } from "@/user-management/hooks/useUnit"; +import { useAuth } from "@/shared/context/AuthContext"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/common/ui/select"; +import { UnitDto } from "@/user-management/dto/unit/unitDto"; +import { PositionTypeDto } from "@/user-management/dto/positions/positionType"; + +export default function PositionManagement() { + const [pageIndex, setPageIndex] = useState(0); + const pageSize = 10; + const [isExporting, setIsExporting] = useState(false); + const [searchTerm, setSearchTerm] = useState(""); + + const { user } = useAuth(); + + const { getAccessibleList } = useUnit(); + + const organizationId = user?.employee?.[0]?.organizationId; + + const { data: unitsResponse, isError: isUnitsError } = getAccessibleList( + organizationId ?? "", + { + take: 300, + skip: 0, + }, + ); + + // Add state for selected unitId + // Default: if super_admin => "All", otherwise wait for units + const [selectedUnitId, setSelectedUnitId] = useState("All"); + + useEffect(() => { + // If there’s no selectedUnitId yet, default to first unit (if any), otherwise keep "All" + if (!selectedUnitId) { + if (unitsResponse?.data?.items?.length) { + setSelectedUnitId(unitsResponse.data.items[0].id); + } else { + setSelectedUnitId("All"); + } + } + }, [unitsResponse, selectedUnitId]); + + // Reset to first page whenever the search term or unit changes so users + // land on the first page of matches instead of an empty later page. + useEffect(() => { + setPageIndex(0); + }, [searchTerm, selectedUnitId]); + + const handlePageChange = (newPage: number) => { + setPageIndex(newPage); + }; + + const showingAllUnits = selectedUnitId === "All"; + + const { + positionTypeResponse, + isLoading, + isError, + positionTypeByUnitId, + isLoadingPosition, + isErrorPosition, + refetch, + refetchPosition, + } = usePositionTypes({ + params: { + take: 1000, + skip: 0, + orderBy: "updatedAt:DESC", + }, + unitId: showingAllUnits ? undefined : selectedUnitId, + }); + + // Refresh whichever list is on screen. `positionTypeResponse` is the + // unscoped fetch, so it doubles as the migration-target source — no second + // usePositionTypes() call needed (its cache key ignores unitId, so a second + // call returned the very same query). + const handlePositionTypeChanged = useCallback(async () => { + await (showingAllUnits ? refetch() : refetchPosition()); + }, [showingAllUnits, refetch, refetchPosition]); + + const columns = useMemo( + () => + createPositionTypeColumns( + positionTypeResponse, + handlePositionTypeChanged, + handlePositionTypeChanged, + ), + [positionTypeResponse, handlePositionTypeChanged], + ); + + const allItems = useMemo( + () => + (showingAllUnits + ? positionTypeResponse?.items + : positionTypeByUnitId?.items) || [], + [showingAllUnits, positionTypeResponse?.items, positionTypeByUnitId?.items], + ); + + const filteredItems = useMemo(() => { + const trimmed = searchTerm.trim().toLowerCase(); + if (!trimmed) return allItems; + return allItems.filter((item: PositionTypeDto) => { + const en = (item?.name?.en || "").toLowerCase(); + const am = (item?.name?.am || "").toLowerCase(); + const key = (item?.key || "").toLowerCase(); + return ( + en.includes(trimmed) || am.includes(trimmed) || key.includes(trimmed) + ); + }); + }, [allItems, searchTerm]); + + const paginatedItems = useMemo(() => { + const start = pageIndex * pageSize; + return filteredItems.slice(start, start + pageSize); + }, [filteredItems, pageIndex, pageSize]); + + // Track whichever query is actually feeding the table — picking a unit used + // to leave the previous unit's rows on screen with no loading state. + const isLoadingList = showingAllUnits ? isLoading : isLoadingPosition; + const isErrorList = showingAllUnits ? isError : isErrorPosition; + + const exportTypes = () => { + setIsExporting(true); + positionTypeService + .getAll({ take: 3000 }) + .then((allPositionKeys) => { + const positionTypeKeys = allPositionKeys.data?.items?.map((p) => p.key); + + if (!positionTypeKeys?.length) { + toast.error(t("contentManagement.exportFailed")); + return; + } + + // One key per line, downloaded as a plain text file. + const blob = new Blob([positionTypeKeys.join("\n")], { + type: "text/plain", + }); + const link = document.createElement("a"); + link.href = URL.createObjectURL(blob); + link.download = "position_keys.txt"; + link.click(); + URL.revokeObjectURL(link.href); + }) + .catch(() => { + toast.error(t("contentManagement.exportFailed")); + }) + .finally(() => { + setIsExporting(false); + }); + }; + + return ( +
+ + + + {t("contentManagement.permissionType")} + + + + {!!unitsResponse?.data?.items?.length && ( +
+ + +
+ )} + {isUnitsError && ( +

+ {t("organization.errorLoadingUnits")} +

+ )} + + {isLoadingList ? ( +
+ {t("common.loading")} +
+ ) : isErrorList ? ( +
+ {t("contentManagement.failedToLoadPositionTypes")} +
+ ) : ( + + + + } + pageIndex={pageIndex} + onPageChange={handlePageChange} + nextFunction={() => handlePageChange(pageIndex + 1)} + prevFunction={() => handlePageChange(Math.max(pageIndex - 1, 0))} + /> + )} +
+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionTypeActions.tsx b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionTypeActions.tsx index 32613a0ea..1a2b829d6 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionTypeActions.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionTypeActions.tsx @@ -1,393 +1,251 @@ -import { useState } from "react"; -import { useNavigate } from "react-router-dom"; -import { PositionTypeDto } from "@/user-management/dto/positions/positionType"; - -import { - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuLabel, - DropdownMenuItem, -} from "@/shared/common/ui/dropdown-menu"; - -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/shared/common/ui/alert-dialog"; - -import { Button } from "@/shared/common/ui/button"; -import { MoreVertical, Edit, Eye, Trash2, Pencil } from "lucide-react"; -import { t } from "i18next"; -import PositionTypeMigrationModal from "./PostionTypeMigration"; -import { CreatePositionForm } from "./CreatePositionForm"; -import { toast } from "sonner"; -import { useLocalizedName } from "@/shared/common/localizedName"; -import { positionTypeService } from "@/user-management/services/api/positionTypesService"; -import { useErrorHandler } from "@/shared/hooks/useErrorHandler"; -import { usePositionTypeConfiguration } from "@/user-management/hooks/usePostionType"; -import { Switch } from "@/shared/common/ui/switch"; -import { PositionTypeConfigurationDto } from "@/user-management/services/api/positionTypeConfigurationService"; -import { useQueryClient } from "@tanstack/react-query"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "@/shared/common/ui/dialog"; - -interface PositionTypeResponse { - items: PositionTypeDto[]; - count: number; -} - -type ActionsCellProps = { - row: PositionTypeDto | PositionTypeConfigurationDto; - globalPositionTypes?: PositionTypeResponse; - onDelete?: () => void | Promise; - onEdit?: () => void | Promise; - onToggle?: ( - positionTypeId: string, - checked: boolean, - field: "canReceiveRecord" | "canAssignRecord" | "canCreateBankRecord", - ) => void | Promise; - isGlobal?: boolean; // true when viewing "All" units — hide toggle -}; - -const PositionTypeActionsCell: React.FC = ({ - row, - globalPositionTypes, - onDelete, - onEdit, - onToggle, - isGlobal = false, -}) => { - const navigate = useNavigate(); - const [dropdownOpen, setDropdownOpen] = useState(false); - const [showMigrateDialog, setShowMigrateDialog] = useState(false); - const [showEditDialog, setShowEditDialog] = useState(false); - const [showDeleteDialog, setShowDeleteDialog] = useState(false); - const [isDeleting, setIsDeleting] = useState(false); - const localizedName = useLocalizedName(); - const { handleError } = useErrorHandler(t); - const queryClient = useQueryClient(); - // Use row.id as the positionTypeId for the configuration lookup - - const { - configurations, - isLoadingConfigurations, - updateConfiguration, - isUpdatingConfiguration, - } = usePositionTypeConfiguration( - row?.id ?? null, // 👈 pass row.id as unitId - ); - - const configItem = configurations[0]; - const isCanReceiveRecord = configItem?.canReceiveRecord ?? false; - const isCanAssignRecord = configItem?.canAssignRecord ?? false; - const isCanCreateBankRecord = configItem?.canCreateBankRecord ?? false; - - const invalidateConfig = () => { - queryClient.invalidateQueries({ - queryKey: ["positionTypeConfigurations", row.id], - }); - queryClient.invalidateQueries({ - queryKey: ["positionTypeConfiguration", row.id], - }); - }; - - // Create position type options from globalPositionTypes - only those WITHOUT unitId - const positionTypeOptions = - globalPositionTypes?.items - .filter((item) => !item.unitId) - .map((item) => ({ - label: localizedName(item.name), - value: item.id, - })) || []; - - // Only show migrate/delete actions if current row has a unitId - const canBeModified = !!row.unitId; - - const handleView = () => { - navigate(`/user-management/position-management/edit/${row.id}`); - }; - - const handleMigrate = (e: Event) => { - e.preventDefault(); - setDropdownOpen(false); - setShowMigrateDialog(true); - }; - - const handleEdit = (e: Event) => { - e.preventDefault(); - setDropdownOpen(false); - setShowEditDialog(true); - }; - - const handleDelete = async () => { - try { - setIsDeleting(true); - await positionTypeService.delete(row.id); - toast.success(t("common.DeletedSuccessfully")); - setShowDeleteDialog(false); - if (onDelete) { - await onDelete(); - } - } catch (error) { - handleError(error); - toast.error(t("common.FailedToDelete")); - } finally { - setIsDeleting(false); - } - }; - - const handleToggleChange = async (checked: boolean) => { - if (isGlobal) return; - try { - if (configItem?.id) { - await updateConfiguration({ - id: configItem.id, - payload: { - organizationId: configItem.organizationId, - positionTypeId: configItem.positionTypeId, - timeframe: configItem.timeframe, - canReceiveRecord: checked, - }, - }); - } else { - await onToggle?.(row.id, checked, "canReceiveRecord"); - } - toast.success(t("incomingRecord.UpdatedSuccessfully")); - invalidateConfig(); - } catch (error) { - handleError(error); - toast.error(t("incomingRecord.FailedToUpdate")); - } - }; - - const handleAssignToggleChange = async (checked: boolean) => { - if (isGlobal) return; - try { - if (configItem?.id) { - await updateConfiguration({ - id: configItem.id, - payload: { - organizationId: configItem.organizationId, - positionTypeId: configItem.positionTypeId, - timeframe: configItem.timeframe, - canAssignRecord: checked, - }, - }); - } else { - await onToggle?.(row.id, checked, "canAssignRecord"); - } - toast.success(t("incomingRecord.UpdatedSuccessfully")); - invalidateConfig(); - } catch (error) { - handleError(error); - toast.error(t("incomingRecord.FailedToUpdate")); - } - }; - - const handleCreateBankRecordToggleChange = async (checked: boolean) => { - if (isGlobal) return; - try { - if (configItem?.id) { - await updateConfiguration({ - id: configItem.id, - payload: { - organizationId: configItem.organizationId, - positionTypeId: configItem.positionTypeId, - timeframe: configItem.timeframe, - canCreateBankRecord: checked, - }, - }); - } else { - await onToggle?.(row.id, checked, "canCreateBankRecord"); - } - toast.success(t("incomingRecord.UpdatedSuccessfully")); - invalidateConfig(); - } catch (error) { - handleError(error); - toast.error(t("incomingRecord.FailedToUpdate")); - } - }; - const rowName = "name" in row ? row.name : { am: "", en: "" }; - const isPositionType = "name" in row && "key" in row; - - return ( - <> - - - - - - { - const target = e.target as HTMLElement; - if (!target.closest('[role="dialog"]')) { - setDropdownOpen(false); - } - }}> - Actions - - {canBeModified && ( - - - {t("common.Migrate")} - - )} - - {canBeModified && isPositionType && ( - - - {t("common.Edit")} - - )} - - - - {t("common.View")} - - - {canBeModified && ( - { - setDropdownOpen(false); - setShowDeleteDialog(true); - }} - className="cursor-pointer hover:!text-red-500 !bg-transparent !transition-colors duration-200"> - - {t("common.Delete")} - - )} - - {/* Toggle moved here from ToggleCell */} - {!isGlobal && ( -
-
- - {t("contentManagement.CanReceiveRecord")} - - -
-
- )} - {!isGlobal && ( -
-
- - {t("contentManagement.CanAssignRecord")} - - -
-
- )} - {!isGlobal && ( -
-
- - {t("contentManagement.CanCreateBankRecord")} - - -
-
- )} -
-
- - {showMigrateDialog && ( - { - setShowMigrateDialog(false); - }} - toId={row.id} - toName={localizedName(rowName)} - positionTypeOptions={positionTypeOptions} - /> - )} - - - - - {t("common.Edit")} - -
- {isPositionType && showEditDialog && ( - { - setShowEditDialog(false); - if (onEdit) { - await onEdit(); - } - }} - onCancel={() => setShowEditDialog(false)} - /> - )} -
-
-
- - - - - {t("common.ConfirmDelete")} - - {t("common.DeleteConfirmationMessage", { - defaultValue: `Are you sure you want to delete "${localizedName(rowName)}"? This action cannot be undone.`, - })} - - - - {t("common.Cancel")} - - {isDeleting ? t("common.Deleting") : t("common.Delete")} - - - - - - ); -}; - -export default PositionTypeActionsCell; +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { PositionTypeDto } from "@/user-management/dto/positions/positionType"; + +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuItem, +} from "@/shared/common/ui/dropdown-menu"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/common/ui/alert-dialog"; + +import { Button } from "@/shared/common/ui/button"; +import { MoreVertical, Edit, Eye, Trash2, Pencil } from "lucide-react"; +import { t } from "i18next"; +import PositionTypeMigrationModal from "./PostionTypeMigration"; +import { CreatePositionForm } from "./CreatePositionForm"; +import { useLocalizedName } from "@/shared/common/localizedName"; +import { usePositionTypes } from "@/user-management/hooks/usePositionTypes"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/shared/common/ui/dialog"; + +interface PositionTypeResponse { + items: PositionTypeDto[]; + count: number; +} + +type ActionsCellProps = { + row: PositionTypeDto; + globalPositionTypes?: PositionTypeResponse; + onDelete?: () => void | Promise; + onEdit?: () => void | Promise; +}; + +/* + * TODO(record-toggles): this menu used to carry CanReceiveRecord / + * CanAssignRecord / CanCreateBankRecord switches. They never worked. IAM's + * PositionTypeConfiguration entity only has { id, organizationId, + * positionTypeId, timeframe } — verified against every local build (0.7.4 + * through 0.7.12) and the live swagger. canAssignRecord and + * canCreateBankRecord do not exist anywhere in the IAM package, and the global + * ValidationPipe runs with forbidNonWhitelisted, so every write 400'd. The + * reads were broken too: the list route filters on organizationId (the repo is + * built as TExtraCrudRepository(repo, "organizationId")) while the UI passed a + * positionTypeId, so it always came back empty. + * + * The flag that does exist is PositionConfiguration.canReceiveRecord, keyed by + * positionId — a per-position setting served by /api/position-configurations, + * not a per-position-type one. Restoring this needs either that endpoint and a + * position-level UI, or new columns on PositionTypeConfiguration in IAM. + */ +const PositionTypeActionsCell: React.FC = ({ + row, + globalPositionTypes, + onDelete, + onEdit, +}) => { + const navigate = useNavigate(); + const [dropdownOpen, setDropdownOpen] = useState(false); + const [showMigrateDialog, setShowMigrateDialog] = useState(false); + const [showEditDialog, setShowEditDialog] = useState(false); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const localizedName = useLocalizedName(); + const { deletePositionType } = usePositionTypes(); + + // Create position type options from globalPositionTypes - only those WITHOUT unitId + const positionTypeOptions = + globalPositionTypes?.items + .filter((item) => !item.unitId) + .map((item) => ({ + label: localizedName(item.name), + value: item.id, + })) || []; + + // Only show migrate/delete actions if current row has a unitId + const canBeModified = !!row.unitId; + + const handleView = () => { + navigate(`/user-management/position-management/edit/${row.id}`); + }; + + const handleMigrate = (e: Event) => { + e.preventDefault(); + setDropdownOpen(false); + setShowMigrateDialog(true); + }; + + const handleEdit = (e: Event) => { + e.preventDefault(); + setDropdownOpen(false); + setShowEditDialog(true); + }; + + // Goes through the mutation rather than the service directly, so the cache is + // invalidated and IAM's 403 for built-in types reaches the user. + const handleDelete = async () => { + try { + await deletePositionType.mutateAsync(row.id); + setShowDeleteDialog(false); + await onDelete?.(); + } catch { + // reported by the mutation's onError + } + }; + + return ( + <> + + + + + + { + const target = e.target as HTMLElement; + if (!target.closest('[role="dialog"]')) { + setDropdownOpen(false); + } + }}> + {t("userRecord.Actions")} + + {canBeModified && ( + + + {t("common.Migrate")} + + )} + + {canBeModified && ( + + + {t("common.Edit")} + + )} + + + + {t("common.View")} + + + {canBeModified && ( + { + setDropdownOpen(false); + setShowDeleteDialog(true); + }} + className="cursor-pointer hover:!text-red-500 !bg-transparent !transition-colors duration-200"> + + {t("common.Delete")} + + )} + + + + {showMigrateDialog && ( + { + setShowMigrateDialog(false); + }} + toId={row.id} + toName={localizedName(row.name)} + positionTypeOptions={positionTypeOptions} + /> + )} + + + + + {t("common.Edit")} + +
+ {showEditDialog && ( + { + setShowEditDialog(false); + if (onEdit) { + await onEdit(); + } + }} + onCancel={() => setShowEditDialog(false)} + /> + )} +
+
+
+ + + + + {t("common.ConfirmDelete")} + + {t("common.DeleteConfirmationMessage", { + name: localizedName(row.name), + })} + + + + + {t("common.Cancel")} + + + {deletePositionType.isPending + ? t("common.Deleting") + : t("common.Delete")} + + + + + + ); +}; + +export default PositionTypeActionsCell; diff --git a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionTypeColumnDefn.tsx b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionTypeColumnDefn.tsx index d5fcb509e..bcd73ae3f 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionTypeColumnDefn.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionTypeColumnDefn.tsx @@ -16,16 +16,9 @@ const NameCell = ({ name }: { name: PositionTypeDto["name"] }) => { }; export const createPositionTypeColumns = ( - _positionTypeResponse?: PositionTypeResponse, globalPositionTypes?: PositionTypeResponse, onDelete?: () => void | Promise, onEdit?: () => void | Promise, - onToggle?: ( - positionTypeId: string, - checked: boolean, - field: "canReceiveRecord" | "canAssignRecord" | "canCreateBankRecord", - ) => void | Promise, - isGlobal?: boolean, ): ColumnDef[] => [ { accessorKey: "name", @@ -64,8 +57,6 @@ export const createPositionTypeColumns = ( globalPositionTypes={globalPositionTypes} onDelete={onDelete} onEdit={onEdit} - onToggle={onToggle} - isGlobal={isGlobal} /> ), }, diff --git a/apps/edr-freight-web/backoffice/src/user-management/dto/positions/positionType.ts b/apps/edr-freight-web/backoffice/src/user-management/dto/positions/positionType.ts index 3f76be9f7..c2e79844f 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/dto/positions/positionType.ts +++ b/apps/edr-freight-web/backoffice/src/user-management/dto/positions/positionType.ts @@ -5,10 +5,14 @@ export interface PositionTypeDto { en: string; }; key: string; - unitId: string; - canReceiveRecord: boolean; - canCreateBankRecord?: boolean; - canAssignRecord: boolean; + /** + * Null for the built-in ("common") types, which `isSystem` marks and which + * every unit can use. IAM has no organizationId on a position type — the + * owning organization is only reachable via unit -> organizationId. + */ + unitId: string | null; + /** Built-in type. IAM rejects update/delete on these with a 403. */ + isSystem?: boolean; createdAt: string; updatedAt: string; } diff --git a/apps/edr-freight-web/backoffice/src/user-management/hooks/usePositionTypes.ts b/apps/edr-freight-web/backoffice/src/user-management/hooks/usePositionTypes.ts index a62ef26d6..8f102532d 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/hooks/usePositionTypes.ts +++ b/apps/edr-freight-web/backoffice/src/user-management/hooks/usePositionTypes.ts @@ -1,4 +1,9 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + QueryClient, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; import { CreatePositionTypePayload, PositionRequest, @@ -23,10 +28,22 @@ interface positionParams { interface UsePositionTypeManagerProps { id?: string; unitId?: string; - organizationId?: string; params?: positionParams; // 👈 we expected query params to be passed like this } +/** + * Every cache key this hook writes under. React Query matches key prefixes + * element by element, so `["position-type"]` does NOT reach + * `["position-types-common", ...]` — each root has to be listed. Anything that + * mutates a position type should call this rather than hand-picking keys, or + * the department pickers (which read the "-common" queries) go stale. + */ +export const invalidatePositionTypeQueries = (queryClient: QueryClient) => { + ["position-types", "position-type", "position-types-common"].forEach( + (root) => queryClient.invalidateQueries({ queryKey: [root] }), + ); +}; + export const usePositionTypes = ({ id, params = { @@ -35,11 +52,11 @@ export const usePositionTypes = ({ orderBy: "createdAt:Desc", }, unitId, - organizationId, }: UsePositionTypeManagerProps = {}) => { const queryClient = useQueryClient(); const { t } = useTranslation(); const { handleError } = useErrorHandler(t); + const invalidateAll = () => invalidatePositionTypeQueries(queryClient); const { data, isLoading, isError, refetch } = useQuery({ queryKey: ["position-types", params], queryFn: () => positionTypeService.getAll(params).then((res) => res.data), @@ -70,38 +87,6 @@ export const usePositionTypes = ({ enabled: !!unitId, }); - // Position types by organization ID - const { - data: positionTypeByOrgId, - isLoading: isLoadingOrgPosition, - isError: isErrorOrgPosition, - refetch: refetchOrgPosition, - } = useQuery({ - queryKey: ["position-type-org", organizationId, params], - queryFn: async () => { - if (!organizationId) return undefined; - const res = await positionTypeService.getByOrganizationId(organizationId, params); - return res.data as PositionTypesListResponse | undefined; - }, - enabled: !!organizationId, - }); - - // Common types with organization ID (includes both org-specific and common types) - const { - data: commonPositionTypesByOrgId, - isLoading: isLoadingCommonOrgTypes, - isError: isErrorCommonOrgTypes, - refetch: refetchCommonOrgTypes, - } = useQuery({ - queryKey: ["position-types-common-org", organizationId, params], - queryFn: async () => { - if (!organizationId) return undefined; - const res = await positionTypeService.getCommonTypesByOrganizationId(organizationId, params); - return res.data as PositionTypesListResponse | undefined; - }, - enabled: !!organizationId, - }); - // Common types with unit ID (includes both unit-specific and common types) const { data: commonPositionTypes, @@ -123,15 +108,16 @@ export const usePositionTypes = ({ mutationFn: (payload: CreatePositionTypePayload) => positionTypeService.create(payload), onSuccess: () => { - toast.success("Position type created"); - queryClient.invalidateQueries({ queryKey: ["position-types"] }); + toast.success(t("contentManagement.positionTypeCreated")); + invalidateAll(); }, onError: (error) => { handleError(error); }, }); - // Update + // Update. IAM answers 403 `position_type_not_allowed_to_update` for built-in + // (isSystem) types, so the error has to reach the user. const updatePositionType = useMutation({ mutationFn: ({ id, @@ -141,11 +127,12 @@ export const usePositionTypes = ({ data: UpdatePositionTypePayload; }) => positionTypeService.update(id, data), onSuccess: () => { - toast.success("Position type updated"); - queryClient.invalidateQueries({ queryKey: ["position-types"] }); - queryClient.invalidateQueries({ queryKey: ["position-type", id] }); + toast.success(t("contentManagement.positionTypeUpdated")); + invalidateAll(); + }, + onError: (error) => { + handleError(error); }, - onError: () => {}, }); //update positon from to @@ -153,30 +140,32 @@ export const usePositionTypes = ({ mutationFn: ({ toId, fromId }: { toId: string; fromId: string }) => positionTypeService.updateFromto(toId, fromId), onSuccess: () => { - toast.success("Position type migration updated"); - queryClient.invalidateQueries({ queryKey: ["position-types-to"] }); - queryClient.invalidateQueries({ queryKey: ["position-type", id] }); + toast.success(t("contentManagement.positionTypeMigrated")); + invalidateAll(); + }, + onError: (error) => { + handleError(error); }, - onError: () => {}, }); //update all postions const migratePositionsByPositions = useMutation({ mutationFn: ({ id, data }: { id: string; data: PositionRequest }) => positionTypeService.updateByPostion(id, data), onSuccess: () => { - toast.success("Position type migration updated"); - queryClient.invalidateQueries({ queryKey: ["position-types-migration"] }); - queryClient.invalidateQueries({ queryKey: ["position-type", id] }); + toast.success(t("contentManagement.positionTypeMigrated")); + invalidateAll(); + }, + onError: (error) => { + handleError(error); }, - onError: () => {}, }); - // Delete + // Delete. Also 403s for built-in types. const deletePositionType = useMutation({ mutationFn: (id: string) => positionTypeService.delete(id), onSuccess: () => { - toast.success("Position type deleted"); - queryClient.invalidateQueries({ queryKey: ["position-types"] }); + toast.success(t("contentManagement.positionTypeDeleted")); + invalidateAll(); }, onError: (error) => { handleError(error); @@ -205,16 +194,6 @@ export const usePositionTypes = ({ refetchPosition, isErrorPosition, isLoadingPosition, - // organization-based position types - positionTypeByOrgId, - refetchOrgPosition, - isErrorOrgPosition, - isLoadingOrgPosition, - // common types with organization ID - commonPositionTypesByOrgId, - refetchCommonOrgTypes, - isErrorCommonOrgTypes, - isLoadingCommonOrgTypes, // common types with unit ID commonPositionTypes: commonPositionTypes?.items ?? [], isLoadingCommonTypes, diff --git a/apps/edr-freight-web/backoffice/src/user-management/services/api/positionTypesService.ts b/apps/edr-freight-web/backoffice/src/user-management/services/api/positionTypesService.ts index b3ac43ecd..450009fa7 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/services/api/positionTypesService.ts +++ b/apps/edr-freight-web/backoffice/src/user-management/services/api/positionTypesService.ts @@ -40,38 +40,25 @@ export const positionTypeService = { getById: (id: string): Promise> => axiosInstance.get(`/position-types/${id}`, { headers: withHeaders() }), + // Types owned by one unit. IAM has no organization-scoped route — position + // types carry a unitId only, so scoping to an org means filtering by that + // org's units client-side. getByUnitId: ( - id: string, + unitId: string, params?: Params, ): Promise> => - axiosInstance.get(`/position-types/list/${id}`, { - headers: withHeaders(), - params, - }), - - getByOrganizationId: ( - id: string, - params?: Params, - ): Promise> => - axiosInstance.get(`/position-types/list/${id}`, { - headers: withHeaders(), - params, - }), - - getCommonTypesByOrganizationId: ( - id: string, - params?: Params, - ): Promise> => - axiosInstance.get(`/position-types/list-with-commons/${id}`, { + axiosInstance.get(`/position-types/list/${unitId}`, { headers: withHeaders(), params, }), + // WHERE isSystem = true OR unitId = :unitId — "commons" means the built-in + // types, not the ones with a null unitId. getCommonTypesById: ( - id: string, + unitId: string, params: Params, ): Promise> => - axiosInstance.get(`/position-types/list-with-commons/${id}`, { + axiosInstance.get(`/position-types/list-with-commons/${unitId}`, { headers: withHeaders(), params, }), From dcc94643d0c8ee7a2ba72379c8568f2ad617ddad Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 23 Jul 2026 13:48:42 +0000 Subject: [PATCH 09/16] feat(companies): enforce customers:* permissions on customer endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The customers:* keys were seeded and present in the backoffice constants but enforced nowhere except reset-password. Customer CRUD sat behind the coarse edr_freight_app:admin umbrella, and every company read endpoint was unguarded. Two routes could not be gated on the route alone, because the authority they need depends on the request BODY, not the path: - PATCH /companies/:id carries `status` (UpdateCompanyDto extends PartialType(CreateCompanyDto)), so it both edits fields and blacklists. - PATCH /company-profiles/:profileId/status is approve, reject, suspend and blacklist on one route. Both now take a one-of route guard and assert per-status against a shared STATUS_PERM map: approving/reactivating needs customers:verify, suspending or blacklisting needs customers:deactivate. PATCH /companies/:id additionally requires customers:update when any non-status field is present, so a caller holding only deactivate cannot rename a company. The backoffice mirrors the same map so no button is offered that the server would reject. GET /companies/:companyId/documents is left authenticated-only with the split in the handler: it is dual-audience. The portal reads its own documents during onboarding, and the contract-request detail page (gated on contracts:view) reads the applicant's. Gating it on customers:view alone would have 403'd customers on their own documents and blanked the contract reviewer's panel. The two by-company customer-view reads take a one-of guard for the same reason — otherwise a staffer granted only customers:view gets a detail page whose tabs 403 individually. Frontend: the customers routes were sidebar-filtered but not wrapped in RequirePermission, so direct URL navigation rendered them for anyone. Verified: freight-api type-check clean; backoffice type-check unchanged from HEAD (pre-existing errors only); 25 tests pass across the companies and freight-permission suites. Not exercised against a running API. Co-Authored-By: Claude Opus 4.8 --- .../modules/bookings/bookings.controller.ts | 5 +- .../modules/companies/companies.controller.ts | 109 +++++++++++++++-- .../src/modules/payment/payment.controller.ts | 5 +- apps/edr-freight-web/backoffice/src/App.tsx | 18 ++- .../customers/ChangeRequestReview.tsx | 47 +++++--- .../src/components/customers/badges.tsx | 110 +++++++++++------- .../pages/customers/CustomerDetailPage.tsx | 12 +- 7 files changed, 231 insertions(+), 75 deletions(-) diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index c93c61f13..c292a0395 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -252,8 +252,11 @@ export class BookingsController { return this.bookingsService.findAll(filter, companyId); } + // Powers the customer-detail bookings tab, so `customers:view` reaches it too + // — otherwise a staffer granted only the customer permission gets a page whose + // tabs 403 individually. @Get("by-company/:companyId/customer-view") - @BookingView() + @BookingStaff([FREIGHT_PERMS.customers.view, FREIGHT_PERMS.bookings.view]) @ApiOperation({ summary: "List bookings for a company (customer-view shape, backoffice)", }) diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 43d70ce19..8e3be4d1a 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -11,13 +11,22 @@ import { HttpCode, HttpStatus, UseInterceptors, + UseGuards, UploadedFiles, BadRequestException, + NotFoundException, } from "@nestjs/common"; import { AnyFilesInterceptor } from "@nestjs/platform-express"; import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger"; import { CurrentUser } from "@edr/api-common"; -import { FreightAdmin } from "../../common/booking-guards"; +import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; +import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard"; +import { BookingStaff } from "../../common/booking-guards"; +import { + assertFreightPermission, + hasFreightPermission, +} from "../../common/freight-permission.util"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { FilesService } from "../files/files.service"; import { CompaniesService } from "./companies.service"; import { CreateCompanyDto } from "./dto/create-company.dto"; @@ -59,6 +68,23 @@ interface CurrentIamUser { phoneNumber?: string; } +/** + * Which permission a status write needs. Approving/reactivating is a different + * authority from suspending, but both arrive on the same route with the target + * in the BODY — a route-level guard can't tell them apart, so the handlers + * assert against this map instead. + * + * Keyed by string so it serves both `CompanyStatus` and `ProfileStatus` + * (a superset: it adds `rejected`). + */ +const STATUS_PERM: Record = { + active: FREIGHT_PERMS.customers.verify, + pending: FREIGHT_PERMS.customers.verify, + rejected: FREIGHT_PERMS.customers.verify, + suspended: FREIGHT_PERMS.customers.deactivate, + blacklisted: FREIGHT_PERMS.customers.deactivate, +}; + @ApiTags("Companies") @Controller("companies") export class CompaniesController { @@ -410,7 +436,7 @@ export class CompaniesController { // Used by backoffice @Post() - @FreightAdmin() + @BookingStaff(FREIGHT_PERMS.customers.create) @ApiOperation({ summary: "Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter)", @@ -421,12 +447,14 @@ export class CompaniesController { } @Get("stats") + @BookingStaff(FREIGHT_PERMS.customers.view) @ApiOperation({ summary: "Company counts by status (KPI strip)" }) async getStats(): Promise { return this.companiesService.getCompanyStats(); } @Get() + @BookingStaff(FREIGHT_PERMS.customers.view) @ApiOperation({ summary: "List companies (paginated, filterable)" }) async findAll( @Query() query: ListCompaniesQueryDto, @@ -436,6 +464,7 @@ export class CompaniesController { } @Get(":id") + @BookingStaff(FREIGHT_PERMS.customers.view) @ApiOperation({ summary: "Get company by ID" }) async findById( @Param("id", ParseUUIDPipe) id: string, @@ -446,30 +475,77 @@ export class CompaniesController { return dto; } + /** + * Edits fields AND carries `status`, so it spans two authorities. The route + * guard is one-of (a status-only caller must get in); the asserts below are + * what actually authorize: touching `status` needs the permission + * {@link STATUS_PERM} maps it to, touching anything else needs + * `customers:update`. Both checks are required — without the second, a + * caller holding only `customers:deactivate` could rename the company. + */ @Patch(":id") - @FreightAdmin() + @BookingStaff([ + FREIGHT_PERMS.customers.update, + FREIGHT_PERMS.customers.verify, + FREIGHT_PERMS.customers.deactivate, + ]) @ApiOperation({ summary: "Update a company" }) async update( @Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdateCompanyDto, + @CurrentUser() user: TCurrentUser, ): Promise { + const { status, ...fields } = dto; + if (status) assertFreightPermission(user, STATUS_PERM[status]); + if (Object.keys(fields).length > 0) { + assertFreightPermission(user, FREIGHT_PERMS.customers.update); + } const company = await this.companiesService.updateCompany(id, dto); return new ResponseCompanyDto(company); } @Delete(":id") - @FreightAdmin() + @BookingStaff(FREIGHT_PERMS.customers.deactivate) @ApiOperation({ summary: "Soft-delete a company" }) @HttpCode(HttpStatus.NO_CONTENT) async remove(@Param("id", ParseUUIDPipe) id: string): Promise { await this.companiesService.deleteCompany(id); } + /** + * Dual-audience: staff read any customer's documents, and the portal reads + * its OWN during onboarding (`companiesService.getDocuments`). So the route + * is authenticated-only and the split happens here — same shape as + * `GET /contracts/:id`. Gating it on a staff permission alone would 403 every + * customer on their own documents. + * + * The staff arm is one-of because two pages consume it: the customer detail + * page (`customers:view`) and the contract-request detail page, whose route + * is gated on `contracts:view` — a contract reviewer without the customer + * permission still needs the applicant's documents. + */ @Get(":companyId/documents") + @UseGuards(JwtGuard) @ApiOperation({ summary: "List documents uploaded for a company" }) async listDocuments( @Param("companyId", ParseUUIDPipe) companyId: string, + @CurrentUser() user: TCurrentUser, ) { + const isStaff = [ + FREIGHT_PERMS.customers.view, + FREIGHT_PERMS.contracts.view, + FREIGHT_PERMS.bookings.view, + ].some((p) => hasFreightPermission(user, p)); + + if (!isStaff) { + const { company } = await this.companiesService.getCompanyInfoByUserId( + user.id, + ); + // Hidden as NotFound rather than Forbidden so company ids can't be probed. + if (company.id !== companyId) { + throw new NotFoundException(`Company ${companyId} not found`); + } + } const files = await this.filesService.findByResource(companyId, "companies"); return Promise.all( files.map(async (f) => ({ @@ -490,7 +566,7 @@ export class CompaniesController { } @Post("documents/:fileId/request-change") - @FreightAdmin() + @BookingStaff(FREIGHT_PERMS.customers.verify) @ApiOperation({ summary: "Ask the customer to correct one uploaded document", description: @@ -532,14 +608,23 @@ export class CompaniesController { return this.companiesService.uploadCompanyDocuments(companyId, files, user.id); } + /** + * Approve / reject / suspend / blacklist all arrive here with the target in + * the body, so authorization is per-status via {@link STATUS_PERM} rather + * than on the route (the guard is only the one-of gate). + */ @Patch("company-profiles/:profileId/status") - @FreightAdmin() + @BookingStaff([ + FREIGHT_PERMS.customers.verify, + FREIGHT_PERMS.customers.deactivate, + ]) @ApiOperation({ summary: "Update a company profile's approval status" }) async updateCompanyProfileStatus( - @CurrentUser() user: CurrentIamUser, + @CurrentUser() user: TCurrentUser, @Param("profileId", ParseUUIDPipe) profileId: string, @Body() dto: UpdateCompanyProfileStatusDto, ): Promise { + assertFreightPermission(user, STATUS_PERM[dto.status]); const profile = await this.companiesService.setCompanyProfileStatus( profileId, dto.status, @@ -550,7 +635,7 @@ export class CompaniesController { } @Get(":companyId/change-requests") - @FreightAdmin() + @BookingStaff(FREIGHT_PERMS.customers.view) @ApiOperation({ summary: "List a company's profile change requests" }) async listChangeRequests( @Param("companyId", ParseUUIDPipe) companyId: string, @@ -560,7 +645,7 @@ export class CompaniesController { } @Post("change-requests/:id/approve") - @FreightAdmin() + @BookingStaff(FREIGHT_PERMS.customers.verify) @ApiOperation({ summary: "Approve a pending profile change request (applies the changes)", }) @@ -576,7 +661,7 @@ export class CompaniesController { } @Post("change-requests/:id/reject") - @FreightAdmin() + @BookingStaff(FREIGHT_PERMS.customers.verify) @ApiOperation({ summary: "Reject a pending profile change request with a note", }) @@ -594,7 +679,7 @@ export class CompaniesController { } @Post(":companyId/profiles") - @FreightAdmin() + @BookingStaff(FREIGHT_PERMS.customers.update) @ApiOperation({ summary: "Add a profile (employee) to a company" }) async createProfile( @Param("companyId", ParseUUIDPipe) companyId: string, @@ -608,6 +693,7 @@ export class CompaniesController { } @Get(":companyId/profiles") + @BookingStaff(FREIGHT_PERMS.customers.view) @ApiOperation({ summary: "List profiles for a company" }) async listProfiles( @Param("companyId", ParseUUIDPipe) companyId: string, @@ -618,6 +704,7 @@ export class CompaniesController { } @Get("profile/user/:userId") + @BookingStaff(FREIGHT_PERMS.customers.view) @ApiOperation({ summary: "Get profile by IAM user ID" }) async findProfileByUser( @Param("userId", ParseUUIDPipe) userId: string, diff --git a/apps/edr-freight-api/src/modules/payment/payment.controller.ts b/apps/edr-freight-api/src/modules/payment/payment.controller.ts index 50856c3d7..2ae62d4f3 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.controller.ts @@ -16,7 +16,8 @@ import { } from "@nestjs/swagger"; import { Response } from "express"; import { Public } from "@edr/api-common"; -import { BookingView } from "../../common/booking-guards"; +import { BookingStaff, BookingView } from "../../common/booking-guards"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { PaymentService } from "./payment.service"; import { IntentStatusDto } from "./payments.dto"; @@ -25,7 +26,9 @@ import { IntentStatusDto } from "./payments.dto"; export class PaymentController { constructor(private readonly paymentService: PaymentService) { } + // Customer-detail payments tab — same one-of rule as the bookings tab. @Get("by-company/:companyId/customer-view") + @BookingStaff([FREIGHT_PERMS.customers.view, FREIGHT_PERMS.payments.view]) @ApiOperation({ summary: "List payments for a company (customer-view shape, backoffice)" }) findByCompanyCustomerView( @Param("companyId", ParseUUIDPipe) companyId: string, diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 04a26e3fe..bb4d024c8 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -802,8 +802,22 @@ const App = () => { } /> } /> - } /> - } /> + + + + } + /> + + + + } + /> )} - - - - + {/* Reviewing the diff is `customers:view`; deciding on it is + `customers:verify`. Without it the request stays readable but + un-actionable. */} + {canReview && ( + + + + + )} )} diff --git a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx index 04267cc7b..112026ca6 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx @@ -11,6 +11,8 @@ import { } from "@mantine/core"; import { useMutation } from "@tanstack/react-query"; import { useState } from "react"; +import { useAuth } from "@/auth/useAuth"; +import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { api } from "@/services/api"; import type { @@ -286,6 +288,19 @@ export function InvoiceStatusBadge({ * regardless (setCompanyProfileStatus). Suspend/blacklist/reinstate stay live so * an already-active profile is still managable. */ +/** + * Which permission each status write needs. Mirrors `STATUS_PERM` in the API's + * `companies.controller.ts` — approving is a different authority from + * suspending, and both go through the same endpoint. Keep the two in step. + */ +const STATUS_PERM: Record = { + active: FREIGHT_PERMS.customers.verify, + pending: FREIGHT_PERMS.customers.verify, + rejected: FREIGHT_PERMS.customers.verify, + suspended: FREIGHT_PERMS.customers.deactivate, + blacklisted: FREIGHT_PERMS.customers.deactivate, +}; + export function ProfileApprovalActions({ profileId, status, @@ -295,6 +310,10 @@ export function ProfileApprovalActions({ status: ProfileStatus; locked?: boolean; }) { + const { user } = useAuth(); + /** The API rejects these anyway — hide rather than offer a button that 403s. */ + const canSet = (next: ProfileStatus) => + hasPermission(user, STATUS_PERM[next]); const { mutate, isPending } = useMutation( api.customers.setProfileStatus.mutationOptions(), ); @@ -414,35 +433,41 @@ export function ProfileApprovalActions({ } if (status === "pending") { + if (!canSet("active") && !canSet("rejected")) return null; return ( <> {decisionModal} - - + {canSet("active") && ( + + )} + {canSet("rejected") && ( + + )} ); } if (status === "rejected") { + if (!canSet("active")) return null; return ( - + {canSet("active") && ( + + )} + {canSet("blacklisted") && ( + + )} ); } if (status === "blacklisted") { + if (!canSet("pending")) return null; return ( - - - - ); -} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx index f6c3098a0..aff1526a6 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx @@ -72,7 +72,6 @@ import { ContractClearancePanel } from "./ContractClearancePanel"; import { ContractClearanceWorkflowBanner } from "./ContractClearanceWorkflowBanner"; import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel"; import { InitiateBookingButton } from "@/components/customer-actions/ContractCustomerAction"; -import { PayClearanceFeeButton } from "@/pages/bookings/payments/PayClearanceFeeButton"; import { formatRateUnit } from "./new-contract-form/unit-rates"; import { getContractBookingAction } from "./contract-booking-action"; import { closedWindowMessage, hasOpenWindow } from "./booking-window"; @@ -438,9 +437,6 @@ export default function ContractDetailPage() { // clearance is finalized. const canUploadClearance = CLEARANCE_UPLOAD_STATUSES.includes(contract.status) && !clearanceFinalized; - // Prepaid clearance service fee gate (Path B) — the document step stays - // locked until the fee invoice settles. - const awaitingClearanceFee = contract.status === "AWAITING_CLEARANCE_PAYMENT"; return ( @@ -574,13 +570,6 @@ export default function ContractDetailPage() { Global Logistics is creating your booking )} - {awaitingClearanceFee && ( - - )} {canUploadClearance && ( + + + + {/* Step 0 — Setup: operation, contract, service, currency, miles. */} {step === 0 && ( diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/total.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/total.ts index 841d7ddf1..b4055bb1c 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/total.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/total.ts @@ -111,7 +111,10 @@ export function computeShipmentTotal( const qty = Number(values.cargoWeightTons || values.itemCount || 0); const rate = rateFor( - (i) => (i.unit === "per_ton" || i.unit === "per_item") && !i.isClearance, + (i) => + (i.unit === "per_ton" || i.unit === "per_item") && + !i.isClearance && + !i.conditionalOn, ) ?? items[0]; if (rate && qty > 0) { lines.push({ @@ -150,6 +153,23 @@ export function computeShipmentTotal( } } + // Lashing / cargo securing — bulk-only, applies whenever the contract shows + // it (the commodity needs lashing). Per-ton scales by tonnage; per-wagon + // depends on the wagon capacity the train stocks — shown at real pricing. + const lashing = items.find((i) => i.conditionalOn === "has_lashing"); + if (lashing && lashing.unit === "per_ton") { + const tons = Number(values.cargoWeightTons || 0); + if (tons > 0) { + lines.push({ + label: lashing.label, + unitPrice: lashing.unitPrice, + unit: lashing.unit, + quantity: tons, + amount: lashing.unitPrice * tons, + }); + } + } + // Customs clearance service fee — billed on the booking invoice with the // freight. Container fees estimate per size (per box, or per wagon: two 20ft // share one); bulk per-ton scales by tonnage. Bulk per-wagon fees depend on From 40f16f3cec1d88085954a7a3651616e78974d94a Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 23 Jul 2026 14:30:49 +0000 Subject: [PATCH 12/16] changes --- .../portal/src/pages/contracts/ContractDetailPage.tsx | 4 ++-- .../portal/src/pages/contracts/NewContractPage.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx index aff1526a6..fe9209c1d 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx @@ -924,8 +924,8 @@ export default function ContractDetailPage() { )} {item.isClearance && ( - Paid in advance, before clearance — excluded from - shipment invoices + Customs service fee — billed on your shipment booking + invoice together with the freight )} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx index 81e3a1806..27a1c242f 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx @@ -993,8 +993,8 @@ export default function NewContractPage({ )} {item.isClearance && ( - Paid in advance, before clearance — not part of your - shipment booking invoice + Customs service fee — billed on your shipment booking + invoice together with the freight )} From 84346d77ac038c7181f71c9af492623cd2e48a35 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Thu, 23 Jul 2026 19:39:39 +0300 Subject: [PATCH 13/16] Generate missing ticket and payment report currency updates --- .../src/modules/reports/reports.service.ts | 56 +++++++++---------- .../src/modules/tickets/tickets.controller.ts | 7 ++- .../src/modules/tickets/tickets.service.ts | 32 +++++++---- .../backoffice/src/app/tickets/page.tsx | 17 +++++- .../backoffice/src/lib/api/index.ts | 2 +- 5 files changed, 69 insertions(+), 45 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/reports/reports.service.ts b/apps/edr-passenger-api/src/modules/reports/reports.service.ts index 7889c67a1..1aef00185 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -599,32 +599,31 @@ export class ReportsService { sortBy?: string; search?: string; }) { - // Load exchange rates once — we need DJF→ETB (and any other non-ETB currencies). - // Keep only the most-recent rate per pair (rates are ordered desc by effectiveDate). + // Load all exchange rates once — we need conversions in both directions. const rateRows = await this.prisma.currencyExchangeRate.findMany({ - where: { toCurrency: 'ETB' as any }, orderBy: { effectiveDate: 'desc' }, }); - const rateToEtb = new Map(); + // Most-recent rate for each fromCurrency→toCurrency pair + const rateMap = new Map(); for (const r of rateRows) { - if (!rateToEtb.has(r.fromCurrency)) { - rateToEtb.set(r.fromCurrency, Number(r.rate)); - } + const key = `${r.fromCurrency}→${r.toCurrency}`; + if (!rateMap.has(key)) rateMap.set(key, Number(r.rate)); } - // Convert any minor amount to its ETB equivalent using stored exchange rates. - // b.totalMinor is the booking's canonical ETB amount (always stored in ETB), - // so callers should pass that directly rather than converting displayTotalMinor. - const toEtbMinor = (minor: number, currency: string): number => { - if (currency === 'ETB') return minor; - const rate = rateToEtb.get(currency); - // If no rate is on file fall back to the raw value (avoids silently hiding - // cross-currency bookings, at the cost of an approximate comparison). - return rate ? Math.round(minor * rate) : minor; + // Convert minor amount from one currency to another. + const convertMinor = (minor: number, from: string, to: string): number => { + if (from === to) return minor; + const direct = rateMap.get(`${from}→${to}`); + if (direct) return Math.round(minor * direct); + // Try via ETB as pivot + const toEtb = rateMap.get(`${from}→ETB`); + const fromEtb = rateMap.get(`ETB→${to}`); + if (toEtb && fromEtb) return Math.round(minor * toEtb * fromEtb); + return minor; // fallback: no rate on file }; if (params.search?.trim()) { - return this.getDiscrepancyForRef(params.search.trim(), toEtbMinor); + return this.getDiscrepancyForRef(params.search.trim(), convertMinor); } const dateFilter: Record = {}; @@ -679,20 +678,18 @@ export class ReportsService { .map(b => { const pi = b.paymentIntent!; - // Display amounts shown to the passenger (may be in DJF). + // Display amounts shown to the passenger (may be in DJF/USD). const actualMinor = b.displayTotalMinor ?? b.totalMinor; const actualCurrency = (b.displayCurrency as string | null) ?? b.currency; const paidMinor = pi.amountMinor; const paidCurrency = pi.currency; - // b.totalMinor is always in ETB minor. pi.amountMinor is the charge MAJOR amount - // (the gateway receives major units — displayMinorToChargeMajor divides by 100 before - // sending). Multiply by 100 to convert back to minor before the ETB comparison. - const owedEtb = b.totalMinor; - const paidEtb = toEtbMinor(paidMinor * 100, paidCurrency); - const balanceMinor = owedEtb - paidEtb; - const balanceCurrency = 'ETB'; + // Balance in the booking's display currency: + // convert paid (major units from gateway) to display currency minor, then subtract. + const paidInDisplayMinor = convertMinor(paidMinor * 100, paidCurrency, actualCurrency); + const balanceMinor = actualMinor - paidInDisplayMinor; + const balanceCurrency = actualCurrency; const firstSeat = b.seats[0]; const passengers = this.buildSeatPassengers(b.seats, actualCurrency); @@ -731,7 +728,7 @@ export class ReportsService { private async getDiscrepancyForRef( search: string, - toEtbMinor: (minor: number, currency: string) => number, + convertMinor: (minor: number, from: string, to: string) => number, ) { let bookingId: string | null = null; const byPnr = await this.prisma.booking.findUnique({ @@ -797,10 +794,9 @@ export class ReportsService { const paidMinor = pi?.amountMinor ?? 0; const paidCurrency = pi?.currency ?? b.currency; - const owedEtb = b.totalMinor; - const paidEtb = toEtbMinor(paidMinor * 100, paidCurrency); - const balanceMinor = owedEtb - paidEtb; - const balanceCurrency = 'ETB'; + const paidInDisplayMinor = convertMinor(paidMinor * 100, paidCurrency, actualCurrency); + const balanceMinor = actualMinor - paidInDisplayMinor; + const balanceCurrency = actualCurrency; const firstSeat = b.seats[0]; const passengers = this.buildSeatPassengers(b.seats, actualCurrency); diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts index 4ecaeb267..869a8578a 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts @@ -14,10 +14,11 @@ export class TicketsController { @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Generate tickets for all confirmed bookings that are missing them', - description: 'Finds every CONFIRMED booking with no ticket rows and attempts to generate tickets for each. Returns a summary of processed/generated/failed counts.', + description: 'Finds every CONFIRMED booking with no ticket rows and attempts to generate tickets for each. Returns a summary of processed/generated/failed/remaining counts. Call repeatedly until remaining=0.', }) - generateMissing() { - return this.service.generateMissing(); + @ApiQuery({ name: 'limit', required: false, description: 'Max bookings to process per call (default 10)' }) + generateMissing(@Query('limit') limit?: string) { + return this.service.generateMissing(limit ? parseInt(limit, 10) : 10); } @Post('smart-assign/:bookingId') diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts index e6f3b0781..5f0965e7e 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -905,15 +905,21 @@ export class TicketsService { }; } - async generateMissing(): Promise<{ processed: number; generated: number; failed: number; details: any[] }> { - const confirmedWithNoTickets = await this.prisma.booking.findMany({ - where: { - status: 'CONFIRMED', - tickets: { none: {} }, - paymentIntent: { status: 'SUCCEEDED' }, - }, - select: { id: true, bookingRef: true }, - }); + async generateMissing(limit = 10): Promise<{ processed: number; generated: number; failed: number; remaining: number; details: any[] }> { + const missingWhere = { + status: 'CONFIRMED' as const, + tickets: { none: {} }, + paymentIntent: { status: 'SUCCEEDED' as const }, + }; + + const [confirmedWithNoTickets, totalRemaining] = await Promise.all([ + this.prisma.booking.findMany({ + where: missingWhere, + select: { id: true, bookingRef: true }, + take: limit, + }), + this.prisma.booking.count({ where: missingWhere }), + ]); const details: any[] = []; let generated = 0; @@ -930,7 +936,13 @@ export class TicketsService { } } - return { processed: confirmedWithNoTickets.length, generated, failed, details }; + return { + processed: confirmedWithNoTickets.length, + generated, + failed, + remaining: Math.max(0, totalRemaining - confirmedWithNoTickets.length), + details, + }; } async delete(id: string) { diff --git a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx index bd12def01..8731a1eb4 100644 --- a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx @@ -111,7 +111,22 @@ export default function TicketsPage() { }); const generateMissingMutation = useMutation({ - mutationFn: () => ticketsApi.generateMissing(), + mutationFn: async () => { + let totalGenerated = 0; + let totalFailed = 0; + let totalProcessed = 0; + let remaining = 1; + + while (remaining > 0) { + const result: any = await ticketsApi.generateMissing(10); + totalGenerated += result.generated ?? 0; + totalFailed += result.failed ?? 0; + totalProcessed += result.processed ?? 0; + remaining = result.remaining ?? 0; + } + + return { generated: totalGenerated, processed: totalProcessed, failed: totalFailed }; + }, onSuccess: (result: any) => { queryClient.invalidateQueries({ queryKey: ['tickets'] }); queryClient.invalidateQueries({ queryKey: ['bookings-missing-tickets'] }); diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts index 0d35ed2d3..24d5f1b7f 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts @@ -224,7 +224,7 @@ export const ticketsApi = { return Array.isArray(response) ? { items: response } : response; }, getById: (id: string) => apiClient.get(`/tickets/${id}`), - generateMissing: () => apiClient.post('/tickets/generate-missing', {}), + generateMissing: (limit = 10) => apiClient.post(`/tickets/generate-missing?limit=${limit}`, {}), validate: (ticketId: string, data: any) => apiClient.post(`/tickets/${ticketId}/validate`, data), scanAndBoard: (qrCodeOrRef: string, data: any) => apiClient.post(`/tickets/scan-board/${encodeURIComponent(qrCodeOrRef)}`, data), regenerate: (ticketId: string) => apiClient.post(`/tickets/${ticketId}/regenerate`), From 8bba882710a873e32bfb263fdd74f34585c40027 Mon Sep 17 00:00:00 2001 From: Muluhabt Date: Thu, 23 Jul 2026 20:03:35 +0300 Subject: [PATCH 14/16] Adding stop based booking configuration --- .../migration.sql | 9 + .../test/checkin-cutoff.e2e-spec.ts | 203 ++++++++++++++++++ .../backoffice/src/lib/timezone.ts | 21 ++ .../specs/backoffice/checkin-cutoff.spec.ts | 100 +++++++++ 4 files changed, 333 insertions(+) create mode 100644 apps/edr-passenger-api/prisma/migrations/20260723000001_add_route_stop_travel_minutes/migration.sql create mode 100644 apps/edr-passenger-api/test/checkin-cutoff.e2e-spec.ts create mode 100644 apps/edr-passenger-web/backoffice/src/lib/timezone.ts create mode 100644 e2e-ui/specs/backoffice/checkin-cutoff.spec.ts diff --git a/apps/edr-passenger-api/prisma/migrations/20260723000001_add_route_stop_travel_minutes/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260723000001_add_route_stop_travel_minutes/migration.sql new file mode 100644 index 000000000..59ae79426 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260723000001_add_route_stop_travel_minutes/migration.sql @@ -0,0 +1,9 @@ +-- Adds RouteStop.travelMinutesToStop: admin-configured travel time (minutes) from the +-- previous stop, used to compute each stop's estimated arrival time (replacing/augmenting +-- distance-proportional interpolation). Nullable — falls back to distance interpolation +-- when unset. +-- Uses IF NOT EXISTS following the pattern established in +-- 20260719000002_repair_route_checkin_minutes, after this same table had two migrations +-- checked in as empty "applied directly" placeholders that never reached the deployed DB. + +ALTER TABLE passenger."RouteStop" ADD COLUMN IF NOT EXISTS "travelMinutesToStop" INTEGER; diff --git a/apps/edr-passenger-api/test/checkin-cutoff.e2e-spec.ts b/apps/edr-passenger-api/test/checkin-cutoff.e2e-spec.ts new file mode 100644 index 000000000..8cbc7d8f8 --- /dev/null +++ b/apps/edr-passenger-api/test/checkin-cutoff.e2e-spec.ts @@ -0,0 +1,203 @@ +/** + * Per-station check-in cutoff — proves booking closure is now based on each stop's own + * ESTIMATED ARRIVAL time (computed from RouteStop.travelMinutesToStop), not the schedule's + * overall departure. The regression this guards: before this change, all stops effectively + * shared one cutoff basis, so a later station could be wrongly blocked (or an earlier one + * wrongly left open) together with the rest of the route. + * + * Uses the slim harness (SchedulesService, real Nest DI) for schedule creation — this exercises + * the actual cumulative travel-time interpolation in SchedulesService.createSchedule. SeatsService + * and TasksService are NOT in the slim harness's DOMAIN_MODULES (they pull in NotificationsModule + * → RabbitMQ, which the slim harness deliberately avoids — see test/setup/slim-app.ts), so they're + * instantiated directly with a real Prisma + stubbed collaborators, mirroring the Tier-2 pattern in + * money-integrity.e2e-spec.ts. + */ +import { SchedulesService } from "../src/modules/schedules/schedules.service"; +import { SeatsService } from "../src/modules/seats/seats.service"; +import { TasksService } from "../src/modules/tasks/tasks.service"; +import { SystemConfigService } from "../src/modules/system-config/system-config.service"; +import { createServiceHarness, ServiceHarness } from "./setup/slim-app"; +import { IDS, DISTANCE, resetAndSeedCore } from "./fixtures/seed-core"; + +/** A Proxy whose every property is an async no-op — satisfies unused collaborator method calls. */ +function asyncStub(): any { + return new Proxy({}, { get: () => async () => undefined }); +} + +/** Creates a fresh Train + TrainSchedule on the seed-core route via the real interpolation logic. */ +async function createTestSchedule( + harness: ServiceHarness, + schedules: SchedulesService, + opts: { trainNumber: string; departureAt: Date; arrivalAt: Date }, +) { + const train = await harness.prisma.train.create({ + data: { number: opts.trainNumber, name: `Test ${opts.trainNumber}` }, + }); + const schedule = await schedules.createSchedule({ + trainId: train.id, + routeId: IDS.route, + departureAt: opts.departureAt.toISOString(), + arrivalAt: opts.arrivalAt.toISOString(), + } as any); + + // Give the schedule a bookable coach so holdSeats has real seats to work with. + const coach = await harness.prisma.coach.create({ + data: { coachTypeId: IDS.coachType, number: `${opts.trainNumber}-C1`, capacity: 4, sequence: 1, status: "ACTIVE" }, + }); + await harness.prisma.coachAssignment.create({ + data: { scheduleId: schedule.id, coachId: coach.id, positionNumber: 1, isOperational: true }, + }); + const seats = await Promise.all( + ["1A", "1B", "1C", "1D"].map((seatNumber, i) => + harness.prisma.seat.create({ + data: { coachId: coach.id, seatNumber, row: 1, col: seatNumber.slice(-1), isWindow: i === 0, isAisle: i === 1 }, + }), + ), + ); + + return { schedule, seats }; +} + +describe("Check-in cutoff — arrival-time basis, per-station independence", () => { + let harness: ServiceHarness; + let schedulesService: SchedulesService; + let seatsService: SeatsService; + let tasksService: TasksService; + + beforeAll(async () => { + harness = await createServiceHarness(); + schedulesService = await harness.moduleRef.resolve(SchedulesService); + const systemConfig = new SystemConfigService(harness.prisma as any); + seatsService = new SeatsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub()); + tasksService = new TasksService(harness.prisma as any, asyncStub(), asyncStub()); + }); + + afterAll(async () => { + await harness?.close(); + }); + + it("a later station remains independently bookable after an earlier station's cutoff has passed", async () => { + await resetAndSeedCore(harness.prisma); + + // dep only 5 min out (createSchedule requires a future departureAt). Route-level default + // checkinMinutesBefore is 30 (schema default, unset here), so A's cutoff (dep - 30min) is + // already ~25 min in the past by the time this runs — but B, with a 60-min travel time from + // A, has an arrival far enough out (dep + 60min) that its own cutoff (arrival - 30min) is + // still ~35 min in the future. + const dep = new Date(Date.now() + 5 * 60_000); + const arr = new Date(dep.getTime() + 100 * 60_000); // A->B 60min + B->C 40min + await harness.prisma.routeStop.update({ + where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, + data: { travelMinutesToStop: 60 }, + }); + await harness.prisma.routeStop.update({ + where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, + data: { travelMinutesToStop: 40 }, + }); + + const { schedule, seats } = await createTestSchedule(harness, schedulesService, { + trainNumber: `CUTOFF-A-${Date.now()}`, + departureAt: dep, + arrivalAt: arr, + }); + + await expect( + seatsService.holdSeats({ + scheduleId: schedule.id, + originStationId: IDS.stationA, + destinationStationId: IDS.stationB, + passengers: [{ passengerId: "11111111-1111-4111-8111-111111111111", seatId: seats[0].id }], + } as any), + ).rejects.toThrow(/cannot be held within/i); + + const held = await seatsService.holdSeats({ + scheduleId: schedule.id, + originStationId: IDS.stationB, + destinationStationId: IDS.stationC, + passengers: [{ passengerId: "22222222-2222-4222-8222-222222222222", seatId: seats[1].id }], + } as any); + expect(held).toBeTruthy(); + }); + + it("a stop-level checkinMinutesBefore override wins over the route-level default", async () => { + // Override B with a LARGE cutoff (90 min) — under the route default (30 min) this exact + // schedule's B segment would still be OPEN (see previous test), so a rejection here proves + // the stop-level override, not the default, is what's actually being applied. + await resetAndSeedCore(harness.prisma, { B: { checkinMinutesBefore: 90 } }); + await harness.prisma.routeStop.update({ + where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, + data: { travelMinutesToStop: 60 }, + }); + + const dep = new Date(Date.now() + 5 * 60_000); + const arr = new Date(dep.getTime() + 100 * 60_000); + const { schedule, seats } = await createTestSchedule(harness, schedulesService, { + trainNumber: `CUTOFF-B-${Date.now()}`, + departureAt: dep, + arrivalAt: arr, + }); + + await expect( + seatsService.holdSeats({ + scheduleId: schedule.id, + originStationId: IDS.stationB, + destinationStationId: IDS.stationC, + passengers: [{ passengerId: "33333333-3333-4333-8333-333333333333", seatId: seats[0].id }], + } as any), + ).rejects.toThrow(/cannot be held within 90 minute/i); + }); + + it("syncScheduleStatuses closes only the specific stops past their own arrival-based cutoff", async () => { + await resetAndSeedCore(harness.prisma); + await harness.prisma.routeStop.update({ + where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, + data: { travelMinutesToStop: 60 }, + }); + await harness.prisma.routeStop.update({ + where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, + data: { travelMinutesToStop: 40 }, + }); + + const dep = new Date(Date.now() + 5 * 60_000); + const arr = new Date(dep.getTime() + 100 * 60_000); + const { schedule } = await createTestSchedule(harness, schedulesService, { + trainNumber: `CUTOFF-C-${Date.now()}`, + departureAt: dep, + arrivalAt: arr, + }); + + await tasksService.syncScheduleStatuses(); + + const stopTimes = await harness.prisma.tripStopTime.findMany({ + where: { scheduleId: schedule.id }, + orderBy: { sequence: "asc" }, + }); + const byStation = Object.fromEntries(stopTimes.map((s) => [s.stationId, s.status])); + expect(byStation[IDS.stationA]).toBe("CHECKIN_CLOSED"); + expect(byStation[IDS.stationB]).toBe("OPEN"); + expect(byStation[IDS.stationC]).toBe("OPEN"); + }); + + it("a stop missing travelMinutesToStop falls back to distance interpolation without failing schedule creation", async () => { + await resetAndSeedCore(harness.prisma); // no travelMinutesToStop set on any stop + + const dep = new Date(Date.now() + 60 * 60_000); + const arr = new Date(dep.getTime() + 240 * 60_000); // 4h, matches seed-ui's convention + const { schedule } = await createTestSchedule(harness, schedulesService, { + trainNumber: `CUTOFF-D-${Date.now()}`, + departureAt: dep, + arrivalAt: arr, + }); + + const stopTimes = await harness.prisma.tripStopTime.findMany({ + where: { scheduleId: schedule.id }, + orderBy: { sequence: "asc" }, + }); + const totalDuration = arr.getTime() - dep.getTime(); + const bProgress = DISTANCE.B / DISTANCE.C; + const expectedBArrival = new Date(dep.getTime() + totalDuration * bProgress); + + const bStop = stopTimes.find((s) => s.stationId === IDS.stationB)!; + expect(bStop.plannedArrivalAt?.getTime()).toBe(expectedBArrival.getTime()); + }); +}); diff --git a/apps/edr-passenger-web/backoffice/src/lib/timezone.ts b/apps/edr-passenger-web/backoffice/src/lib/timezone.ts new file mode 100644 index 000000000..5c6097feb --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/lib/timezone.ts @@ -0,0 +1,21 @@ +/** + * The backend always interprets bare datetime strings (no timezone suffix) as East African + * Time (EAT, UTC+3) and stores everything as UTC — see parseEthiopianTime() in + * apps/edr-passenger-api/src/common/utils/timezone.utils.ts. These mirror that on the frontend + * so `` fields round-trip correctly regardless of the browser's + * own local timezone. + */ + +/** Parse a datetime-local string ("YYYY-MM-DDTHH:mm") as EAT (UTC+3) and return a UTC ISO string. */ +export function eatLocalToISO(local: string): string { + if (!local) return ''; + return new Date(local + ':00+03:00').toISOString(); +} + +/** Convert a UTC ISO string/Date to a datetime-local value ("YYYY-MM-DDTHH:mm") in EAT (UTC+3). */ +export function isoToEATLocal(iso: string | Date): string { + if (!iso) return ''; + const utcMs = new Date(iso).getTime(); + const eatMs = utcMs + 3 * 60 * 60 * 1000; + return new Date(eatMs).toISOString().slice(0, 16); +} diff --git a/e2e-ui/specs/backoffice/checkin-cutoff.spec.ts b/e2e-ui/specs/backoffice/checkin-cutoff.spec.ts new file mode 100644 index 000000000..b123f24bc --- /dev/null +++ b/e2e-ui/specs/backoffice/checkin-cutoff.spec.ts @@ -0,0 +1,100 @@ +import { test, expect } from "@playwright/test"; +import { API_URL, STATIONS, staffToken } from "../../fixtures/data"; +import { UI_IDS } from "../../../apps/edr-passenger-api/test/fixtures/seed-ui"; + +/** + * Track B — per-station check-in cutoff, driven by the backoffice route config API. Follows the + * same convention as config-validation.spec.ts: hits the passenger-api directly with a staff + * bearer token rather than driving the real DOM — the route-stop form has no data-testid hooks, + * so browser automation here would be selector-fragile for no extra coverage value. + * + * Creates its OWN route (not the shared ROUTE_ID from seed-ui) since updateRoute deletes and + * recreates all stops — mutating the shared fixture route would break every other spec that + * depends on its distances/times staying stable for the whole suite run. + */ +function auth() { + return { Authorization: `Bearer ${staffToken()}` }; +} + +test("BC-11 ✅ travelMinutesToStop drives each stop's estimated arrival independently of route-wide departure", async ({ request }) => { + const routeRes = await request.post(`${API_URL}/routes`, { + headers: auth(), + data: { + code: `E2E-CUTOFF-${Date.now()}`, + name: "E2E Check-in Cutoff Route", + effectiveFrom: "2020-01-01T00:00:00Z", + stops: [ + { stationId: STATIONS.A, sequence: 1 }, + { stationId: STATIONS.B, sequence: 2, travelMinutesToStop: 60 }, + { stationId: STATIONS.C, sequence: 3, travelMinutesToStop: 40 }, + ], + }, + }); + expect(routeRes.ok()).toBeTruthy(); + const route = (await routeRes.json())?.data ?? (await routeRes.json()); + const routeId = route.id; + + // Reuse the seeded coach via a route coach template so schedule creation auto-assigns real + // seats (createSchedule auto-applies any route coach template — see schedules.service.ts). + const templateRes = await request.put(`${API_URL}/routes/${routeId}/coaches`, { + headers: auth(), + data: { coaches: [{ coachId: UI_IDS.coach, positionNumber: 1 }] }, + }); + expect(templateRes.ok()).toBeTruthy(); + + // dep only 5 min out: A's cutoff (dep - 30min default) is already ~25 min in the past by the + // time this schedule is queried, but B's arrival (dep + 60min) keeps its own cutoff (arrival - + // 30min default) about 35 min in the future — proving the two stations close independently. + const dep = new Date(Date.now() + 5 * 60_000); + const arr = new Date(dep.getTime() + 100 * 60_000); // A->B 60min + B->C 40min + const scheduleRes = await request.post(`${API_URL}/schedules`, { + headers: auth(), + data: { trainId: UI_IDS.train, routeId, departureAt: dep.toISOString(), arrivalAt: arr.toISOString() }, + }); + expect(scheduleRes.ok()).toBeTruthy(); + const schedule = (await scheduleRes.json())?.data ?? (await scheduleRes.json()); + + const getRes = await request.get(`${API_URL}/schedules/${schedule.id}`, { headers: auth() }); + expect(getRes.ok()).toBeTruthy(); + const full = (await getRes.json())?.data ?? (await getRes.json()); + const stopTimes: any[] = full.stopTimes ?? []; + const bStop = stopTimes.find((s) => s.stationId === STATIONS.B); + const cStop = stopTimes.find((s) => s.stationId === STATIONS.C); + + expect(new Date(bStop.plannedArrivalAt).getTime()).toBe(dep.getTime() + 60 * 60_000); + expect(new Date(cStop.plannedArrivalAt).getTime()).toBe(arr.getTime()); // last stop locked to overall arrival + + // A's own segment (no arrival — falls back to its departure) is already past its cutoff... + const rejectRes = await request.post(`${API_URL}/seats/hold`, { + headers: auth(), + data: { + scheduleId: schedule.id, + originStationId: STATIONS.A, + destinationStationId: STATIONS.B, + passengers: [{ passengerId: "44444444-4444-4444-8444-444444444444", seatId: "00000000-0000-4000-8000-000000009999" }], + }, + }); + expect(rejectRes.status()).toBe(400); + expect((await rejectRes.json())?.message ?? "").toMatch(/cannot be held within/i); + + // ...while B, whose own arrival is comfortably later, remains independently bookable. + const seatmapRes = await request.get(`${API_URL}/seats/seatmap/${schedule.id}`, { headers: auth() }); + expect(seatmapRes.ok()).toBeTruthy(); + const seatmap = (await seatmapRes.json())?.data ?? (await seatmapRes.json()); + const seatId = seatmap.coaches?.[0]?.seats?.[0]?.id; + expect(seatId).toBeTruthy(); + + const holdRes = await request.post(`${API_URL}/seats/hold`, { + headers: auth(), + data: { + scheduleId: schedule.id, + originStationId: STATIONS.B, + destinationStationId: STATIONS.C, + passengers: [{ passengerId: "55555555-5555-4555-8555-555555555555", seatId }], + }, + }); + expect(holdRes.ok()).toBeTruthy(); + + await request.delete(`${API_URL}/schedules/${schedule.id}`, { headers: auth() }).catch(() => {}); + await request.delete(`${API_URL}/routes/${routeId}?cascade=true`, { headers: auth() }).catch(() => {}); +}); From 64e1130f7d7a47e9c49889c9766a2c8b139e2944 Mon Sep 17 00:00:00 2001 From: Muluhabt Date: Thu, 23 Jul 2026 20:11:11 +0300 Subject: [PATCH 15/16] Commiting stop based booking --- .gitignore | 1 + apps/edr-passenger-api/prisma/schema.prisma | 1 + .../modules/schedules/routes.controller.ts | 2 +- .../src/modules/schedules/routes.dto.ts | 12 +- .../src/modules/schedules/routes.service.ts | 50 +++++- .../src/modules/schedules/schedules.dto.ts | 4 + .../modules/schedules/schedules.service.ts | 152 +++++++++++------- .../src/modules/search/search.service.ts | 48 +++--- .../src/modules/seats/seats.service.ts | 6 +- .../src/modules/tasks/tasks.service.ts | 72 ++++++--- .../test/fixtures/seed-core.ts | 23 ++- .../backoffice/src/app/routes/page.tsx | 78 ++++++++- .../backoffice/src/app/schedules/page.tsx | 39 +++-- .../src/providers/waafi/waafi.provider.ts | 13 +- 14 files changed, 354 insertions(+), 147 deletions(-) diff --git a/.gitignore b/.gitignore index cadb36cea..316f08dc3 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,4 @@ e2e-ui-report/ test-results/ playwright-report/ blob-report/ +RUNNING_LOCALLY.md diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index db46df025..de086e465 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -1078,6 +1078,7 @@ model RouteStop { sequence Int distanceKm Float? checkinMinutesBefore Int? + travelMinutesToStop Int? plannedArrivalTime DateTime? plannedDepartureTime DateTime? createdAt DateTime @default(now()) diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts b/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts index d468bba7c..5e3237e93 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts @@ -42,7 +42,7 @@ Route stops carry distanceKm for fare-by-distance calculations.`, @Patch(':id') @PassengerStaff([PASSENGER_PERMS.routes.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') - @ApiOperation({ summary: 'Update route metadata (name, description, active flag, effectiveUntil)' }) + @ApiOperation({ summary: 'Update route metadata (name, description, active flag, effectiveFrom, effectiveUntil)' }) @ApiParam({ name: 'id', description: 'Route UUID' }) @ApiResponse({ status: 200, description: 'Route updated' }) @ApiResponse({ status: 404, description: 'Route not found' }) diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts b/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts index f2db36e7c..1036138c9 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts @@ -5,8 +5,9 @@ import { Type } from 'class-transformer'; export class RouteStopInputDto { @ApiProperty({ example: 'station-uuid', description: 'Station UUID' }) @IsString() stationId: string; @ApiProperty({ example: 1, description: 'Stop order (1 = origin, ascending)' }) @IsInt() @Min(1) sequence: number; - @ApiPropertyOptional({ example: 120.5, description: 'Distance in km from previous stop' }) @IsOptional() @IsNumber() distanceKm?: number; + @ApiPropertyOptional({ example: 120.5, description: 'Cumulative distance in km from the route origin (not from the previous stop)' }) @IsOptional() @IsNumber() distanceKm?: number; @ApiPropertyOptional({ example: 45, description: 'Override check-in cutoff (minutes) for this stop. Falls back to route-level checkinMinutesBefore if omitted.' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number; + @ApiPropertyOptional({ example: 40, description: 'Travel time in minutes from the previous stop, used to estimate this stop\'s arrival time. Ignored for sequence 1 (origin, no predecessor). Falls back to distance-proportional interpolation if omitted.' }) @IsOptional() @IsInt() @Min(1) travelMinutesToStop?: number; } export class CreateRouteDto { @@ -14,8 +15,9 @@ export class CreateRouteDto { @ApiProperty({ example: 'Addis Ababa – Djibouti' }) @IsString() name: string; @ApiPropertyOptional({ example: 'Main corridor via Dire Dawa' }) @IsOptional() @IsString() description?: string; @ApiProperty({ example: '2026-01-01T00:00:00Z', description: 'Date from which this route is effective' }) @IsDateString() effectiveFrom: string; - @ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string; + @ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string | null; @ApiPropertyOptional({ example: true, description: 'Whether the route is active (defaults to true)' }) @IsOptional() @IsBoolean() active?: boolean; + @ApiPropertyOptional({ example: 30, description: 'Minutes before departure to close check-in for this route (defaults to 30 if omitted)' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number; @ApiProperty({ type: [RouteStopInputDto], description: 'Ordered stops for this route. Sequence 1 = origin, last sequence = destination.', @@ -35,15 +37,17 @@ export class CreateRouteDto { export class AddRouteStopDto { @ApiProperty({ example: 'station-uuid' }) @IsString() stationId: string; @ApiProperty({ example: 3 }) @IsInt() @Min(1) sequence: number; - @ApiPropertyOptional({ example: 75.5 }) @IsOptional() @IsNumber() distanceKm?: number; + @ApiPropertyOptional({ example: 75.5, description: 'Cumulative distance in km from the route origin (not from the previous stop)' }) @IsOptional() @IsNumber() distanceKm?: number; @ApiPropertyOptional({ example: 45, description: 'Override check-in cutoff (minutes) for this stop. Falls back to route-level checkinMinutesBefore if omitted.' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number; + @ApiPropertyOptional({ example: 40, description: 'Travel time in minutes from the previous stop, used to estimate this stop\'s arrival time. Falls back to distance-proportional interpolation if omitted.' }) @IsOptional() @IsInt() @Min(1) travelMinutesToStop?: number; } export class UpdateRouteDto { @ApiPropertyOptional({ example: 'Addis Ababa – Djibouti Express' }) @IsOptional() @IsString() name?: string; @ApiPropertyOptional() @IsOptional() @IsString() description?: string; @ApiPropertyOptional({ example: true }) @IsOptional() @IsBoolean() active?: boolean; - @ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string; + @ApiPropertyOptional({ example: '2026-01-01T00:00:00Z', description: 'Date from which this route is effective' }) @IsOptional() @IsDateString() effectiveFrom?: string; + @ApiPropertyOptional({ example: '2027-12-31T23:59:59Z', description: 'Send null to clear (open-ended route)' }) @IsOptional() @IsDateString() effectiveUntil?: string | null; @ApiPropertyOptional({ example: 30, description: 'Minutes before departure to close check-in for this route' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number; @ApiPropertyOptional({ type: [RouteStopInputDto] }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => RouteStopInputDto) stops?: RouteStopInputDto[]; } diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.service.ts b/apps/edr-passenger-api/src/modules/schedules/routes.service.ts index 58e647180..1a78e906e 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.service.ts @@ -3,6 +3,7 @@ import { PrismaService } from '../../common/prisma.service'; import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto, SetRouteCoachTemplateDto } from './routes.dto'; import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception'; import { AuditService } from '../../common/audit.service'; +import { parseEthiopianTime } from '../../common/utils/timezone.utils'; @Injectable() export class RoutesService { @@ -10,6 +11,33 @@ export class RoutesService { // ── Route CRUD ───────────────────────────────────────────────────────────── + /** + * distanceKm is CUMULATIVE distance from the route origin, not distance from the previous + * stop (that's what travelMinutesToStop is for) — fare pricing computes a segment's distance + * as destStop.distanceKm - originStop.distanceKm, so a route with equal or decreasing values + * across stops silently produces zero/negative segment distances, which the fare engine + * rejects (caught and swallowed by search into a bare "N/A" instead of a visible error). Catch + * the mistake here instead, with a message that names the exact stops involved. + */ + private validateStopDistances(stops: { sequence: number; stationId: string; distanceKm?: number | null }[]): void { + const sorted = [...stops].sort((a, b) => a.sequence - b.sequence); + let prevDistance = sorted[0]?.distanceKm ?? 0; + for (let i = 1; i < sorted.length; i++) { + const stop = sorted[i]; + if (stop.distanceKm == null) { + throw new BadRequestException( + `Stop ${stop.sequence} is missing distanceKm (cumulative distance in km from the route origin). This is required for fare pricing.`, + ); + } + if (stop.distanceKm <= prevDistance) { + throw new BadRequestException( + `Stop ${stop.sequence}'s distanceKm (${stop.distanceKm}) must be greater than stop ${sorted[i - 1].sequence}'s distanceKm (${prevDistance}) — distanceKm is cumulative distance from the route origin, not distance from the previous stop. Equal or decreasing values make fare pricing between these stops fail silently.`, + ); + } + prevDistance = stop.distanceKm; + } + } + async createRoute(dto: CreateRouteDto) { const existing = await this.prisma.route.findUnique({ where: { code: dto.code } }); if (existing) throw new ConflictException(`Route code "${dto.code}" already exists`); @@ -19,6 +47,8 @@ export class RoutesService { const seqs = dto.stops.map(s => s.sequence); if (new Set(seqs).size !== seqs.length) throw new ConflictException('Duplicate sequence numbers in stop list'); + this.validateStopDistances(dto.stops); + const stationIds = [...new Set(dto.stops.map(s => s.stationId))]; const stations = await this.prisma.station.findMany({ where: { id: { in: stationIds } } }); if (stations.length !== stationIds.length) throw new BadRequestException('One or more station IDs not found'); @@ -29,14 +59,16 @@ export class RoutesService { name: dto.name, description: dto.description, active: dto.active ?? true, - effectiveFrom: new Date(dto.effectiveFrom), - effectiveUntil: dto.effectiveUntil ? new Date(dto.effectiveUntil) : null, + ...(dto.checkinMinutesBefore != null ? { checkinMinutesBefore: dto.checkinMinutesBefore } : {}), + effectiveFrom: parseEthiopianTime(dto.effectiveFrom), + effectiveUntil: dto.effectiveUntil ? parseEthiopianTime(dto.effectiveUntil) : null, stops: { create: dto.stops.map(s => ({ stationId: s.stationId, sequence: s.sequence, distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null, checkinMinutesBefore: s.checkinMinutesBefore ?? null, + travelMinutesToStop: s.travelMinutesToStop ?? null, })), }, }, @@ -86,13 +118,20 @@ export class RoutesService { const route = await this.prisma.route.findUnique({ where: { id } }); if (!route) throw new NotFoundException('Route not found'); + if (dto.stops && dto.stops.length >= 2) this.validateStopDistances(dto.stops); + await this.prisma.route.update({ where: { id }, data: { name: dto.name, description: dto.description, active: dto.active, - effectiveUntil: dto.effectiveUntil ? new Date(dto.effectiveUntil) : undefined, + ...(dto.effectiveFrom ? { effectiveFrom: parseEthiopianTime(dto.effectiveFrom) } : {}), + // effectiveUntil is nullable (open-ended route) — distinguish "field not sent" (leave + // untouched) from "explicitly cleared" (null → set to null), not just truthy/falsy. + ...(dto.effectiveUntil !== undefined + ? { effectiveUntil: dto.effectiveUntil ? parseEthiopianTime(dto.effectiveUntil) : null } + : {}), ...(dto.checkinMinutesBefore != null ? { checkinMinutesBefore: dto.checkinMinutesBefore } : {}), }, }); @@ -106,6 +145,7 @@ export class RoutesService { sequence: s.sequence, distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null, checkinMinutesBefore: s.checkinMinutesBefore ?? null, + travelMinutesToStop: s.travelMinutesToStop ?? null, })), }); } @@ -218,6 +258,9 @@ export class RoutesService { }); if (existing) throw new ConflictException(`Sequence ${dto.sequence} already exists on this route`); + const otherStops = await this.prisma.routeStop.findMany({ where: { routeId } }); + this.validateStopDistances([...otherStops, { sequence: dto.sequence, stationId: dto.stationId, distanceKm: dto.distanceKm }]); + return this.prisma.routeStop.create({ data: { routeId, @@ -225,6 +268,7 @@ export class RoutesService { sequence: dto.sequence, distanceKm: dto.distanceKm != null ? parseFloat(String(dto.distanceKm)) : null, checkinMinutesBefore: dto.checkinMinutesBefore ?? null, + travelMinutesToStop: dto.travelMinutesToStop ?? null, }, }); } diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts index 675168cf9..49dec1d96 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts @@ -52,6 +52,10 @@ export class CreateScheduleDto { }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto) plannedTimes?: PlannedStopTimeDto[]; + + @ApiPropertyOptional({ type: [String], description: 'Coach UUIDs to assign, in consist order. Overrides the route coach template if provided. A schedule must end up with at least one coach.' }) + @IsOptional() @IsArray() @IsString({ each: true }) + coachIds?: string[]; } export class UpdateScheduleDto { diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts index 4c2870a8a..be1c936b4 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; import { RoutesService } from './routes.service'; import { FareEngineService } from '../fare-engine/fare-engine.service'; @@ -9,6 +9,8 @@ import { AuditService } from '../../common/audit.service'; @Injectable() export class SchedulesService { + private readonly logger = new Logger(SchedulesService.name); + constructor( private prisma: PrismaService, private routesService: RoutesService, @@ -16,6 +18,44 @@ export class SchedulesService { private auditService: AuditService, ) { } + /** + * Computes each stop's planned arrival/departure time by walking the route in sequence + * order and accumulating `RouteStop.travelMinutesToStop` (minutes of travel from the + * previous stop). Falls back to distance-proportional interpolation over `distanceKm` for + * any stop missing `travelMinutesToStop`. The last stop is always locked to the confirmed + * overall `arr` regardless of the accumulated cursor, so schedule.arrivalAt stays + * authoritative even if per-stop estimates drift. + */ + private computePlannedTimes( + route: { id: string; stops: { sequence: number; distanceKm: number | null; travelMinutesToStop: number | null }[] }, + dep: Date, + arr: Date, + ) { + const totalDuration = arr.getTime() - dep.getTime(); + const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0; + + let cursor = dep; + return route.stops.map((stop, index) => { + if (index === 0) { + cursor = dep; + } else if (index === route.stops.length - 1) { + cursor = arr; + } else if (stop.travelMinutesToStop != null) { + cursor = new Date(cursor.getTime() + stop.travelMinutesToStop * 60_000); + } else { + const stopDistance = stop.distanceKm || 0; + const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1); + cursor = new Date(dep.getTime() + totalDuration * progress); + this.logger.warn(`Route ${route.id} stop seq ${stop.sequence} missing travelMinutesToStop; falling back to distance interpolation`); + } + return { + sequence: stop.sequence, + plannedArrivalAt: index === 0 ? undefined : cursor.toISOString(), + plannedDepartureAt: index === route.stops.length - 1 ? undefined : cursor.toISOString(), + }; + }); + } + async bulkGenerateSchedules(dto: BulkCreateSchedulesDto) { const startDate = parseEthiopianTime(dto.startDateTime); const endDate = new Date(startDate.getTime() + dto.forNextDays * 24 * 60 * 60 * 1000); @@ -43,20 +83,14 @@ export class SchedulesService { departureAt: departureAt.toISOString(), arrivalAt: arrivalAt.toISOString(), plannedTimes: dto.plannedTimes || [], + coachIds: dto.coachIds, }; + // createSchedule applies coachIds if given, else auto-applies the route coach template, + // and rejects the day outright (caught below) if it would end up with zero coaches. const schedule = await this.createSchedule(createDto); scheduleIds.push(schedule.id); - // createSchedule already auto-applies the route coach template; - // only override if explicit coachIds are provided - if (dto.coachIds && dto.coachIds.length > 0) { - await this.assignCoaches( - schedule.id, - dto.coachIds.map((coachId, idx) => ({ coachId, positionNumber: idx + 1 })), - ); - } - scheduleCount++; } catch (error) { errors.push(`Failed to create schedule for ${currentDate.toISOString()}: ${error instanceof Error ? error.message : String(error)}`); @@ -135,26 +169,7 @@ export class SchedulesService { let plannedTimes = dto.plannedTimes; if (!plannedTimes || plannedTimes.length === 0) { - const totalDuration = arr.getTime() - dep.getTime(); - const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0; - - plannedTimes = route.stops.map((stop, index) => { - let stopTime: Date; - if (index === 0) { - stopTime = dep; - } else if (index === route.stops.length - 1) { - stopTime = arr; - } else { - const stopDistance = stop.distanceKm || 0; - const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1); - stopTime = new Date(dep.getTime() + totalDuration * progress); - } - return { - sequence: stop.sequence, - plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(), - plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(), - }; - }); + plannedTimes = this.computePlannedTimes(route, dep, arr); } const providedSeqs = new Set((plannedTimes ?? []).map(t => t.sequence)); @@ -183,15 +198,34 @@ export class SchedulesService { const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t])); await this.routesService.applyRouteToSchedule(dto.routeId, schedule.id, plannedTimesMap); - // Auto-apply route coach template if one is defined - const coachTemplates = await this.prisma.routeCoachTemplate.findMany({ - where: { routeId: dto.routeId }, - orderBy: { positionNumber: 'asc' }, - }); - if (coachTemplates.length > 0) { + // Explicit coachIds (from the schedule form's Coaches step) override the route's coach + // template; otherwise auto-apply the template if one is defined. + if (dto.coachIds && dto.coachIds.length > 0) { await this.assignCoaches( schedule.id, - coachTemplates.map(t => ({ coachId: t.coachId, positionNumber: t.positionNumber })), + dto.coachIds.map((coachId, idx) => ({ coachId, positionNumber: idx + 1 })), + ); + } else { + const coachTemplates = await this.prisma.routeCoachTemplate.findMany({ + where: { routeId: dto.routeId }, + orderBy: { positionNumber: 'asc' }, + }); + if (coachTemplates.length > 0) { + await this.assignCoaches( + schedule.id, + coachTemplates.map(t => ({ coachId: t.coachId, positionNumber: t.positionNumber })), + ); + } + } + + // A schedule with zero coaches has zero seats and is silently invisible to search (and + // unbookable) with no indication why — block creation instead of leaving a dead schedule. + const assignedCoachCount = await this.prisma.coachAssignment.count({ where: { scheduleId: schedule.id } }); + if (assignedCoachCount === 0) { + await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: schedule.id } }); + await this.prisma.trainSchedule.delete({ where: { id: schedule.id } }); + throw new BadRequestException( + 'A schedule must have at least one coach assigned to be bookable. Add coaches in the Coaches step, or set a Route Coach Template on this route so new schedules auto-assign coaches.', ); } @@ -306,26 +340,7 @@ export class SchedulesService { let plannedTimes = dto.plannedTimes; if (!plannedTimes || plannedTimes.length === 0) { - const totalDuration = arr.getTime() - dep.getTime(); - const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0; - - plannedTimes = route.stops.map((stop, index) => { - let stopTime: Date; - if (index === 0) { - stopTime = dep; - } else if (index === route.stops.length - 1) { - stopTime = arr; - } else { - const stopDistance = stop.distanceKm || 0; - const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1); - stopTime = new Date(dep.getTime() + totalDuration * progress); - } - return { - sequence: stop.sequence, - plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(), - plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(), - }; - }); + plannedTimes = this.computePlannedTimes(route, dep, arr); } const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t])); @@ -655,11 +670,14 @@ export class SchedulesService { if (!schedule) throw new NotFoundException('Schedule not found'); const updateData: any = {}; + let dep: Date | undefined; + let arr: Date | undefined; if (dto.departureAt || dto.arrivalAt) { - const dep = dto.departureAt ? parseEthiopianTime(dto.departureAt) : new Date(schedule.departureAt); - const arr = dto.arrivalAt ? parseEthiopianTime(dto.arrivalAt) : new Date(schedule.arrivalAt); + dep = dto.departureAt ? parseEthiopianTime(dto.departureAt) : new Date(schedule.departureAt); + arr = dto.arrivalAt ? parseEthiopianTime(dto.arrivalAt) : new Date(schedule.arrivalAt); if (arr <= dep) throw new BadRequestException('Arrival time must be after departure time'); + if (dep.getTime() < Date.now()) throw new BadRequestException('departureAt must be in the future'); updateData.departureAt = dep; updateData.arrivalAt = arr; updateData.durationMinutes = Math.round((arr.getTime() - dep.getTime()) / 60_000); @@ -672,6 +690,22 @@ export class SchedulesService { await this.prisma.trainSchedule.update({ where: { id }, data: updateData }); } + // departureAt/arrivalAt changed — the per-stop TripStopTime rows were computed against the + // OLD times and are now stale (same interpolation createSchedule/updateSchedule use). Left + // unfixed, check-in cutoff enforcement and search silently keep using outdated per-stop + // arrival/departure estimates for every intermediate stop. + if (dep && arr && schedule.routeId) { + const route = await this.prisma.route.findUnique({ + where: { id: schedule.routeId }, + include: { stops: { orderBy: { sequence: 'asc' } } }, + }); + if (route && route.stops.length >= 2) { + const plannedTimes = this.computePlannedTimes(route, dep, arr); + const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t])); + await this.routesService.applyRouteToSchedule(schedule.routeId, id, plannedTimesMap); + } + } + if (dto.coaches !== undefined) { if (dto.coaches.length > 0) { await this.assignCoaches(id, dto.coaches); diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index 6ed657975..78fbcb955 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -10,7 +10,7 @@ import { CurrencyService } from "../currency/currency.service"; import { FareEngineService } from "../fare-engine/fare-engine.service"; import { SegmentsService } from "../segments/segments.service"; import { resolveCurrencyFromNationality } from "../fare-engine/fare-engine.dto"; -import { Currency } from "@prisma/client"; +import { Currency, Prisma } from "@prisma/client"; const POINTS_TO_MINOR = 10; @@ -220,12 +220,18 @@ export class SearchService { const totalPassengers = adultCount + (childCount ?? 0); const NEEDED = 3; - const baseWhere = { - status: "SCHEDULED", + // Include BOARDING alongside SCHEDULED: BOARDING is just an operational display status the + // schedule-level cron sets on a fixed 30-min-before-departure timer (see tasks.service.ts) — + // it does NOT mean booking is closed. The actual booking cutoff is per-stop and configurable + // (RouteStop/Route.checkinMinutesBefore), enforced below by buildScheduleResult's own live + // check against each stop's estimated arrival/departure. Excluding BOARDING here would + // silently impose a hidden, non-configurable 30-minute cutoff on top of that. + const baseWhere: Prisma.TrainScheduleWhereInput = { + status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] }, isPackageOnly: false, stopTimes: { some: { stationId: originStationId } }, coachAssignments: { some: {} }, - } as const; + }; // Fetch candidates before and after in parallel; take more than needed to // account for routes that don't serve the destination or have no availability. @@ -300,23 +306,21 @@ export class SearchService { const nextDay = new Date( `${String(y)}-${String(m).padStart(2, "0")}-${String(d + 1).padStart(2, "0")}T00:00:00+03:00`, ); - const now = new Date(); const totalPassengers = adultCount + (childCount ?? 0); - // Use now as the lower bound for today so we don't fetch schedules that have - // already fully departed. The per-segment cutoff check in buildScheduleResult - // handles the exact check using each stop's own plannedDepartureAt. - const isToday = - now.getFullYear() === y && - now.getMonth() === m - 1 && - now.getDate() === d; - const earliest = isToday ? now : date; - + // Match on the schedule's own departure DATE only — do NOT use `now` as a lower bound here. + // A schedule whose origin has already departed (EN_ROUTE) can still have a later stop (e.g. + // Lebu, Adama) whose own cutoff hasn't passed; using the overall departureAt as a floor would + // wrongly exclude the whole schedule for those still-bookable downstream segments. The + // per-segment cutoff check in buildScheduleResult is the sole authority for whether THIS + // specific origin stop is still bookable, using each stop's own estimated arrival/departure. const schedules = await this.prisma.trainSchedule.findMany({ where: { - status: "SCHEDULED", + // EN_ROUTE/BOARDING included alongside SCHEDULED — these are operational display + // statuses, not booking-closed signals (see comment on searchAlternatives' baseWhere). + status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] }, isPackageOnly: false, - departureAt: { gte: earliest, lt: nextDay }, + departureAt: { gte: date, lt: nextDay }, stopTimes: { some: { stationId: originStationId } }, coachAssignments: { some: {} }, }, @@ -368,7 +372,8 @@ export class SearchService { const [leg1Schedules, allCandidates] = await Promise.all([ this.prisma.trainSchedule.findMany({ where: { - status: "SCHEDULED", + // BOARDING included alongside SCHEDULED — see comment on searchAlternatives' baseWhere. + status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] }, isPackageOnly: false, departureAt: { gte: dayStart, lt: dayEnd }, stopTimes: { some: { stationId: originStationId } }, @@ -378,7 +383,7 @@ export class SearchService { }), this.prisma.trainSchedule.findMany({ where: { - status: "SCHEDULED", + status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] }, isPackageOnly: false, departureAt: { gte: dayStart, lt: leg2WindowEnd }, coachAssignments: { some: {} }, @@ -547,13 +552,14 @@ export class SearchService { if (!originStop || !destStop || originStop.sequence >= destStop.sequence) return null; - // Segment-level cutoff: use the origin stop's planned departure, not the + // Segment-level cutoff: use the origin stop's own estimated arrival time, not the // schedule's overall departureAt (which is station A's time). This lets - // B→D remain bookable even after A→D closes. + // B→D remain bookable even after A→D closes. The first stop has no arrival (nothing + // to arrive at), so it falls back to its own departure. // Cutoff resolution: stop-level override → route default → 30 min fallback. const now = new Date(); const segmentDepartureAt = - originStop.plannedDepartureAt ?? schedule.departureAt; + originStop.plannedArrivalAt ?? originStop.plannedDepartureAt ?? schedule.departureAt; const routeStop = schedule.route?.stops?.find( (s) => s.stationId === originStationId, ); diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index bd73c10f5..bd0a3cadf 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -281,7 +281,7 @@ export class SeatsService { }), this.prisma.tripStopTime.findFirst({ where: { scheduleId: dto.scheduleId, stationId: dto.originStationId }, - select: { plannedDepartureAt: true }, + select: { plannedArrivalAt: true, plannedDepartureAt: true }, }), this.prisma.routeStop.findFirst({ where: { @@ -295,7 +295,9 @@ export class SeatsService { // Stop-level override wins; falls back to route-level; then to 30 min. const checkinMinutes = originRouteStop?.checkinMinutesBefore ?? schedule.route?.checkinMinutesBefore ?? 30; - const segmentDepartureAt = originStopTime?.plannedDepartureAt ?? schedule.departureAt; + // Arrival basis: the origin stop's own estimated arrival, not its departure. The first + // stop of a route has no arrival (nothing to arrive at), so it falls back to its departure. + const segmentDepartureAt = originStopTime?.plannedArrivalAt ?? originStopTime?.plannedDepartureAt ?? schedule.departureAt; const msUntilDeparture = segmentDepartureAt.getTime() - Date.now(); if (msUntilDeparture <= checkinMinutes * 60 * 1000) { throw new BadRequestException( diff --git a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts index 4767c7eba..99d6b4d4e 100644 --- a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts +++ b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts @@ -81,17 +81,23 @@ export class TasksService { byRoute.get(stop.routeId)!.push(stop.stationId); } + // Arrival basis: each stop's own estimated arrival time, not its departure. The first + // stop of a route has no arrival (nothing to arrive at), so it falls back to its + // departure — expressed below as COALESCE(plannedArrivalAt, plannedDepartureAt). let reopenedCount = 0; let checkinClosedCount = 0; for (const [mins, byRoute] of byMins) { const cutoffAt = new Date(now.getTime() + mins * 60 * 1000); for (const [routeId, stationIds] of byRoute) { // Revert first: if the cutoff was reduced, stops that were prematurely closed - // should reopen (departure is still beyond the new cutoff window). + // should reopen (arrival is still beyond the new cutoff window). const reverted = await this.prisma.tripStopTime.updateMany({ where: { status: 'CHECKIN_CLOSED', - plannedDepartureAt: { gt: cutoffAt }, + OR: [ + { plannedArrivalAt: { gt: cutoffAt } }, + { AND: [{ plannedArrivalAt: null }, { plannedDepartureAt: { gt: cutoffAt } }] }, + ], stationId: { in: stationIds }, schedule: { routeId }, }, @@ -103,7 +109,10 @@ export class TasksService { const closed = await this.prisma.tripStopTime.updateMany({ where: { status: 'OPEN', - plannedDepartureAt: { lte: cutoffAt }, + OR: [ + { plannedArrivalAt: { lte: cutoffAt } }, + { AND: [{ plannedArrivalAt: null }, { plannedDepartureAt: { lte: cutoffAt } }] }, + ], stationId: { in: stationIds }, schedule: { routeId }, }, @@ -169,8 +178,8 @@ export class TasksService { include: { originStation: { select: { name: true } }, destinationStation: { select: { name: true } }, - stopTimes: { select: { stationId: true, plannedDepartureAt: true } }, - route: { select: { checkinMinutesBefore: true } }, + stopTimes: { select: { stationId: true, plannedArrivalAt: true, plannedDepartureAt: true } }, + route: { select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } } }, }, }, }, @@ -179,12 +188,17 @@ export class TasksService { for (const booking of bookings) { try { const createdAt = booking.createdAt as Date; - // Use the booking's origin-segment departure and the route's own check-in window. + // Use the booking's origin-segment estimated arrival (falling back to its departure + // for the first stop) and that stop's own check-in window (falling back to the route + // default), same resolution as holdSeats/search. const originStop = (booking.schedule as any).stopTimes?.find( (s: any) => s.stationId === (booking as any).originStationId, ); - const dep = (originStop?.plannedDepartureAt ?? booking.schedule.departureAt) as Date; - const checkinMinutes = (booking.schedule as any).route?.checkinMinutesBefore ?? 30; + const dep = (originStop?.plannedArrivalAt ?? originStop?.plannedDepartureAt ?? booking.schedule.departureAt) as Date; + const originRouteStop = (booking.schedule as any).route?.stops?.find( + (s: any) => s.stationId === (booking as any).originStationId, + ); + const checkinMinutes = originRouteStop?.checkinMinutesBefore ?? (booking.schedule as any).route?.checkinMinutesBefore ?? 30; if (dep <= now) continue; // segment has already departed; cancel job handles clean-up const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes); const totalWindowMs = paymentDeadline.getTime() - createdAt.getTime(); @@ -230,13 +244,28 @@ export class TasksService { // ── Cancel bookings whose payment deadline has passed ───────────────────── private async cancelExpiredPendingBookings(now: Date) { - const twoHoursAgo = new Date(now.getTime() - MAX_PAYMENT_HOURS * 60 * 60 * 1000); - const departureCutoff = new Date(now.getTime() + CUTOFF_MINUTES * 60 * 1000); + const twoHoursAgo = new Date(now.getTime() - MAX_PAYMENT_HOURS * 60 * 60 * 1000); - // payment_deadline = MIN(createdAt + 2h, departureAt - 30min) + // The departure pre-filter below is a query-scoping optimization only — the real + // deadline check happens per-row further down. It must be widened to the largest + // configured checkinMinutes across all routes/stops, or a booking on a route with a + // cutoff bigger than the CUTOFF_MINUTES default would never even be fetched here, + // silently never getting auto-cancelled. + const [maxRouteCutoff, maxStopCutoff] = await Promise.all([ + this.prisma.route.aggregate({ _max: { checkinMinutesBefore: true } }), + this.prisma.routeStop.aggregate({ _max: { checkinMinutesBefore: true } }), + ]); + const effectiveMaxCutoffMinutes = Math.max( + CUTOFF_MINUTES, + maxRouteCutoff._max.checkinMinutesBefore ?? 0, + maxStopCutoff._max.checkinMinutesBefore ?? 0, + ); + const departureCutoff = new Date(now.getTime() + effectiveMaxCutoffMinutes * 60 * 1000); + + // payment_deadline = MIN(createdAt + 2h, segment_arrival - checkinMinutes) // Deadline is reached when either branch of the MIN is in the past: - // (a) createdAt ≤ now - 2h → 2-hour max window elapsed - // (b) departureAt ≤ now + 30min → departure within 30 min + // (a) createdAt ≤ now - 2h → 2-hour max window elapsed + // (b) departureAt ≤ now + effectiveMaxCutoff → within the widest possible cutoff window const expiredBookings = await this.prisma.booking.findMany({ where: { status: 'PENDING_PAYMENT', @@ -250,8 +279,8 @@ export class TasksService { include: { originStation: { select: { name: true } }, destinationStation: { select: { name: true } }, - stopTimes: { select: { stationId: true, plannedDepartureAt: true } }, - route: { select: { checkinMinutesBefore: true } }, + stopTimes: { select: { stationId: true, plannedArrivalAt: true, plannedDepartureAt: true } }, + route: { select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } } }, }, }, paymentIntent: { select: { method: true } }, @@ -264,14 +293,19 @@ export class TasksService { for (const booking of expiredBookings) { try { // Re-verify exact deadline to avoid racing with a concurrent payment confirmation. - // Use the booking's origin-segment departure for the deadline so that a B→C booking - // on an A→B→C→D schedule gets the correct payment window anchored to B, not A. + // Use the booking's origin-segment estimated arrival (falling back to its departure + // for the first stop) and that stop's own check-in window, so a B→C booking on an + // A→B→C→D schedule gets the correct payment window anchored to B, not A. const createdAt = booking.createdAt as Date; const originStop = (booking.schedule as any).stopTimes?.find( (s: any) => s.stationId === (booking as any).originStationId, ); - const dep = (originStop?.plannedDepartureAt ?? booking.schedule.departureAt) as Date; - const paymentDeadline = computePaymentDeadline(createdAt, dep); + const dep = (originStop?.plannedArrivalAt ?? originStop?.plannedDepartureAt ?? booking.schedule.departureAt) as Date; + const originRouteStop = (booking.schedule as any).route?.stops?.find( + (s: any) => s.stationId === (booking as any).originStationId, + ); + const checkinMinutes = originRouteStop?.checkinMinutesBefore ?? (booking.schedule as any).route?.checkinMinutesBefore ?? CUTOFF_MINUTES; + const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes); if (now < paymentDeadline) continue; // 1a. Release held seats (Journey rows are the occupancy source of truth once paid) diff --git a/apps/edr-passenger-api/test/fixtures/seed-core.ts b/apps/edr-passenger-api/test/fixtures/seed-core.ts index 098489dab..115df217f 100644 --- a/apps/edr-passenger-api/test/fixtures/seed-core.ts +++ b/apps/edr-passenger-api/test/fixtures/seed-core.ts @@ -23,6 +23,17 @@ export const IDS = { /** Route stop distances (km from origin). A=0, B=100, C=250 → A→B is 100km, A→C is 250km. */ export const DISTANCE = { A: 0, B: 100, C: 250 } as const; +/** + * Optional per-stop check-in-cutoff/travel-time overrides, keyed by station label (A/B/C). + * Lets a spec seed a distinct `checkinMinutesBefore` override and/or `travelMinutesToStop` + * per stop without changing the zero-arg call sites the other specs rely on. + */ +export interface RouteStopOverrides { + A?: { checkinMinutesBefore?: number; travelMinutesToStop?: number }; + B?: { checkinMinutesBefore?: number; travelMinutesToStop?: number }; + C?: { checkinMinutesBefore?: number; travelMinutesToStop?: number }; +} + /** * FX rate chosen so the seat-class distance formula (which multiplies an ETB/km rate by the * USD→ETB rate — see fare-engine.service.ts:157) yields whole ETB-minor amounts. 100 makes the @@ -45,7 +56,7 @@ export async function truncateAllPassenger(prisma: PrismaClient): Promise } /** Insert the deterministic core graph. Call after truncateAllPassenger. */ -export async function seedCore(prisma: PrismaClient): Promise { +export async function seedCore(prisma: PrismaClient, stopOverrides: RouteStopOverrides = {}): Promise { const past = new Date("2020-01-01T00:00:00.000Z"); await prisma.coachType.create({ @@ -103,9 +114,9 @@ export async function seedCore(prisma: PrismaClient): Promise { active: true, stops: { create: [ - { stationId: IDS.stationA, sequence: 1, distanceKm: DISTANCE.A }, - { stationId: IDS.stationB, sequence: 2, distanceKm: DISTANCE.B }, - { stationId: IDS.stationC, sequence: 3, distanceKm: DISTANCE.C }, + { stationId: IDS.stationA, sequence: 1, distanceKm: DISTANCE.A, ...stopOverrides.A }, + { stationId: IDS.stationB, sequence: 2, distanceKm: DISTANCE.B, ...stopOverrides.B }, + { stationId: IDS.stationC, sequence: 3, distanceKm: DISTANCE.C, ...stopOverrides.C }, ], }, }, @@ -122,7 +133,7 @@ export async function seedCore(prisma: PrismaClient): Promise { } /** Convenience: reset + seed in one call. */ -export async function resetAndSeedCore(prisma: PrismaClient): Promise { +export async function resetAndSeedCore(prisma: PrismaClient, stopOverrides: RouteStopOverrides = {}): Promise { await truncateAllPassenger(prisma); - await seedCore(prisma); + await seedCore(prisma, stopOverrides); } diff --git a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx index feca7e3b5..6b310d137 100644 --- a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Plus, Edit, Trash2, X, Search, Train, Save, GripVertical } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; @@ -10,6 +10,7 @@ import Modal from '@/components/ui/Modal'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { routesApi } from '@/lib/api/routes'; import { stationsApi, fleetApi, routeCoachTemplatesApi } from '@/lib/api'; +import { eatLocalToISO, isoToEATLocal } from '@/lib/timezone'; interface RouteStop { stationId: string; @@ -17,6 +18,7 @@ interface RouteStop { distanceKm?: number; distanceFromOrigin?: number; checkinMinutesBefore?: number; + travelMinutesToStop?: number; } type Tab = 'routes' | 'coaches'; @@ -175,8 +177,18 @@ export default function RoutesPage() { const [destinationDistance, setDestinationDistance] = useState(undefined); const [originCheckinMinutes, setOriginCheckinMinutes] = useState(undefined); const [destinationCheckinMinutes, setDestinationCheckinMinutes] = useState(undefined); + const [destinationTravelMinutes, setDestinationTravelMinutes] = useState(undefined); const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; route: any | null; error?: string; cascade?: boolean; cascadeChecked?: boolean }>({ isOpen: false, route: null }); const [search, setSearch] = useState(''); + const [error, setError] = useState(null); + const errorBannerRef = useRef(null); + + // The form scrolls internally (long stop lists push the error banner above the fold), so a + // submit failure can land silently off-screen with no visible indication anything went wrong. + // Scroll the banner into view whenever a new error appears. + useEffect(() => { + if (error) errorBannerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, [error]); const queryClient = useQueryClient(); const { data: routes, isLoading: routesLoading } = useQuery({ @@ -198,6 +210,11 @@ export default function RoutesPage() { queryClient.invalidateQueries({ queryKey: ['routes'] }); setShowModal(false); setEditingRoute(null); + setError(null); + }, + onError: (e: any) => { + const msg = e?.response?.data?.message || e?.message || 'Failed to create route'; + setError(Array.isArray(msg) ? msg.join(' ') : msg); }, }); @@ -207,6 +224,11 @@ export default function RoutesPage() { queryClient.invalidateQueries({ queryKey: ['routes'] }); setShowModal(false); setEditingRoute(null); + setError(null); + }, + onError: (e: any) => { + const msg = e?.response?.data?.message || e?.message || 'Failed to update route'; + setError(Array.isArray(msg) ? msg.join(' ') : msg); }, }); @@ -244,6 +266,8 @@ export default function RoutesPage() { const sortedMiddleStops = stops; // distanceKm = cumulative distance from origin (fare engine uses destStop.distanceKm - originStop.distanceKm) + // travelMinutesToStop = minutes of travel from the PREVIOUS stop, used to estimate this + // stop's arrival time. The origin (sequence 1) has no predecessor, so it gets none. const stopsArray = [ { stationId: originStationId, sequence: 1, distanceKm: 0, checkinMinutesBefore: originCheckinMinutes ?? undefined }, ...sortedMiddleStops.map((stop, idx) => ({ @@ -251,12 +275,14 @@ export default function RoutesPage() { sequence: idx + 2, distanceKm: stop.distanceFromOrigin || 0, checkinMinutesBefore: stop.checkinMinutesBefore ?? undefined, + travelMinutesToStop: stop.travelMinutesToStop ?? undefined, })), { stationId: destinationStationId, sequence: sortedMiddleStops.length + 2, distanceKm: destinationDistance || 0, checkinMinutesBefore: destinationCheckinMinutes ?? undefined, + travelMinutesToStop: destinationTravelMinutes ?? undefined, }, ]; @@ -266,8 +292,10 @@ export default function RoutesPage() { name: formData.get('name') as string, description: formData.get('description') as string || undefined, active: !editingRoute ? (formData.get('active') !== 'false') : undefined, - effectiveFrom: formData.get('effectiveFrom') as string, - effectiveUntil: formData.get('effectiveUntil') as string || undefined, + effectiveFrom: eatLocalToISO(formData.get('effectiveFrom') as string), + // null (not undefined) so clearing the field on an edit explicitly clears effectiveUntil + // server-side, instead of being silently dropped as "no change". + effectiveUntil: formData.get('effectiveUntil') ? eatLocalToISO(formData.get('effectiveUntil') as string) : null, checkinMinutesBefore: checkinRaw ? parseInt(checkinRaw) : undefined, stops: stopsArray, }; @@ -390,14 +418,17 @@ export default function RoutesPage() { setDestinationStationId(destStop.stationId); setDestinationCheckinMinutes(destStop.checkinMinutesBefore ?? undefined); setDestinationDistance(destStop.distanceKm || 0); + setDestinationTravelMinutes(destStop.travelMinutesToStop ?? undefined); setStops(routeStops.slice(1, -1).map((s: any) => ({ stationId: s.stationId, sequence: s.sequence, distanceKm: s.distanceKm, distanceFromOrigin: s.distanceKm || 0, checkinMinutesBefore: s.checkinMinutesBefore ?? undefined, + travelMinutesToStop: s.travelMinutesToStop ?? undefined, }))); } + setError(null); setShowModal(true); } finally { setEditLoading(false); @@ -436,7 +467,9 @@ export default function RoutesPage() { setDestinationStationId(''); setDestinationCheckinMinutes(undefined); setDestinationDistance(undefined); + setDestinationTravelMinutes(undefined); setStops([]); + setError(null); setShowModal(true); }} > @@ -515,13 +548,20 @@ export default function RoutesPage() { setDestinationStationId(''); setDestinationCheckinMinutes(undefined); setDestinationDistance(undefined); + setDestinationTravelMinutes(undefined); setStops([]); setSearch(''); + setError(null); }} title={`${editingRoute ? 'Edit' : 'Add'} Route`} size="lg" >
+ {error && ( +
+ {error} +
+ )} {editingRoute && (

⚠ Warning

@@ -647,7 +687,7 @@ export default function RoutesPage() { type="datetime-local" name="effectiveFrom" className="input" - defaultValue={editingRoute?.effectiveFrom ? new Date(editingRoute.effectiveFrom).toISOString().slice(0, 16) : new Date().toISOString().slice(0, 16)} + defaultValue={editingRoute?.effectiveFrom ? isoToEATLocal(editingRoute.effectiveFrom) : isoToEATLocal(new Date())} required />
@@ -657,7 +697,7 @@ export default function RoutesPage() { type="datetime-local" name="effectiveUntil" className="input" - defaultValue={editingRoute?.effectiveUntil ? new Date(editingRoute.effectiveUntil).toISOString().slice(0, 16) : ''} + defaultValue={editingRoute?.effectiveUntil ? isoToEATLocal(editingRoute.effectiveUntil) : ''} />
@@ -665,7 +705,7 @@ export default function RoutesPage() {
- Drag to rearrange · Cutoff min overrides route check-in window per stop (leave blank to inherit) + Drag to rearrange · Travel min estimates arrival from the previous stop (falls back to distance if blank) · Cutoff min overrides route check-in window per stop (leave blank to inherit)
@@ -741,6 +781,17 @@ export default function RoutesPage() { required />
+
+ updateStop(index, 'travelMinutesToStop', e.target.value ? parseInt(e.target.value) : undefined)} + min={1} + title="Travel time in minutes from the previous stop, used to estimate this stop's arrival time" + /> +
)}
+
+ {destinationStationId && ( + setDestinationTravelMinutes(e.target.value ? parseInt(e.target.value) : undefined)} + min={1} + title="Travel time in minutes from the previous stop, used to estimate this stop's arrival time" + /> + )} +
@@ -833,8 +897,10 @@ export default function RoutesPage() { setDestinationStationId(''); setDestinationCheckinMinutes(undefined); setDestinationDistance(undefined); + setDestinationTravelMinutes(undefined); setStops([]); setSearch(''); + setError(null); }} > Cancel diff --git a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx index 0f37825fb..dcaf6261c 100644 --- a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Plus, Loader2, Zap, Trash2, Edit, Search, X, GripVertical } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; @@ -12,6 +12,7 @@ import { routeCoachTemplatesApi } from '@/lib/api'; import Pagination from '@/components/ui/Pagination'; import { usePagination } from '@/lib/use-pagination'; import { formatDateTime } from '@/lib/utils'; +import { eatLocalToISO, isoToEATLocal } from '@/lib/timezone'; import DateTimePicker from '@/components/ui/DateTimePicker'; interface Schedule { @@ -60,8 +61,15 @@ export default function SchedulesPage() { { isOpen: false, item: null } ); const [error, setError] = useState(null); + const errorBannerRef = useRef(null); const queryClient = useQueryClient(); + // These modals can scroll internally — a submit failure can land silently off-screen with no + // visible indication anything went wrong. Scroll the banner into view when a new error appears. + useEffect(() => { + if (error) errorBannerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, [error]); + const [bulkForm, setBulkForm] = useState({ trainId: '', routeId: '', @@ -167,7 +175,8 @@ export default function SchedulesPage() { setError(null); }, onError: (err: any) => { - setError(err.response?.data?.message || 'Failed to generate schedules'); + const msg = err.response?.data?.message || 'Failed to generate schedules'; + setError(Array.isArray(msg) ? msg.join(' ') : msg); }, }); @@ -181,7 +190,8 @@ export default function SchedulesPage() { setError(null); }, onError: (err: any) => { - setError(err.response?.data?.message || 'Failed to create schedule'); + const msg = err.response?.data?.message || 'Failed to create schedule'; + setError(Array.isArray(msg) ? msg.join(' ') : msg); }, }); @@ -195,7 +205,8 @@ export default function SchedulesPage() { setError(null); }, onError: (err: any) => { - setError(err.response?.data?.message || 'Failed to update schedule'); + const msg = err.response?.data?.message || 'Failed to update schedule'; + setError(Array.isArray(msg) ? msg.join(' ') : msg); }, }); @@ -229,20 +240,6 @@ export default function SchedulesPage() { }, }); - /** Parse a datetime-local string ("YYYY-MM-DDTHH:mm") as EAT (UTC+3) and return an ISO string. */ - const eatLocalToISO = (local: string): string => { - if (!local) return ''; - return new Date(local + ':00+03:00').toISOString(); - }; - - /** Convert a UTC ISO string to a datetime-local value in EAT (UTC+3). */ - const isoToEATLocal = (iso: string): string => { - if (!iso) return ''; - const utcMs = new Date(iso).getTime(); - const eatMs = utcMs + 3 * 60 * 60 * 1000; - return new Date(eatMs).toISOString().slice(0, 16); - }; - const handleBulkSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(null); @@ -685,7 +682,7 @@ export default function SchedulesPage() { size="xl" > - {error &&
{error}
} + {error &&
{error}
}
@@ -803,7 +800,7 @@ export default function SchedulesPage() { > {error && ( -
+
{error}
)} @@ -1022,7 +1019,7 @@ export default function SchedulesPage() { {editingSchedule && ( {error && ( -
+
{error}
)} diff --git a/packages/payment-providers/src/providers/waafi/waafi.provider.ts b/packages/payment-providers/src/providers/waafi/waafi.provider.ts index e7d78f752..dd5978021 100644 --- a/packages/payment-providers/src/providers/waafi/waafi.provider.ts +++ b/packages/payment-providers/src/providers/waafi/waafi.provider.ts @@ -135,15 +135,18 @@ export class WaafiProvider implements PaymentProvider, OnModuleInit { // Waafi returns transaction info (params.status) ONLY when responseCode is 2001. For an // unpaid or not-yet-existing transaction it returns an error envelope (e.g. 5001 / E10206 - // "Failed to get transaction info") with no status. Treat that as still-pending (PROCESSING), - // never terminal — so the intent keeps waiting for the webhook / its expiry rather than being - // wrongly resolved off a "no info" response. + // "Failed to get transaction info") with no status — i.e. the payer hasn't done anything at + // the hosted page yet. That's REQUIRES_ACTION (still awaiting the payer), NOT PROCESSING: + // returning PROCESSING here would let the reconciliation sweep persist that guess and block + // the payer from switching providers on a session they never touched (see cac-bank.provider's + // queryStatus for the same convention). The intent still resolves correctly either way — via + // the webhook on a genuine payment, or via expiresAt once the 5-minute HPP session lapses. if (response.responseCode !== WAAFI_SUCCESS_CODE) { this.logger.warn( - `Waafi HPP_GETTRANINFO ${merchantOrderId}: ${response.responseCode}/${response.errorCode} ${response.responseMsg} — treating as pending`, + `Waafi HPP_GETTRANINFO ${merchantOrderId}: ${response.responseCode}/${response.errorCode} ${response.responseMsg} — treating as still awaiting the payer`, ); return { - status: ProviderPaymentStatus.PROCESSING, + status: ProviderPaymentStatus.REQUIRES_ACTION, rawResponse: response as unknown as Record, }; } From 7320c359d1bebce0d64a4bd4743758692d9f77ac Mon Sep 17 00:00:00 2001 From: Muluhabt Date: Fri, 24 Jul 2026 09:01:27 +0300 Subject: [PATCH 16/16] Cut off time configuration --- .../src/common/utils/checkin-cutoff.utils.ts | 45 +++ .../common/utils/segment-resolver.utils.ts | 37 ++ .../src/modules/bookings/bookings.service.ts | 155 ++++---- .../modules/bookings/guest-booking.service.ts | 57 ++- .../notifications/notifications.service.ts | 58 +-- .../src/modules/search/search.service.ts | 30 +- .../src/modules/tickets/tickets.service.ts | 49 +-- .../test/checkin-cutoff.e2e-spec.ts | 21 +- .../stop-based-booking-segment.e2e-spec.ts | 366 ++++++++++++++++++ .../backoffice/src/app/routes/page.tsx | 7 +- 10 files changed, 618 insertions(+), 207 deletions(-) create mode 100644 apps/edr-passenger-api/src/common/utils/checkin-cutoff.utils.ts create mode 100644 apps/edr-passenger-api/src/common/utils/segment-resolver.utils.ts create mode 100644 apps/edr-passenger-api/test/stop-based-booking-segment.e2e-spec.ts diff --git a/apps/edr-passenger-api/src/common/utils/checkin-cutoff.utils.ts b/apps/edr-passenger-api/src/common/utils/checkin-cutoff.utils.ts new file mode 100644 index 000000000..5a0fb50b1 --- /dev/null +++ b/apps/edr-passenger-api/src/common/utils/checkin-cutoff.utils.ts @@ -0,0 +1,45 @@ +/** + * Resolves the booking/check-in cutoff for one boarding stop: stop-level + * `RouteStop.checkinMinutesBefore` override wins, else the route-level default + * (`Route.checkinMinutesBefore`), else a bare 30-minute fallback for routes/stops with + * neither configured. The basis is the stop's own estimated ARRIVAL time (the train reaching + * that stop), not its departure or the schedule's overall origin departure — a downstream + * stop's cutoff must be independent of how long ago the train left its origin. The first stop + * of a route has no arrival (nothing to arrive at), so it falls back to its own departure. + * + * Single source of truth for this computation — SeatsService.holdSeats and + * SearchService.buildScheduleResult already applied it (search results only ever showed a + * segment as bookable if this same cutoff hadn't passed); GuestBookingService.createGuestBooking + * used to independently hardcode a flat, non-configurable 30 minutes off the schedule's origin + * departure, which could reject a booking the search/hold steps had just accepted under the + * route's actual configured cutoff. + */ +export interface CheckinCutoff { + /** The stop's own estimated arrival time (or departure, for the first stop / missing data). */ + segmentTime: Date; + /** Minutes before segmentTime that booking/holding closes. */ + checkinMinutes: number; + /** The moment booking/holding closes for this stop. */ + cutoffAt: Date; +} + +export function resolveCheckinCutoff( + schedule: { + departureAt: Date; + route?: { + checkinMinutesBefore?: number | null; + stops?: Array<{ stationId: string; checkinMinutesBefore: number | null }>; + } | null; + }, + stopTime: { plannedArrivalAt?: Date | null; plannedDepartureAt?: Date | null } | null | undefined, + stationId: string | null | undefined, +): CheckinCutoff { + const segmentTime = stopTime?.plannedArrivalAt ?? stopTime?.plannedDepartureAt ?? schedule.departureAt; + const routeStop = stationId ? schedule.route?.stops?.find((s) => s.stationId === stationId) : undefined; + const checkinMinutes = routeStop?.checkinMinutesBefore ?? schedule.route?.checkinMinutesBefore ?? 30; + return { + segmentTime, + checkinMinutes, + cutoffAt: new Date(segmentTime.getTime() - checkinMinutes * 60_000), + }; +} diff --git a/apps/edr-passenger-api/src/common/utils/segment-resolver.utils.ts b/apps/edr-passenger-api/src/common/utils/segment-resolver.utils.ts new file mode 100644 index 000000000..2fcc6581b --- /dev/null +++ b/apps/edr-passenger-api/src/common/utils/segment-resolver.utils.ts @@ -0,0 +1,37 @@ +/** + * Resolves a booking's actual boarding/alighting station AND time for one leg from + * originStationId/destinationStationId (set when the booking covers only part of a + * longer multi-stop schedule, e.g. train runs A→D but the passenger booked B→D), via + * the schedule's stopTimes — falling back to the schedule's own full-route + * station/time when there's no segment override (older records, or a booking that + * covers the whole run). + * + * Single source of truth for this resolution — station-only lookups used to be + * duplicated ad hoc across bookings/tickets/notifications while the departureAt/ + * arrivalAt kept being read straight off the schedule (the train's full-route span), + * which showed the wrong boarding/alighting time for any stop-based booking. + */ +export interface ResolvedSegment { + origin: any; + destination: any; + departureAt: any; + arrivalAt: any; +} + +export function resolveBookingSegment( + schedule: any, + originStationId: string | null | undefined, + destinationStationId: string | null | undefined, +): ResolvedSegment { + const stopTimes: any[] = schedule?.stopTimes ?? []; + const findStop = (stationId: string | null | undefined) => + stationId && stopTimes.length > 0 ? stopTimes.find((st: any) => st.stationId === stationId) : undefined; + const originStop = findStop(originStationId); + const destStop = findStop(destinationStationId); + return { + origin: originStop?.station ?? schedule?.originStation ?? null, + destination: destStop?.station ?? schedule?.destinationStation ?? null, + departureAt: originStop?.plannedDepartureAt ?? schedule?.departureAt ?? null, + arrivalAt: destStop?.plannedArrivalAt ?? schedule?.arrivalAt ?? null, + }; +} diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index b75f1eeb4..98a54528b 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -14,6 +14,7 @@ import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client'; import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto'; import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception'; import { AuditService } from '../../common/audit.service'; +import { resolveBookingSegment } from '../../common/utils/segment-resolver.utils'; function generateRef(): string { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; @@ -140,7 +141,7 @@ export class BookingsService { take: pageSize, orderBy: { createdAt: 'desc' }, include: { - schedule: { include: { originStation: true, destinationStation: true, train: true } }, + schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, paymentIntent: true, seats: { include: { seat: true } }, priceTier: { select: { priceMinor: true } }, @@ -148,31 +149,34 @@ export class BookingsService { }), this.prisma.booking.count({ where }), ]); - + return { - items: items.map(booking => ({ - id: booking.id, - bookingRef: booking.bookingRef, - status: booking.status, - totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount), - currency: booking.displayCurrency, - displayCurrency: booking.displayCurrency, - displayTotalMinor: booking.displayTotalMinor, - adultCount: booking.adultCount, - childCount: booking.childCount, - bookingType: booking.bookingType, - returnLegStatus: (booking as any).returnLegStatus ?? null, - createdAt: booking.createdAt, - schedule: { - train: booking.schedule.train, - originStation: booking.schedule.originStation, - destinationStation: booking.schedule.destinationStation, - departureAt: booking.schedule.departureAt, - arrivalAt: booking.schedule.arrivalAt, - }, - paymentIntent: booking.paymentIntent, - seatCount: booking.seats.length, - })), + items: items.map(booking => { + const segment = resolveBookingSegment((booking as any).schedule, (booking as any).originStationId, (booking as any).destinationStationId); + return { + id: booking.id, + bookingRef: booking.bookingRef, + status: booking.status, + totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount), + currency: booking.displayCurrency, + displayCurrency: booking.displayCurrency, + displayTotalMinor: booking.displayTotalMinor, + adultCount: booking.adultCount, + childCount: booking.childCount, + bookingType: booking.bookingType, + returnLegStatus: (booking as any).returnLegStatus ?? null, + createdAt: booking.createdAt, + schedule: { + train: booking.schedule.train, + originStation: segment.origin, + destinationStation: segment.destination, + departureAt: segment.departureAt, + arrivalAt: segment.arrivalAt, + }, + paymentIntent: booking.paymentIntent, + seatCount: booking.seats.length, + }; + }), meta: { page, pageSize, @@ -270,7 +274,7 @@ export class BookingsService { take: pageSize, orderBy: { createdAt: 'desc' }, include: { - schedule: { include: { originStation: true, destinationStation: true, train: true } }, + schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, paymentIntent: { select: { method: true, status: true, amountMinor: true, currency: true } }, seats: { select: { id: true } }, priceTier: { select: { priceMinor: true } }, @@ -294,7 +298,9 @@ export class BookingsService { this.prisma.packageBooking.count({ where: pkgWhere }), ]); - const mappedBookings = items.map(booking => ({ + const mappedBookings = items.map(booking => { + const segment = resolveBookingSegment((booking as any).schedule, (booking as any).originStationId, (booking as any).destinationStationId); + return { id: booking.id, bookingRef: booking.bookingRef, status: booking.status, @@ -309,14 +315,15 @@ export class BookingsService { createdAt: booking.createdAt, schedule: { train: booking.schedule.train, - originStation: booking.schedule.originStation, - destinationStation: booking.schedule.destinationStation, - departureAt: booking.schedule.departureAt, - arrivalAt: booking.schedule.arrivalAt, + originStation: segment.origin, + destinationStation: segment.destination, + departureAt: segment.departureAt, + arrivalAt: segment.arrivalAt, }, payment: booking.paymentIntent ?? undefined, seatCount: booking.seats.length, - })); + }; + }); const mappedPkg = pkgItems.map((b: any) => ({ id: b.id, @@ -397,7 +404,7 @@ export class BookingsService { take: pageSize, orderBy: { createdAt: 'desc' }, include: { - schedule: { include: { originStation: true, destinationStation: true, train: true } }, + schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, paymentIntent: true, seats: { include: { seat: true } }, priceTier: { select: { priceMinor: true } }, @@ -405,9 +412,11 @@ export class BookingsService { }), this.prisma.booking.count({ where }), ]); - + return { - items: items.map(booking => ({ + items: items.map(booking => { + const segment = resolveBookingSegment((booking as any).schedule, (booking as any).originStationId, (booking as any).destinationStationId); + return { id: booking.id, bookingRef: booking.bookingRef, status: booking.status, @@ -422,14 +431,15 @@ export class BookingsService { createdAt: booking.createdAt, schedule: { train: booking.schedule.train, - originStation: booking.schedule.originStation, - destinationStation: booking.schedule.destinationStation, - departureAt: booking.schedule.departureAt, - arrivalAt: booking.schedule.arrivalAt, + originStation: segment.origin, + destinationStation: segment.destination, + departureAt: segment.departureAt, + arrivalAt: segment.arrivalAt, }, paymentIntent: booking.paymentIntent, seatCount: booking.seats.length, - })), + }; + }), meta: { page, pageSize, @@ -532,7 +542,7 @@ export class BookingsService { orderBy: { createdAt: 'desc' }, include: { passenger: { select: { id: true, iamUserId: true } }, - schedule: { include: { originStation: true, destinationStation: true, train: true } }, + schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, paymentIntent: true, seats: { include: { seat: true } }, priceTier: { select: { priceMinor: true } }, @@ -554,9 +564,10 @@ export class BookingsService { const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined; const passengerDetails = booking.seats.map((s: any) => ({ name: s.passengerName, category: s.passengerCategory })); const uniquePassengers = Array.from(new Map(passengerDetails.map((p: any) => [p.name, p])).values()); + const segment = booking.schedule ? resolveBookingSegment(booking.schedule, booking.originStationId, booking.destinationStationId) : null; return { id: booking.id, bookingRef: booking.bookingRef, status: booking.status, - totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, booking.priceTier?.priceMinor, booking.adultCount, booking.childCount), + totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, booking.priceTier?.priceMinor, booking.adultCount, booking.childCount), currency: booking.displayCurrency, displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor, contactEmail: booking.contactEmail, contactPhone: booking.contactPhone, @@ -567,11 +578,11 @@ export class BookingsService { passenger: iam ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number } : null, passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))], passengers: uniquePassengers, - schedule: booking.schedule ? { + schedule: segment ? { train: booking.schedule.train, - originStation: booking.schedule.originStation, - destinationStation: booking.schedule.destinationStation, - departureAt: booking.schedule.departureAt, + originStation: segment.origin, + destinationStation: segment.destination, + departureAt: segment.departureAt, } : null, paymentIntent: booking.paymentIntent, seatCount: booking.seats.length, @@ -715,16 +726,15 @@ export class BookingsService { verifaydaVerified: s.verifaydaVerified, seat: s.seat ? { seatNumber: s.seat.seatNumber, coach: { number: s.seat.coach?.number ?? null } } : null, })), - schedule: { - train: booking.schedule.train, - originStation: (booking as any).originStationId - ? ((booking.schedule as any).stopTimes?.find((s: any) => s.stationId === (booking as any).originStationId)?.station ?? booking.schedule.originStation) - : booking.schedule.originStation, - destinationStation: (booking as any).destinationStationId - ? ((booking.schedule as any).stopTimes?.find((s: any) => s.stationId === (booking as any).destinationStationId)?.station ?? booking.schedule.destinationStation) - : booking.schedule.destinationStation, - departureAt: booking.schedule.departureAt, - }, + schedule: (() => { + const segment = resolveBookingSegment(booking.schedule, (booking as any).originStationId, (booking as any).destinationStationId); + return { + train: booking.schedule.train, + originStation: segment.origin, + destinationStation: segment.destination, + departureAt: segment.departureAt, + }; + })(), paymentIntent: booking.paymentIntent, seatCount: booking.seats.length, }; @@ -1850,35 +1860,6 @@ export class BookingsService { ); } - // Resolves the passenger's actual boarding/alighting stations AND times for one leg - // from originStationId/destinationStationId (set when the booking covers only part of - // a longer multi-stop schedule, e.g. train runs A→D but the passenger booked B→D) via - // the schedule's stopTimes, falling back to the schedule's own full-route endpoints/ - // times when there's no segment override (older records, or a booking that covers the - // whole run). Station resolution mirrors notifications.service.ts's - // resolveSegmentStations (already applied to SMS/email); the departureAt/arrivalAt - // resolution mirrors search.service.ts's leg construction (originStop.plannedDepartureAt - // / destStop.plannedArrivalAt) — this brings the booking API (voucher, detail page, - // confirmation) to the same behavior search results already have, instead of always - // showing the train's full-route span. - private resolveSegmentStations( - schedule: any, - originStationId: string | null | undefined, - destinationStationId: string | null | undefined, - ): { origin: any; destination: any; departureAt: any; arrivalAt: any } { - const stopTimes: any[] = schedule?.stopTimes ?? []; - const findStop = (stationId: string | null | undefined) => - stationId && stopTimes.length > 0 ? stopTimes.find((st: any) => st.stationId === stationId) : undefined; - const originStop = findStop(originStationId); - const destStop = findStop(destinationStationId); - return { - origin: originStop?.station ?? schedule?.originStation ?? null, - destination: destStop?.station ?? schedule?.destinationStation ?? null, - departureAt: originStop?.plannedDepartureAt ?? schedule?.departureAt ?? null, - arrivalAt: destStop?.plannedArrivalAt ?? schedule?.arrivalAt ?? null, - }; - } - async getByRef(bookingRefOrId: string) { const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(bookingRefOrId); const booking = await this.prisma.booking.findUnique({ @@ -1987,13 +1968,13 @@ export class BookingsService { if (refreshed) Object.assign(booking, refreshed); } - const outboundSegment = this.resolveSegmentStations( + const outboundSegment = resolveBookingSegment( (booking as any).schedule, (booking as any).originStationId, (booking as any).destinationStationId, ); const returnSegment = (booking as any).returnSchedule - ? this.resolveSegmentStations( + ? resolveBookingSegment( (booking as any).returnSchedule, (booking as any).returnOriginStationId, (booking as any).returnDestinationStationId, diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts index d40824c36..3e2a225e6 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts @@ -8,9 +8,22 @@ import { FareEngineService } from '../fare-engine/fare-engine.service'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { CreateGuestBookingDto, SavedPassengerProfileDto } from './guest-booking.dto'; import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client'; +import { resolveCheckinCutoff } from '../../common/utils/checkin-cutoff.utils'; -/** Booking cutoff: reject new bookings within this many ms of departure. */ -const BOOKING_CUTOFF_MS = 30 * 60 * 1000; +/** + * Throws if the given boarding stop's own configurable check-in cutoff (route/stop + * checkinMinutesBefore, same mechanism the seat hold and search results already enforce) has + * passed. Must be checked against the actual boarding stop, not the schedule's origin — a + * downstream stop's cutoff is independent of how long ago the train left its origin. + */ +function assertWithinCheckinCutoff(schedule: any, stopTime: any, stationId: string | null | undefined): void { + const { cutoffAt, checkinMinutes } = resolveCheckinCutoff(schedule, stopTime, stationId); + if (Date.now() >= cutoffAt.getTime()) { + throw new BadRequestException( + `Bookings are not accepted within ${checkinMinutes} minute${checkinMinutes !== 1 ? 's' : ''} of departure`, + ); + } +} function generateRef(): string { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; @@ -107,20 +120,22 @@ export class GuestBookingService { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, + route: { include: { stops: true } }, }, }); if (!schedule) throw new NotFoundException('Schedule not found'); - if (Date.now() >= schedule.departureAt.getTime() - BOOKING_CUTOFF_MS) { - throw new BadRequestException('Bookings are not accepted within 30 minutes of departure'); - } - const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId) ?? (schedule.stopTimes.length === 0 ? { stationId: schedule.originStationId, sequence: 0, station: schedule.originStation } : undefined); const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId) ?? (schedule.stopTimes.length === 0 ? { stationId: schedule.destinationStationId, sequence: 1, station: schedule.destinationStation } : undefined); if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found'); + // Cut off relative to the passenger's actual boarding stop, using the same + // configurable per-stop/route checkinMinutesBefore that already gated the seat hold + // and the search result — not a separate, hardcoded 30 minutes off the train's origin. + assertWithinCheckinCutoff(schedule, originStop, dto.originStationId); + const segmentRoute = `${originStop.station.code}-${destStop.station.code}`; const fullRoute = `${schedule.originStation.code}-${schedule.destinationStation.code}`; @@ -391,7 +406,7 @@ export class GuestBookingService { const [outboundSchedule, returnSchedule] = await Promise.all([ this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, - include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } }, + include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, route: { include: { stops: true } } }, }), this.prisma.trainSchedule.findUnique({ where: { id: dto.returnScheduleId }, @@ -401,10 +416,6 @@ export class GuestBookingService { if (!outboundSchedule) throw new NotFoundException('Outbound schedule not found'); if (!returnSchedule) throw new NotFoundException('Return schedule not found'); - if (Date.now() >= outboundSchedule.departureAt.getTime() - BOOKING_CUTOFF_MS) { - throw new BadRequestException('Bookings are not accepted within 30 minutes of departure'); - } - const synth = (sched: any, stationId: string, seq: number) => { const station = sched.originStationId === stationId ? sched.originStation : sched.destinationStation; return { stationId, sequence: seq, station }; @@ -418,6 +429,10 @@ export class GuestBookingService { if (!outboundOriginStop || !outboundDestStop) throw new NotFoundException('Outbound origin or destination not found on schedule'); if (!returnOriginStop || !returnDestStop) throw new NotFoundException('Return origin or destination not found on schedule'); + // Cut off relative to the passenger's actual boarding stop, using the same configurable + // per-stop/route checkinMinutesBefore that already gated the seat hold and search result. + assertWithinCheckinCutoff(outboundSchedule, outboundOriginStop, dto.originStationId); + const outboundSegmentRoute = `${outboundOriginStop.station.code}-${outboundDestStop.station.code}`; const outboundFullRoute = `${outboundSchedule.originStation.code}-${outboundSchedule.destinationStation.code}`; const returnSegmentRoute = `${returnOriginStop.station.code}-${returnDestStop.station.code}`; @@ -690,7 +705,7 @@ export class GuestBookingService { const [leg1Schedule, leg2Schedule] = await Promise.all([ this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, - include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } }, + include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, route: { include: { stops: true } } }, }), this.prisma.trainSchedule.findUnique({ where: { id: dto.leg2ScheduleId }, @@ -700,10 +715,6 @@ export class GuestBookingService { if (!leg1Schedule) throw new NotFoundException('Leg-1 schedule not found'); if (!leg2Schedule) throw new NotFoundException('Leg-2 schedule not found'); - if (Date.now() >= leg1Schedule.departureAt.getTime() - BOOKING_CUTOFF_MS) { - throw new BadRequestException('Bookings are not accepted within 30 minutes of departure'); - } - const leg1OriginStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.originStationId); const leg1DestStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.transitStationId); const leg2OriginStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.transitStationId); @@ -711,6 +722,10 @@ export class GuestBookingService { if (!leg1OriginStop || !leg1DestStop) throw new NotFoundException('Leg-1 origin or transit station not found on schedule'); if (!leg2OriginStop || !leg2DestStop) throw new NotFoundException('Transit or leg-2 destination not found on leg-2 schedule'); + // Cut off relative to the passenger's actual boarding stop, using the same configurable + // per-stop/route checkinMinutesBefore that already gated the seat hold and search result. + assertWithinCheckinCutoff(leg1Schedule, leg1OriginStop, dto.originStationId); + // Process passengers (verify identity once) const passengersData: any[] = []; let adultCount = 0, childCount = 0; @@ -894,7 +909,7 @@ export class GuestBookingService { if (!retL2Hold || retL2Hold.expiresAt < now) throw new BadRequestException('Return leg-2 hold expired'); const [obL1Sched, obL2Sched, retL1Sched, retL2Sched] = await Promise.all([ - this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }), + this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, route: { include: { stops: true } } } }), this.prisma.trainSchedule.findUnique({ where: { id: dto.leg2ScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }), this.prisma.trainSchedule.findUnique({ where: { id: dto.returnScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }), this.prisma.trainSchedule.findUnique({ where: { id: dto.returnLeg2ScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }), @@ -904,10 +919,6 @@ export class GuestBookingService { if (!retL1Sched) throw new NotFoundException('Return leg-1 schedule not found'); if (!retL2Sched) throw new NotFoundException('Return leg-2 schedule not found'); - if (Date.now() >= obL1Sched.departureAt.getTime() - BOOKING_CUTOFF_MS) { - throw new BadRequestException('Bookings are not accepted within 30 minutes of departure'); - } - const obL1Origin = obL1Sched.stopTimes.find(s => s.stationId === dto.originStationId); const obL1Dest = obL1Sched.stopTimes.find(s => s.stationId === dto.transitStationId); const obL2Origin = obL2Sched.stopTimes.find(s => s.stationId === dto.transitStationId); @@ -921,6 +932,10 @@ export class GuestBookingService { if (!retL1Origin || !retL1Dest) throw new NotFoundException('Return leg-1: origin or transit stop not found'); if (!retL2Origin || !retL2Dest) throw new NotFoundException('Return leg-2: transit or destination stop not found'); + // Cut off relative to the passenger's actual boarding stop, using the same configurable + // per-stop/route checkinMinutesBefore that already gated the seat hold and search result. + assertWithinCheckinCutoff(obL1Sched, obL1Origin, dto.originStationId); + // Process passengers (verify once) const passengersData: any[] = []; let adultCount = 0, childCount = 0; diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts index a1678d131..12f549e9f 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts @@ -7,6 +7,7 @@ import { PushAdapter, NotificationChannel } from './notification.adapters'; import { EmailClientService } from './email-client.service'; import { SmsClientService } from './sms-client.service'; import { CreateTemplateDto, UpdateTemplateDto } from './notifications.dto'; +import { resolveBookingSegment } from '../../common/utils/segment-resolver.utils'; export type NotificationChannelType = 'EMAIL' | 'SMS' | 'PUSH' | 'IN_APP'; @@ -353,27 +354,6 @@ export class NotificationsService { } } - /** - * Resolves the user's actual boarding/alighting stations from the booking's originStationId / - * destinationStationId via stopTimes, falling back to the schedule's full-route endpoints when - * the booking has no segment override (e.g. older records or packages). - */ - private resolveSegmentStations(booking: any): { originStation: any; destinationStation: any } { - const s = booking?.schedule ?? {}; - const stopTimes: any[] = s.stopTimes ?? []; - const findStation = (stationId: string | null | undefined, fallback: any) => { - if (stationId && stopTimes.length > 0) { - const stop = stopTimes.find((st: any) => st.stationId === stationId); - if (stop?.station) return stop.station; - } - return fallback ?? null; - }; - return { - originStation: findStation(booking?.originStationId, s.originStation), - destinationStation: findStation(booking?.destinationStationId, s.destinationStation), - }; - } - /** * Builds the interpolation context for the `booking.created` template. `trainSeatLines` is a * pre-joined block of one "Train/Seat: …" line per booked seat (multi-passenger bookings get @@ -400,17 +380,17 @@ export class NotificationsService { // Lead passenger (leg-1 seat). Booking has no contactName; the traveller name lives on the seat. const passengerName = seats[0]?.passengerName ?? 'Passenger'; const payLink = `${process.env.PORTAL_URL ?? 'http://localhost:5174'}/booking/detail?ref=${ref}`; - const { originStation: originSt, destinationStation: destSt } = this.resolveSegmentStations(booking); + const segment = resolveBookingSegment(s, booking?.originStationId, booking?.destinationStationId); return { passengerName, bookingRef: ref, - origin: originSt?.name ?? '', - destination: destSt?.name ?? '', + origin: segment.origin?.name ?? '', + destination: segment.destination?.name ?? '', trainSeatLines, - travelDate: fmtDate(s.departureAt), - departureTime: fmtTime(s.departureAt), - arrivalTime: fmtTime(s.arrivalAt), + travelDate: fmtDate(segment.departureAt), + departureTime: fmtTime(segment.departureAt), + arrivalTime: fmtTime(segment.arrivalAt), payLink, }; } @@ -509,12 +489,12 @@ export class NotificationsService { private buildTicketEmailText(booking: any, amount: string, currency: string, url: string): string { const s = booking.schedule ?? {}; - const dep = s.departureAt ? new Date(s.departureAt).toLocaleString('en-GB') : 'TBD'; + const segment = resolveBookingSegment(s, booking?.originStationId, booking?.destinationStationId); + const dep = segment.departureAt ? new Date(segment.departureAt).toLocaleString('en-GB') : 'TBD'; const passengers = (booking.seats ?? []).map((bs: any) => bs.passengerName).filter(Boolean).join(', '); - const { originStation: originSt, destinationStation: destSt } = this.resolveSegmentStations(booking); return [ `Booking ${booking.bookingRef} confirmed.`, - `${originSt?.name ?? ''} -> ${destSt?.name ?? ''}`, + `${segment.origin?.name ?? ''} -> ${segment.destination?.name ?? ''}`, `Train: ${s.train?.name ?? s.train?.number ?? ''}`, `Departs: ${dep}`, passengers ? `Passengers: ${passengers}` : '', @@ -527,7 +507,9 @@ export class NotificationsService { const s = booking.schedule ?? {}; const fmt = (d: any) => d ? new Date(d).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' }) : 'TBD'; - const { originStation: originSt, destinationStation: destSt } = this.resolveSegmentStations(booking); + const segment = resolveBookingSegment(s, booking?.originStationId, booking?.destinationStationId); + const originSt = segment.origin; + const destSt = segment.destination; const seatRows = (booking.seats ?? []) .map((bs: any) => { const coach = bs.seat?.coach?.number ?? '-'; @@ -568,11 +550,11 @@ export class NotificationsService { Departs - ${fmt(s.departureAt)} + ${fmt(segment.departureAt)} Arrives - ${fmt(s.arrivalAt)} + ${fmt(segment.arrivalAt)} @@ -636,12 +618,12 @@ export class NotificationsService { const fmt = (d: any) => d ? new Date(d).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' }) : 'TBD'; const legLabel = leg ? ` (${leg.replace(/_/g, ' ')})` : ''; - const { originStation: originSt, destinationStation: destSt } = this.resolveSegmentStations(booking); - const origin = originSt?.name ?? ''; - const dest = destSt?.name ?? ''; + const segment = resolveBookingSegment(s, booking?.originStationId, booking?.destinationStationId); + const origin = segment.origin?.name ?? ''; + const dest = segment.destination?.name ?? ''; const train = s.train?.name ?? s.train?.number ?? ''; - const dep = fmt(s.departureAt); - const arr = fmt(s.arrivalAt); + const dep = fmt(segment.departureAt); + const arr = fmt(segment.arrivalAt); const seats: { name: string; coach: string; seat: string; cls: string }[] = (booking.seats ?? []).map((bs: any) => ({ name: bs.passengerName ?? '', diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index 78fbcb955..0c905acdd 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -10,6 +10,7 @@ import { CurrencyService } from "../currency/currency.service"; import { FareEngineService } from "../fare-engine/fare-engine.service"; import { SegmentsService } from "../segments/segments.service"; import { resolveCurrencyFromNationality } from "../fare-engine/fare-engine.dto"; +import { resolveCheckinCutoff } from "../../common/utils/checkin-cutoff.utils"; import { Currency, Prisma } from "@prisma/client"; const POINTS_TO_MINOR = 10; @@ -554,23 +555,11 @@ export class SearchService { // Segment-level cutoff: use the origin stop's own estimated arrival time, not the // schedule's overall departureAt (which is station A's time). This lets - // B→D remain bookable even after A→D closes. The first stop has no arrival (nothing - // to arrive at), so it falls back to its own departure. - // Cutoff resolution: stop-level override → route default → 30 min fallback. - const now = new Date(); - const segmentDepartureAt = - originStop.plannedArrivalAt ?? originStop.plannedDepartureAt ?? schedule.departureAt; - const routeStop = schedule.route?.stops?.find( - (s) => s.stationId === originStationId, - ); - const checkinMinutes = - routeStop?.checkinMinutesBefore ?? - schedule.route?.checkinMinutesBefore ?? - 30; - if ( - segmentDepartureAt.getTime() - now.getTime() <= - checkinMinutes * 60 * 1000 - ) + // B→D remain bookable even after A→D closes. Stop-level checkinMinutesBefore override → + // route default → 30 min fallback — same resolution GuestBookingService applies at + // booking-creation time, so a segment shown as bookable here stays bookable through + // checkout instead of being rejected against a different, hardcoded cutoff. + if (Date.now() >= resolveCheckinCutoff(schedule, originStop, originStationId).cutoffAt.getTime()) return null; // Collect all valid seat IDs upfront for a single batch availability check @@ -682,8 +671,11 @@ export class SearchService { nationality, availabilityByClass, ); - const legDepartureAt = schedule.departureAt; - const legArrivalAt = schedule.arrivalAt; + // Use the selected stop's own planned time, not the schedule's full-route span — + // for stop-based (mid-route) boarding/alighting these differ from the train's + // overall origin departure / final destination arrival. + const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt; + const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt; const displayCurrency = faresByClass[0]?.displayCurrency ?? diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts index 736b781bd..684c65880 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -5,6 +5,7 @@ import { PrismaService } from '../../common/prisma.service'; import { NotificationsService } from '../notifications/notifications.service'; import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service'; import { AuditService } from '../../common/audit.service'; +import { resolveBookingSegment } from '../../common/utils/segment-resolver.utils'; import * as QRCode from 'qrcode'; interface OfflineValidation { @@ -129,6 +130,7 @@ export class TicketsService { : { fullName: 'Guest', email: guestEmail, phone: guestPhone }; + const segment = resolveBookingSegment(t.booking?.schedule, t.booking?.originStationId, t.booking?.destinationStationId); return { id: t.id, ticketNumber: t.barcodePayload, @@ -152,20 +154,14 @@ export class TicketsService { contactPhone: t.booking?.contactPhone, returnSchedule: t.booking?.returnSchedule ?? null, seats: t.booking?.seats ?? [], - originStation: (() => { - const id = t.booking?.originStationId; - if (!id) return t.booking?.schedule?.originStation ?? null; - const stop = t.booking?.schedule?.stopTimes?.find((st: any) => st.stationId === id); - return stop?.station ?? t.booking?.schedule?.originStation ?? null; - })(), - destinationStation: (() => { - const id = t.booking?.destinationStationId; - if (!id) return t.booking?.schedule?.destinationStation ?? null; - const stop = t.booking?.schedule?.stopTimes?.find((st: any) => st.stationId === id); - return stop?.station ?? t.booking?.schedule?.destinationStation ?? null; - })(), + originStation: segment.origin, + destinationStation: segment.destination, }, - schedule: t.booking?.schedule, + schedule: t.booking?.schedule ? { + ...t.booking.schedule, + departureAt: segment.departureAt, + arrivalAt: segment.arrivalAt, + } : null, seat: t.seat ? { id: t.seat.id, seatNumber: t.seat.seatNumber, @@ -643,11 +639,14 @@ export class TicketsService { throw new NotFoundException('No ticket found for this booking'); } - // Check if ticket date matches today + // Check if ticket date matches today. Boarding window is relative to the + // passenger's actual boarding stop, not the train's origin — for a mid-route + // boarding these differ. const today = new Date(); - - if ((booking as any).schedule?.departureAt) { - const departureTime = new Date((booking as any).schedule.departureAt); + const boardingSegment = resolveBookingSegment((booking as any).schedule, (booking as any).originStationId, (booking as any).destinationStationId); + + if (boardingSegment.departureAt) { + const departureTime = new Date(boardingSegment.departureAt); const boardingWindowHours = await this.systemConfig.getNumber(CONFIG_KEYS.BOARDING_WINDOW_HOURS_BEFORE_DEPARTURE); const boardingOpenTime = new Date(departureTime.getTime() - boardingWindowHours * 60 * 60 * 1000); @@ -673,18 +672,6 @@ export class TicketsService { // Send notifications after successful boarding await this.sendBoardingNotifications(booking, ticket, result.leg || 'OUTBOUND'); - // Resolve user-selected segment rather than the full schedule route - const _schedStops = (booking as any).schedule?.stopTimes ?? []; - const _resolveStation = (id: string | null | undefined, fallback: any) => { - if (id) { - const found = _schedStops.find((st: any) => st.stationId === id)?.station; - if (found) return found; - } - return fallback; - }; - const boardingOrigin = _resolveStation((booking as any).originStationId, (booking as any).schedule?.originStation); - const boardingDest = _resolveStation((booking as any).destinationStationId, (booking as any).schedule?.destinationStation); - return { success: true, message: `Passenger boarded successfully (${result.leg || 'OUTBOUND'} leg)`, @@ -693,11 +680,11 @@ export class TicketsService { ticketNumber: ticket.barcodePayload, bookingRef: booking.bookingRef, passengerName: seatInfo?.passengerName || ticket.passengerName || 'N/A', - route: `${boardingOrigin?.name || 'N/A'} → ${boardingDest?.name || 'N/A'}`, + route: `${boardingSegment.origin?.name || 'N/A'} → ${boardingSegment.destination?.name || 'N/A'}`, seat: seatNumber, coach: coachNumber, trainName: (booking as any).schedule?.train?.name || (booking as any).schedule?.train?.number || 'N/A', - departureTime: (booking as any).schedule?.departureAt, + departureTime: boardingSegment.departureAt, boardedAt: result.validatedAt, leg: result.leg || 'OUTBOUND', bookingType: booking.bookingType, diff --git a/apps/edr-passenger-api/test/checkin-cutoff.e2e-spec.ts b/apps/edr-passenger-api/test/checkin-cutoff.e2e-spec.ts index 8cbc7d8f8..93c0386b6 100644 --- a/apps/edr-passenger-api/test/checkin-cutoff.e2e-spec.ts +++ b/apps/edr-passenger-api/test/checkin-cutoff.e2e-spec.ts @@ -33,20 +33,13 @@ async function createTestSchedule( const train = await harness.prisma.train.create({ data: { number: opts.trainNumber, name: `Test ${opts.trainNumber}` }, }); - const schedule = await schedules.createSchedule({ - trainId: train.id, - routeId: IDS.route, - departureAt: opts.departureAt.toISOString(), - arrivalAt: opts.arrivalAt.toISOString(), - } as any); - // Give the schedule a bookable coach so holdSeats has real seats to work with. + // createSchedule now rejects a schedule with zero coaches (see schedules.service.ts's + // "must have at least one coach assigned" guard) — the coach has to exist and be passed + // via coachIds BEFORE creation, not attached afterward. const coach = await harness.prisma.coach.create({ data: { coachTypeId: IDS.coachType, number: `${opts.trainNumber}-C1`, capacity: 4, sequence: 1, status: "ACTIVE" }, }); - await harness.prisma.coachAssignment.create({ - data: { scheduleId: schedule.id, coachId: coach.id, positionNumber: 1, isOperational: true }, - }); const seats = await Promise.all( ["1A", "1B", "1C", "1D"].map((seatNumber, i) => harness.prisma.seat.create({ @@ -55,6 +48,14 @@ async function createTestSchedule( ), ); + const schedule = await schedules.createSchedule({ + trainId: train.id, + routeId: IDS.route, + departureAt: opts.departureAt.toISOString(), + arrivalAt: opts.arrivalAt.toISOString(), + coachIds: [coach.id], + } as any); + return { schedule, seats }; } diff --git a/apps/edr-passenger-api/test/stop-based-booking-segment.e2e-spec.ts b/apps/edr-passenger-api/test/stop-based-booking-segment.e2e-spec.ts new file mode 100644 index 000000000..dfcc298fd --- /dev/null +++ b/apps/edr-passenger-api/test/stop-based-booking-segment.e2e-spec.ts @@ -0,0 +1,366 @@ +/** + * Stop-based (mid-route) booking — segment correctness suite. + * + * Regression coverage for three bugs reported against live stop-based bookings: + * + * 1. Search results (SearchService.buildScheduleResult) showed the train's overall + * departure/arrival instead of the selected origin/destination stop's own time — e.g. + * searching B→C on a A→B→C schedule showed A's departure time, not B's. Rooted in + * resolving the boarding/alighting STATION correctly for a mid-route segment while still + * reading TIME off the schedule's full-route span. Fixed via resolveBookingSegment() (also + * used by BookingsService, TicketsService, NotificationsService) — see + * src/common/utils/segment-resolver.utils.ts. + * 2. GuestBookingService's 30-minute booking cutoff was computed off the train's origin + * departure regardless of where the passenger actually boards, so a schedule whose origin + * had already departed >30min ago wrongly blocked booking a downstream segment that + * hadn't closed yet. + * 3. Even after (2), GuestBookingService still enforced a hardcoded, non-configurable 30 + * minutes — ignoring RouteStop/Route.checkinMinutesBefore, the SAME configurable cutoff + * that SeatsService.holdSeats and the search step already enforce. A passenger who passed + * the earlier steps under a shorter (or longer) CONFIGURED cutoff could still be wrongly + * rejected — or wrongly allowed — at /booking/review with "not accepted within 30 minutes + * of departure". Fixed by having GuestBookingService use the same resolveCheckinCutoff() + * utility as SeatsService.holdSeats and SearchService — see + * src/common/utils/checkin-cutoff.utils.ts. + * + * Uses the slim harness (real Nest DI) for SchedulesService — this exercises the actual + * cumulative travel-time interpolation in SchedulesService.createSchedule, same as + * checkin-cutoff.e2e-spec.ts. SeatsService/SearchService/BookingsService/GuestBookingService + * are NOT in the slim harness's DOMAIN_MODULES (they pull in NotificationsModule → RabbitMQ), + * so they're instantiated directly with a real Prisma + stubbed collaborators, mirroring the + * Tier-2 pattern in money-integrity.e2e-spec.ts. + */ +import { IdDocumentType } from "@prisma/client"; +import { SchedulesService } from "../src/modules/schedules/schedules.service"; +import { SeatsService } from "../src/modules/seats/seats.service"; +import { SegmentsService } from "../src/modules/segments/segments.service"; +import { SearchService } from "../src/modules/search/search.service"; +import { CurrencyService } from "../src/modules/currency/currency.service"; +import { FareEngineService } from "../src/modules/fare-engine/fare-engine.service"; +import { SystemConfigService } from "../src/modules/system-config/system-config.service"; +import { BookingsService } from "../src/modules/bookings/bookings.service"; +import { GuestBookingService } from "../src/modules/bookings/guest-booking.service"; +import { createServiceHarness, ServiceHarness } from "./setup/slim-app"; +import { IDS, resetAndSeedCore } from "./fixtures/seed-core"; + +/** A Proxy whose every property is an async no-op — satisfies unused collaborator method calls. */ +function asyncStub(): any { + return new Proxy({}, { get: () => async () => undefined }); +} + +/** Formats a Date as a YYYY-MM-DD string in the process's local timezone (EAT on this host — + * matches search.service.ts's "+03:00" date-matching window). */ +function localDateStr(d: Date): string { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; +} + +describe("Stop-based booking — segment time & cutoff correctness", () => { + let harness: ServiceHarness; + let schedulesService: SchedulesService; + let seatsService: SeatsService; + let searchService: SearchService; + let bookingsService: BookingsService; + let guestBookingService: GuestBookingService; + + beforeAll(async () => { + harness = await createServiceHarness(); + schedulesService = await harness.moduleRef.resolve(SchedulesService); + const currencyService = harness.moduleRef.get(CurrencyService); + const fareEngine = harness.moduleRef.get(FareEngineService); + const segmentsService = new SegmentsService(harness.prisma as any); + const systemConfig = new SystemConfigService(harness.prisma as any); + + searchService = new SearchService(harness.prisma as any, currencyService, fareEngine, segmentsService); + // holdSeats() itself never touches segmentsService (only getSeatMap/availability-map + // callers do), so stubbing it here is safe — mirrors checkin-cutoff.e2e-spec.ts. + seatsService = new SeatsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub()); + bookingsService = new BookingsService( + harness.prisma as any, + asyncStub(), // dataSource + seatsService, + { emit: () => true } as any, // eventEmitter + asyncStub(), // verifaydaService + currencyService, + fareEngine, + asyncStub(), // auditService + ); + guestBookingService = new GuestBookingService( + harness.prisma as any, + seatsService, + asyncStub(), // verifaydaService — never reached: test passengers use PASSPORT, not NATIONAL_ID + currencyService, + asyncStub(), // passengerAuthService — never reached: no createAccount in these DTOs + fareEngine, + { emit: () => true } as any, // eventEmitter + ); + }); + + afterAll(async () => { + await harness?.close(); + }); + + /** Creates a fresh Train + TrainSchedule on the seed-core route, coach assigned at creation + * (createSchedule now rejects a schedule with zero coaches). */ + async function createTestSchedule(opts: { trainNumber: string; departureAt: Date; arrivalAt: Date }) { + const train = await harness.prisma.train.create({ + data: { number: opts.trainNumber, name: `Test ${opts.trainNumber}` }, + }); + const coach = await harness.prisma.coach.create({ + data: { coachTypeId: IDS.coachType, number: `${opts.trainNumber}-C1`, capacity: 4, sequence: 1, status: "ACTIVE" }, + }); + const seats = await Promise.all( + ["1A", "1B", "1C", "1D"].map((seatNumber, i) => + harness.prisma.seat.create({ + data: { coachId: coach.id, seatNumber, row: 1, col: seatNumber.slice(-1), isWindow: i === 0, isAisle: i === 1 }, + }), + ), + ); + const schedule = await schedulesService.createSchedule({ + trainId: train.id, + routeId: IDS.route, + departureAt: opts.departureAt.toISOString(), + arrivalAt: opts.arrivalAt.toISOString(), + coachIds: [coach.id], + } as any); + return { schedule, seats }; + } + + function foreignPassenger(seatId: string) { + return { + seatId, + passengerName: "Test Passenger", + dateOfBirth: "1990-01-01", + idDocumentType: IdDocumentType.PASSPORT, + passportNumber: "X123456", + passportCountry: "Djibouti", + nationality: "Djiboutian", + }; + } + + describe("search results (SearchService.searchTrips)", () => { + it("shows the boarding stop's own departure time, not the schedule's full-route (station A) departure", async () => { + await resetAndSeedCore(harness.prisma); + await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 60 } }); + await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 } }); + + const dep = new Date(Date.now() + 3 * 60 * 60_000); // A's departure, 3h out + const arr = new Date(dep.getTime() + 100 * 60_000); // C's arrival + const { schedule } = await createTestSchedule({ trainNumber: `SEG-DEP-${Date.now()}`, departureAt: dep, arrivalAt: arr }); + + const result: any = await searchService.searchTrips({ + originStationId: IDS.stationB, + destinationStationId: IDS.stationC, + date: localDateStr(dep), + adultCount: 1, + } as any); + + const found = result.outbound.find((o: any) => o.scheduleId === schedule.id); + expect(found).toBeTruthy(); + + const expectedBDeparture = new Date(dep.getTime() + 60 * 60_000); + expect(new Date(found.departureAt).getTime()).toBe(expectedBDeparture.getTime()); + // Would equal A's departure (`dep`) under the old (buggy) schedule.departureAt fallback. + expect(new Date(found.departureAt).getTime()).not.toBe(dep.getTime()); + }); + + it("shows the alighting stop's own arrival time, not the schedule's full-route (station C) arrival", async () => { + await resetAndSeedCore(harness.prisma); + await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 60 } }); + await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 } }); + + const dep = new Date(Date.now() + 3 * 60 * 60_000); + const arr = new Date(dep.getTime() + 100 * 60_000); // C's arrival + const { schedule } = await createTestSchedule({ trainNumber: `SEG-ARR-${Date.now()}`, departureAt: dep, arrivalAt: arr }); + + const result: any = await searchService.searchTrips({ + originStationId: IDS.stationA, + destinationStationId: IDS.stationB, + date: localDateStr(dep), + adultCount: 1, + } as any); + + const found = result.outbound.find((o: any) => o.scheduleId === schedule.id); + expect(found).toBeTruthy(); + + const expectedBArrival = new Date(dep.getTime() + 60 * 60_000); + expect(new Date(found.arrivalAt).getTime()).toBe(expectedBArrival.getTime()); + // Would equal C's arrival (`arr`) under the old (buggy) schedule.arrivalAt fallback. + expect(new Date(found.arrivalAt).getTime()).not.toBe(arr.getTime()); + }); + }); + + describe("guest booking cutoff (GuestBookingService.createGuestBooking)", () => { + it("does NOT block booking a downstream segment whose own boarding stop is still far out, even though the schedule's origin already departed", async () => { + await resetAndSeedCore(harness.prisma); + await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 150 } }); + await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 } }); + + // A departs in 5min (already inside a naive 30-min-before-departure cutoff), but B — the + // passenger's actual boarding stop — is A+150min out (~2.5h), comfortably clear. + const dep = new Date(Date.now() + 5 * 60_000); + const arr = new Date(dep.getTime() + 190 * 60_000); + const { schedule, seats } = await createTestSchedule({ trainNumber: `CUTOFF-OK-${Date.now()}`, departureAt: dep, arrivalAt: arr }); + + const hold = await seatsService.holdSeats({ + scheduleId: schedule.id, + originStationId: IDS.stationB, + destinationStationId: IDS.stationC, + passengers: [{ passengerId: "66666666-6666-4666-8666-666666666666", seatId: seats[0].id }], + } as any); + + const booking: any = await guestBookingService.createGuestBooking({ + scheduleId: schedule.id, + holdId: (hold as any).holdId, + originStationId: IDS.stationB, + destinationStationId: IDS.stationC, + seatClassId: IDS.seatClassLocal, + passengers: [foreignPassenger(seats[0].id)], + } as any); + + expect(booking.bookingRef).toBeTruthy(); + expect(booking.originStationId).toBe(IDS.stationB); + expect(booking.destinationStationId).toBe(IDS.stationC); + }); + + it("still blocks booking when the passenger's own boarding stop is itself within 30 minutes of its departure", async () => { + await resetAndSeedCore(harness.prisma); + await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 10 } }); + await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 } }); + + // B departs at dep+10min (~15min from now) — inside the 30-min cutoff. Hold is created + // directly (bypassing SeatsService.holdSeats' own, separately-tested arrival-based + // cutoff — see checkin-cutoff.e2e-spec.ts) to isolate GuestBookingService's own check. + const dep = new Date(Date.now() + 5 * 60_000); + const arr = new Date(dep.getTime() + 50 * 60_000); + const { schedule, seats } = await createTestSchedule({ trainNumber: `CUTOFF-BLOCK-${Date.now()}`, departureAt: dep, arrivalAt: arr }); + + const hold = await harness.prisma.seatHold.create({ + data: { + scheduleId: schedule.id, + seatIds: [seats[0].id], + passengerId: "77777777-7777-4777-8777-777777777777", + expiresAt: new Date(Date.now() + 10 * 60_000), + }, + }); + + await expect( + guestBookingService.createGuestBooking({ + scheduleId: schedule.id, + holdId: hold.id, + originStationId: IDS.stationB, + destinationStationId: IDS.stationC, + seatClassId: IDS.seatClassLocal, + passengers: [foreignPassenger(seats[0].id)], + } as any), + ).rejects.toThrow(/not accepted within 30 minutes/i); + }); + + it("honors a stop-level checkinMinutesBefore override SHORTER than 30 minutes — booking succeeds inside the old hardcoded window", async () => { + // Regression for the reported bug: /booking/review still rejected a booking with + // "not accepted within 30 minutes of departure" even after the passenger passed the + // earlier steps under a shorter CONFIGURED cutoff — because createGuestBooking used to + // enforce its own separate, hardcoded 30 minutes regardless of RouteStop/Route + // .checkinMinutesBefore. B's own configured cutoff here is 10 minutes. + await resetAndSeedCore(harness.prisma, { B: { checkinMinutesBefore: 10 } }); + await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 20 } }); + await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 } }); + + // B departs at dep+20min (~25min from now) — inside the OLD hardcoded 30-min cutoff, + // but outside B's own configured 10-min cutoff. + const dep = new Date(Date.now() + 5 * 60_000); + const arr = new Date(dep.getTime() + 60 * 60_000); + const { schedule, seats } = await createTestSchedule({ trainNumber: `CUTOFF-CFG-${Date.now()}`, departureAt: dep, arrivalAt: arr }); + + const hold = await seatsService.holdSeats({ + scheduleId: schedule.id, + originStationId: IDS.stationB, + destinationStationId: IDS.stationC, + passengers: [{ passengerId: "88888888-8888-4888-8888-888888888888", seatId: seats[0].id }], + } as any); + + const booking: any = await guestBookingService.createGuestBooking({ + scheduleId: schedule.id, + holdId: (hold as any).holdId, + originStationId: IDS.stationB, + destinationStationId: IDS.stationC, + seatClassId: IDS.seatClassLocal, + passengers: [foreignPassenger(seats[0].id)], + } as any); + + expect(booking.bookingRef).toBeTruthy(); + }); + + it("honors a stop-level checkinMinutesBefore override LONGER than 30 minutes — still blocks past the old hardcoded window", async () => { + await resetAndSeedCore(harness.prisma, { B: { checkinMinutesBefore: 90 } }); + await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 40 } }); + await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 } }); + + // B departs at dep+40min (~45min from now) — outside the OLD hardcoded 30-min cutoff + // (would have wrongly been allowed), but inside B's own configured 90-min cutoff. + const dep = new Date(Date.now() + 5 * 60_000); + const arr = new Date(dep.getTime() + 80 * 60_000); + const { schedule, seats } = await createTestSchedule({ trainNumber: `CUTOFF-CFG2-${Date.now()}`, departureAt: dep, arrivalAt: arr }); + + const hold = await harness.prisma.seatHold.create({ + data: { + scheduleId: schedule.id, + seatIds: [seats[0].id], + passengerId: "99999999-9999-4999-8999-999999999999", + expiresAt: new Date(Date.now() + 10 * 60_000), + }, + }); + + await expect( + guestBookingService.createGuestBooking({ + scheduleId: schedule.id, + holdId: hold.id, + originStationId: IDS.stationB, + destinationStationId: IDS.stationC, + seatClassId: IDS.seatClassLocal, + passengers: [foreignPassenger(seats[0].id)], + } as any), + ).rejects.toThrow(/not accepted within 90 minutes/i); + }); + }); + + describe("booking detail & list segment resolution (BookingsService)", () => { + it("getByRef and findByPassengerId show the boarding stop's own time and station, not the schedule's full-route span", async () => { + await resetAndSeedCore(harness.prisma); + await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 60 } }); + await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 } }); + + const dep = new Date(Date.now() + 3 * 60 * 60_000); + const arr = new Date(dep.getTime() + 100 * 60_000); + const { schedule } = await createTestSchedule({ trainNumber: `SEG-DETAIL-${Date.now()}`, departureAt: dep, arrivalAt: arr }); + + const passenger = await harness.prisma.passenger.create({ data: {} }); + const booking = await harness.prisma.booking.create({ + data: { + bookingRef: `SEGDET${Date.now()}`, + passengerId: passenger.id, + scheduleId: schedule.id, + originStationId: IDS.stationB, + destinationStationId: IDS.stationC, + status: "CONFIRMED", + totalMinor: 10000, + displayCurrency: "ETB", + }, + }); + + const expectedBDeparture = new Date(dep.getTime() + 60 * 60_000); + + const detail: any = await bookingsService.getByRef(booking.bookingRef); + expect(new Date(detail.schedule.departureAt).getTime()).toBe(expectedBDeparture.getTime()); + expect(detail.schedule.origin.id).toBe(IDS.stationB); + expect(detail.schedule.destination.id).toBe(IDS.stationC); + + const list: any = await bookingsService.findByPassengerId(passenger.id); + expect(list.items).toHaveLength(1); + expect(new Date(list.items[0].schedule.departureAt).getTime()).toBe(expectedBDeparture.getTime()); + expect(list.items[0].schedule.originStation.id).toBe(IDS.stationB); + }); + }); +}); diff --git a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx index 6b310d137..251d034b1 100644 --- a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx @@ -308,7 +308,12 @@ export default function RoutesPage() { }; const addStop = () => { - setStops([...stops, { stationId: '', sequence: stops.length + 1, distanceFromOrigin: 0 }]); + // distanceKm must be cumulative and strictly increasing (enforced server-side) — defaulting + // every new stop to 0 made each one collide with the previous, so simply clicking "Add + // Intermediate Stop" a few times and saving without hand-editing every distance always failed + // validation. Default each new stop's distance a step past whatever precedes it instead. + const lastDistance = stops.length > 0 ? (stops[stops.length - 1].distanceFromOrigin ?? 0) : 0; + setStops([...stops, { stationId: '', sequence: stops.length + 1, distanceFromOrigin: lastDistance + 10 }]); }; const removeStop = (index: number) => {