diff --git a/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx index 49dd19bcc..2cedad1eb 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx @@ -16,7 +16,7 @@ import { useEffect, useState } from "react"; import { api } from "@/services/api"; import type { TrainComposition } from "@/services/trainBuilder.service"; import { useToast } from "@/hooks/use-toast"; -import { EXPORT_TRAIN_OPTIONS, importRunFor } from "@/constants/trainRuns"; +import { IMPORT_TRAIN_OPTIONS, exportRunFor } from "@/constants/trainRuns"; const parseError = (error: unknown, fallback: string) => { if (isAxiosError(error)) { @@ -56,11 +56,11 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain setLocomotiveIds([]); }, [yardId]); - // The import run is fixed by the export run, so it tracks it rather than - // being entered by hand (and clears back to empty when the export is cleared). + // The export run is fixed by the import run, so it tracks it rather than + // being entered by hand (and clears back to empty when the import is cleared). useEffect(() => { - setImportTrainNumber(importRunFor(exportTrainNumber)); - }, [exportTrainNumber]); + setExportTrainNumber(exportRunFor(importTrainNumber)); + }, [importTrainNumber]); useEffect(() => { if (!opened) { @@ -85,7 +85,7 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain // only "nothing picked" is reachable here. if (!exportTrainNumber || !importTrainNumber) { toast({ - title: "Pick an export train number (e.g. 8001) — the import run follows it", + title: "Pick an import train number (e.g. 8002) — the export run follows it", variant: "destructive", }); return; @@ -138,24 +138,24 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain maxLength={100} /> - = { "9001": "9002", }; -/** Selectable export runs, in run order. */ +/** Even IMPORT run -> its odd EXPORT run. Derived so the two cannot drift. */ +const EXPORT_BY_IMPORT: Record = Object.fromEntries( + Object.entries(TRAIN_RUN_PAIRS).map(([exportRun, importRun]) => [importRun, exportRun]), +); + +/** Selectable export runs (odd), in run order. */ export const EXPORT_TRAIN_OPTIONS = Object.keys(TRAIN_RUN_PAIRS).map((run) => ({ label: run, value: run, })); +/** Selectable import runs (even), in run order. */ +export const IMPORT_TRAIN_OPTIONS = Object.values(TRAIN_RUN_PAIRS).map((run) => ({ + label: run, + value: run, +})); + /** The import run implied by an export run; empty string when unset/unknown. */ export const importRunFor = (exportRun: unknown): string => TRAIN_RUN_PAIRS[String(exportRun ?? "")] ?? ""; + +/** The export run implied by an import run; empty string when unset/unknown. */ +export const exportRunFor = (importRun: unknown): string => + EXPORT_BY_IMPORT[String(importRun ?? "")] ?? ""; 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 162014f55..8cf8582b7 100644 --- a/apps/edr-freight-web/backoffice/src/locales/en/translation.json +++ b/apps/edr-freight-web/backoffice/src/locales/en/translation.json @@ -2669,9 +2669,6 @@ "uploadNew": "Upload New", "updateExisting": "Update Existing", "updateComingSoon": "Update existing functionality coming soon", - "uploadNew": "Upload New", - "updateExisting": "Update Existing", - "updateComingSoon": "Update existing functionality coming soon", "count": "Count", "posMissing": "Missing 'Positions' or 'Users' sheet.", "invalidFile": "The uploaded file is empty or invalid.", @@ -2697,7 +2694,6 @@ "archiveDepartment": "Archive Department", "deleteDepartment": "Delete Department", "deleteConfirm": "Delete Department?", - "delete": "Delete", "deleteFailed": "Failed to delete department", "cannotDeleteWithEmployees": "Cannot delete department with assigned employees", "reassignEmployeesFirst": "Please reassign or remove all employees from this department first.", @@ -3087,7 +3083,10 @@ "failedToResend": "Failed To Resend Verification Code", "failedToSendInvitation": "Failed To Send Invitation", "confirmRemoveEmployee": "Are you sure you want to remove {{name}}? This action cannot be undone.", - "removeFunctionalityNotImplemented": "Remove functionality will be implemented with proper API integration" + "removeFunctionalityNotImplemented": "Remove functionality will be implemented with proper API integration", + "sendingPasswordReset": "Sending password reset link...", + "passwordResetSent": "Password Reset Link Sent", + "passwordResetSentTo": "{{name}} can now set a new password. Any earlier code no longer works." }, "Resend Invite": "Resend Invite", "deputy": { @@ -3305,7 +3304,6 @@ "selectStyleCategory": "Select Style Category", "customized": "Customized", "textAlign": "Text Align", - "pdfPreview": "PDF Preview", "autoRefreshEnabled": "Auto refresh is enabled", "manualRefreshEnabled": "Manual refresh", "openInNewTab": "Open in new tab", @@ -7053,11 +7051,9 @@ "attach": "Attach", "selectAttachment": "Select Attachment from DMS", "search": "Search…", - "loading": "Loading…", - "loadingFiles": "Fetching DMS files...", - "searchFiles": "Search folders and files...", "loading": "Loading files...", "loadingFiles": "Fetching DMS files...", + "searchFiles": "Search folders and files...", "retry": "Retry", "noSearchResults": "No files match your search", "emptyFolder": "Nothing to show here", diff --git a/apps/edr-freight-web/backoffice/src/pages/documents/ManageFileUploadFieldsDialog.tsx b/apps/edr-freight-web/backoffice/src/pages/documents/ManageFileUploadFieldsDialog.tsx index b9599d23d..939a5a931 100644 --- a/apps/edr-freight-web/backoffice/src/pages/documents/ManageFileUploadFieldsDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/documents/ManageFileUploadFieldsDialog.tsx @@ -39,6 +39,18 @@ interface DraftField extends CreateFileUploadFieldDto { let draftCounter = 0; const nextKey = () => `draft-${Date.now()}-${++draftCounter}`; +/** + * Extensions offered as checkboxes. Mirrors DOC_EXTENSIONS in the API's + * file-upload-settings seeder — the only formats the document flows accept. + * + * The API validates `allowedExtensions` as plain strings, so free text let + * typos ("pd") through silently and the field then rejected every real upload. + * A fixed list makes that unrepresentable. + */ +const FILE_EXTENSION_OPTIONS = ["pdf", "jpg", "jpeg", "png"] as const; + +const KNOWN_EXTENSIONS = new Set(FILE_EXTENSION_OPTIONS); + function makeEmptyDraft(idx: number): DraftField { return { key: nextKey(), @@ -93,13 +105,19 @@ export default function ManageFileUploadFieldsDialog({ }), ); - const updateExtensions = (i: number, raw: string) => { - const list = raw - .split(",") - .map((s) => s.trim().toLowerCase().replace(/^\./, "")) - .filter(Boolean); - update(i, { allowedExtensions: list }); - }; + const toggleExtension = (i: number, ext: string, checked: boolean) => + setFields((prev) => + prev.map((f, idx) => { + if (idx !== i) return f; + const current = f.allowedExtensions; + if (checked) { + return current.includes(ext) + ? f + : { ...f, allowedExtensions: [...current, ext] }; + } + return { ...f, allowedExtensions: current.filter((e) => e !== ext) }; + }), + ); const remove = (i: number) => setFields((prev) => prev.filter((_, idx) => idx !== i)); @@ -214,7 +232,9 @@ export default function ManageFileUploadFieldsDialog({ index={i} total={fields.length} onChange={(patch) => update(i, patch)} - onChangeExtensions={(raw) => updateExtensions(i, raw)} + onToggleExtension={(ext, checked) => + toggleExtension(i, ext, checked) + } onMove={(dir) => move(i, dir)} onRemove={() => remove(i)} /> @@ -258,7 +278,7 @@ function FieldEditor({ index, total, onChange, - onChangeExtensions, + onToggleExtension, onMove, onRemove, }: { @@ -266,13 +286,23 @@ function FieldEditor({ index: number; total: number; onChange: (patch: Partial) => void; - onChangeExtensions: (raw: string) => void; + onToggleExtension: (ext: string, checked: boolean) => void; onMove: (dir: -1 | 1) => void; onRemove: () => void; }) { const minFiles = getMinFiles(field); const effectiveMax = field.isMultiple ? field.maxFiles : 1; + // A field saved before this list existed can hold anything the old free-text + // box accepted (e.g. the typo "pd"). Show those alongside the standard ones so + // they stay visible and removable instead of silently vanishing on save. + const extensionChoices = [ + ...FILE_EXTENSION_OPTIONS, + ...field.allowedExtensions.filter( + (ext) => !KNOWN_EXTENSIONS.has(ext), + ), + ]; + return (
@@ -348,16 +378,33 @@ function FieldEditor({
- - onChangeExtensions(e.target.value)} - placeholder="pdf, docx, jpg" - className="font-mono" - /> -

- Comma-separated, no leading dot. -

+ +
+ {extensionChoices.map((ext) => ( + + ))} +
+ {field.allowedExtensions.length === 0 ? ( +

Pick at least one extension.

+ ) : ( +

+ Uploads are rejected unless the file matches one of these. +

+ )}
diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts index 793537dd9..3981cb47d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts @@ -1,6 +1,6 @@ import { Freight } from "@edr/types"; import type { ColumnFormat, FormFieldDef } from "@/pages/ruleEngine/config/resources"; -import { EXPORT_TRAIN_OPTIONS, importRunFor } from "@/constants/trainRuns"; +import { IMPORT_TRAIN_OPTIONS, exportRunFor } from "@/constants/trainRuns"; import { vehiclesConfig, VEHICLE_TYPE_OPTIONS, FUEL_TYPE_OPTIONS, VEHICLE_STATUS_OPTIONS } from "./vehicles"; import { driversConfig, DRIVER_STATUS_OPTIONS } from "./drivers"; @@ -299,25 +299,25 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [ ], formFields: [ // Run numbers are optional — a wagon sits in the fleet unassigned to any - // run until an operator picks an export run. The import run is fixed by + // run until an operator picks an import run. The export run is fixed by // that choice, so it is derived rather than typed. { name: "exportTrainNumber", label: "Export train number", - type: "select", + type: "text", description: "Odd — Ethiopia → Djibouti runs", placeholder: "e.g. 8001", - options: EXPORT_TRAIN_OPTIONS, + derivedValue: (values) => exportRunFor(values.importTrainNumber), + // Follows the import run to NULL when that is cleared. clearable: true, }, { name: "importTrainNumber", label: "Import train number", - type: "text", + type: "select", description: "Even — Djibouti → Ethiopia runs", placeholder: "e.g. 8002", - derivedValue: (values) => importRunFor(values.exportTrainNumber), - // Follows the export run to NULL when that is cleared. + options: IMPORT_TRAIN_OPTIONS, clearable: true, }, { name: "wagonNumber", label: "Wagon number", type: "text", required: true }, diff --git a/apps/edr-freight-web/backoffice/src/user-management/userManagement/TeamMembers.tsx b/apps/edr-freight-web/backoffice/src/user-management/userManagement/TeamMembers.tsx index 7ee195d22..3cca16fe1 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/userManagement/TeamMembers.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/userManagement/TeamMembers.tsx @@ -281,9 +281,9 @@ export const TeamMembers = ({ }; const ActionDropdown = ({ employee }: { employee: TeamMemberDto }) => { - const status = getUserStatus(employee); - const isPending = status === "pending"; - const isNotInvited = status === "Not Invited"; + // getUserStatus only ever yields "accepted" | "pending" — a member with no + // password set is "pending", so anything else has one already. + const isPending = getUserStatus(employee) === "pending"; return ( @@ -328,8 +328,11 @@ export const TeamMembers = ({ {t("positions.viewPositions")} - {/* Resend/Invite Option */} - {onInviteEmployee && (isPending || isNotInvited) && ( + {/* Resend/Invite Option. Offered for accepted members too: the code + this sends lets them set a new password, so for someone who + already has one it is a reset — labelled as such rather than as an + invite, which would misdescribe what the operator is doing. */} + {onInviteEmployee && ( { e.stopPropagation(); @@ -337,7 +340,7 @@ export const TeamMembers = ({ }} className="cursor-pointer dark:text-gray-200 dark:hover:bg-gray-600"> - {isPending ? t("Resend Invite") : t("Send Invite")} + {isPending ? t("Resend Invite") : t("Send Password Reset")} )} diff --git a/apps/edr-freight-web/backoffice/src/user-management/userManagement/UserManagementTree.tsx b/apps/edr-freight-web/backoffice/src/user-management/userManagement/UserManagementTree.tsx index 70ee2903f..10cf8651a 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/userManagement/UserManagementTree.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/userManagement/UserManagementTree.tsx @@ -213,12 +213,15 @@ const UserManagementTree = () => { // Handle inviting or resending invitation to employees const handleInviteEmployee = async (employee: TeamMemberDto) => { try { - // Show immediate feedback that action is being processed - if (employee.status === "pending") { - toast.loading("Resending verification code..."); - } else { - toast.loading("Sending invitation..."); - } + // A member who already set a password is being sent a fresh set-password + // code — that is a reset, not an invite, so say so. Keyed off + // hasSetPassword (the same fact the menu label uses) rather than the + // looser `status` field, so the wording cannot disagree with the menu. + const isReset = employee.user.hasSetPassword; + + toast.loading( + isReset ? "Sending password reset link..." : "Resending verification code...", + ); await resendVerificationCode({ email: employee.user.email, phoneNumber: employee.user.phoneNumber, @@ -229,14 +232,14 @@ const UserManagementTree = () => { ? localizedName(employee.user.name) : localizedName(employee.user.name) || "User"; - if (employee.user.status === "pending") { - toast.success("Verification Code Resent", { - description: `A new verification code has been sent to ${userName}`, + if (isReset) { + toast.success("Password Reset Link Sent", { + description: `${userName} can now set a new password. Any earlier code they had no longer works.`, duration: 4000, }); } else { - toast.success("Invitation Sent", { - description: `Invitation sent to ${userName}`, + toast.success("Verification Code Resent", { + description: `A new verification code has been sent to ${userName}`, duration: 4000, }); } diff --git a/apps/edr-freight-web/backoffice/src/user-management/userManagement/components/OrganizationEmployeeSearch.tsx b/apps/edr-freight-web/backoffice/src/user-management/userManagement/components/OrganizationEmployeeSearch.tsx index 09018ef44..119e9b288 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/userManagement/components/OrganizationEmployeeSearch.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/userManagement/components/OrganizationEmployeeSearch.tsx @@ -81,12 +81,17 @@ export const OrganizationEmployeeSearch: React.FC< const handleInviteEmployee = async (employee: TeamMemberDto) => { const lang = i18n.language; try { - // Show immediate feedback that action is being processed - if (employee.user.status === "pending") { - toast.loading(t("search.resendingVerification")); - } else { - toast.loading(t("search.sendingInvitation")); - } + // A member who already set a password is being sent a fresh set-password + // code — that is a reset, not an invite. Keyed off hasSetPassword (the + // same fact the menu label uses) rather than the looser `status` field, + // so the wording cannot disagree with the menu the operator clicked. + const isReset = employee.user.hasSetPassword; + + toast.loading( + isReset + ? t("search.sendingPasswordReset") + : t("search.resendingVerification"), + ); await resendVerificationCode({ email: employee.user.email, @@ -97,14 +102,14 @@ export const OrganizationEmployeeSearch: React.FC< const userName = lang === "en" ? employee.user.name.en : employee.user.name.am; - if (employee.user.status === "pending") { - toast.success(t("search.verificationCodeResent"), { - description: t("search.verificationCodeSentTo", { name: userName }), + if (isReset) { + toast.success(t("search.passwordResetSent"), { + description: t("search.passwordResetSentTo", { name: userName }), duration: 4000, }); } else { - toast.success(t("search.invitationSent"), { - description: t("search.invitationSentTo", { name: userName }), + toast.success(t("search.verificationCodeResent"), { + description: t("search.verificationCodeSentTo", { name: userName }), duration: 4000, }); }