This commit is contained in:
natib21
2026-07-17 13:10:27 +00:00
parent 6f126a2345
commit 0d98ccb04b
8 changed files with 151 additions and 82 deletions

View File

@@ -16,7 +16,7 @@ import { useEffect, useState } from "react";
import { api } from "@/services/api"; import { api } from "@/services/api";
import type { TrainComposition } from "@/services/trainBuilder.service"; import type { TrainComposition } from "@/services/trainBuilder.service";
import { useToast } from "@/hooks/use-toast"; 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) => { const parseError = (error: unknown, fallback: string) => {
if (isAxiosError(error)) { if (isAxiosError(error)) {
@@ -56,11 +56,11 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
setLocomotiveIds([]); setLocomotiveIds([]);
}, [yardId]); }, [yardId]);
// The import run is fixed by the export run, so it tracks it rather than // 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 export is cleared). // being entered by hand (and clears back to empty when the import is cleared).
useEffect(() => { useEffect(() => {
setImportTrainNumber(importRunFor(exportTrainNumber)); setExportTrainNumber(exportRunFor(importTrainNumber));
}, [exportTrainNumber]); }, [importTrainNumber]);
useEffect(() => { useEffect(() => {
if (!opened) { if (!opened) {
@@ -85,7 +85,7 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
// only "nothing picked" is reachable here. // only "nothing picked" is reachable here.
if (!exportTrainNumber || !importTrainNumber) { if (!exportTrainNumber || !importTrainNumber) {
toast({ 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", variant: "destructive",
}); });
return; return;
@@ -138,24 +138,24 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
maxLength={100} maxLength={100}
/> />
<Group grow> <Group grow>
<Select {/* Fixed by the import run — derived, never typed. */}
<TextInput
label="Export train number" label="Export train number"
description="Odd — Ethiopia → Djibouti runs" description="Odd — Ethiopia → Djibouti runs"
placeholder="e.g. 8001" placeholder="e.g. 8001"
data={EXPORT_TRAIN_OPTIONS} value={exportTrainNumber}
value={exportTrainNumber || null} readOnly
onChange={(value) => setExportTrainNumber(value ?? "")} variant="filled"
searchable
clearable
/> />
{/* Fixed by the export run — derived, never typed. */} <Select
<TextInput
label="Import train number" label="Import train number"
description="Even — Djibouti → Ethiopia runs" description="Even — Djibouti → Ethiopia runs"
placeholder="e.g. 8002" placeholder="e.g. 8002"
value={importTrainNumber} data={IMPORT_TRAIN_OPTIONS}
readOnly value={importTrainNumber || null}
variant="filled" onChange={(value) => setImportTrainNumber(value ?? "")}
searchable
clearable
/> />
</Group> </Group>
<Select <Select

View File

@@ -24,12 +24,27 @@ export const TRAIN_RUN_PAIRS: Record<string, string> = {
"9001": "9002", "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<string, string> = 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) => ({ export const EXPORT_TRAIN_OPTIONS = Object.keys(TRAIN_RUN_PAIRS).map((run) => ({
label: run, label: run,
value: 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. */ /** The import run implied by an export run; empty string when unset/unknown. */
export const importRunFor = (exportRun: unknown): string => export const importRunFor = (exportRun: unknown): string =>
TRAIN_RUN_PAIRS[String(exportRun ?? "")] ?? ""; 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 ?? "")] ?? "";

View File

@@ -2669,9 +2669,6 @@
"uploadNew": "Upload New", "uploadNew": "Upload New",
"updateExisting": "Update Existing", "updateExisting": "Update Existing",
"updateComingSoon": "Update existing functionality coming soon", "updateComingSoon": "Update existing functionality coming soon",
"uploadNew": "Upload New",
"updateExisting": "Update Existing",
"updateComingSoon": "Update existing functionality coming soon",
"count": "Count", "count": "Count",
"posMissing": "Missing 'Positions' or 'Users' sheet.", "posMissing": "Missing 'Positions' or 'Users' sheet.",
"invalidFile": "The uploaded file is empty or invalid.", "invalidFile": "The uploaded file is empty or invalid.",
@@ -2697,7 +2694,6 @@
"archiveDepartment": "Archive Department", "archiveDepartment": "Archive Department",
"deleteDepartment": "Delete Department", "deleteDepartment": "Delete Department",
"deleteConfirm": "Delete Department?", "deleteConfirm": "Delete Department?",
"delete": "Delete",
"deleteFailed": "Failed to delete department", "deleteFailed": "Failed to delete department",
"cannotDeleteWithEmployees": "Cannot delete department with assigned employees", "cannotDeleteWithEmployees": "Cannot delete department with assigned employees",
"reassignEmployeesFirst": "Please reassign or remove all employees from this department first.", "reassignEmployeesFirst": "Please reassign or remove all employees from this department first.",
@@ -3087,7 +3083,10 @@
"failedToResend": "Failed To Resend Verification Code", "failedToResend": "Failed To Resend Verification Code",
"failedToSendInvitation": "Failed To Send Invitation", "failedToSendInvitation": "Failed To Send Invitation",
"confirmRemoveEmployee": "Are you sure you want to remove {{name}}? This action cannot be undone.", "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", "Resend Invite": "Resend Invite",
"deputy": { "deputy": {
@@ -3305,7 +3304,6 @@
"selectStyleCategory": "Select Style Category", "selectStyleCategory": "Select Style Category",
"customized": "Customized", "customized": "Customized",
"textAlign": "Text Align", "textAlign": "Text Align",
"pdfPreview": "PDF Preview",
"autoRefreshEnabled": "Auto refresh is enabled", "autoRefreshEnabled": "Auto refresh is enabled",
"manualRefreshEnabled": "Manual refresh", "manualRefreshEnabled": "Manual refresh",
"openInNewTab": "Open in new tab", "openInNewTab": "Open in new tab",
@@ -7053,11 +7051,9 @@
"attach": "Attach", "attach": "Attach",
"selectAttachment": "Select Attachment from DMS", "selectAttachment": "Select Attachment from DMS",
"search": "Search…", "search": "Search…",
"loading": "Loading…",
"loadingFiles": "Fetching DMS files...",
"searchFiles": "Search folders and files...",
"loading": "Loading files...", "loading": "Loading files...",
"loadingFiles": "Fetching DMS files...", "loadingFiles": "Fetching DMS files...",
"searchFiles": "Search folders and files...",
"retry": "Retry", "retry": "Retry",
"noSearchResults": "No files match your search", "noSearchResults": "No files match your search",
"emptyFolder": "Nothing to show here", "emptyFolder": "Nothing to show here",

View File

@@ -39,6 +39,18 @@ interface DraftField extends CreateFileUploadFieldDto {
let draftCounter = 0; let draftCounter = 0;
const nextKey = () => `draft-${Date.now()}-${++draftCounter}`; 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<string>(FILE_EXTENSION_OPTIONS);
function makeEmptyDraft(idx: number): DraftField { function makeEmptyDraft(idx: number): DraftField {
return { return {
key: nextKey(), key: nextKey(),
@@ -93,13 +105,19 @@ export default function ManageFileUploadFieldsDialog({
}), }),
); );
const updateExtensions = (i: number, raw: string) => { const toggleExtension = (i: number, ext: string, checked: boolean) =>
const list = raw setFields((prev) =>
.split(",") prev.map((f, idx) => {
.map((s) => s.trim().toLowerCase().replace(/^\./, "")) if (idx !== i) return f;
.filter(Boolean); const current = f.allowedExtensions;
update(i, { allowedExtensions: list }); if (checked) {
}; return current.includes(ext)
? f
: { ...f, allowedExtensions: [...current, ext] };
}
return { ...f, allowedExtensions: current.filter((e) => e !== ext) };
}),
);
const remove = (i: number) => const remove = (i: number) =>
setFields((prev) => prev.filter((_, idx) => idx !== i)); setFields((prev) => prev.filter((_, idx) => idx !== i));
@@ -214,7 +232,9 @@ export default function ManageFileUploadFieldsDialog({
index={i} index={i}
total={fields.length} total={fields.length}
onChange={(patch) => update(i, patch)} onChange={(patch) => update(i, patch)}
onChangeExtensions={(raw) => updateExtensions(i, raw)} onToggleExtension={(ext, checked) =>
toggleExtension(i, ext, checked)
}
onMove={(dir) => move(i, dir)} onMove={(dir) => move(i, dir)}
onRemove={() => remove(i)} onRemove={() => remove(i)}
/> />
@@ -258,7 +278,7 @@ function FieldEditor({
index, index,
total, total,
onChange, onChange,
onChangeExtensions, onToggleExtension,
onMove, onMove,
onRemove, onRemove,
}: { }: {
@@ -266,13 +286,23 @@ function FieldEditor({
index: number; index: number;
total: number; total: number;
onChange: (patch: Partial<DraftField>) => void; onChange: (patch: Partial<DraftField>) => void;
onChangeExtensions: (raw: string) => void; onToggleExtension: (ext: string, checked: boolean) => void;
onMove: (dir: -1 | 1) => void; onMove: (dir: -1 | 1) => void;
onRemove: () => void; onRemove: () => void;
}) { }) {
const minFiles = getMinFiles(field); const minFiles = getMinFiles(field);
const effectiveMax = field.isMultiple ? field.maxFiles : 1; 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 ( return (
<div className="rounded-2xl border border-slate-200 bg-white p-4"> <div className="rounded-2xl border border-slate-200 bg-white p-4">
<div className="mb-3 flex items-center justify-between"> <div className="mb-3 flex items-center justify-between">
@@ -348,16 +378,33 @@ function FieldEditor({
</div> </div>
<div className="space-y-1.5 md:col-span-2"> <div className="space-y-1.5 md:col-span-2">
<Label className="text-xs">Allowed Extensions</Label> <Label className="text-xs">Allowed Extensions *</Label>
<Input <div className="flex flex-wrap items-center gap-x-4 gap-y-2">
value={field.allowedExtensions.join(", ")} {extensionChoices.map((ext) => (
onChange={(e) => onChangeExtensions(e.target.value)} <label
placeholder="pdf, docx, jpg" key={ext}
className="font-mono" className="flex items-center gap-2 text-sm text-slate-700"
/> >
<p className="text-xs text-slate-500"> <input
Comma-separated, no leading dot. type="checkbox"
</p> checked={field.allowedExtensions.includes(ext)}
onChange={(e) => onToggleExtension(ext, e.target.checked)}
className="h-4 w-4 rounded border-slate-300 text-[#10B981] focus:ring-[#10B981]/20"
/>
<span className="font-mono">{ext}</span>
{!KNOWN_EXTENSIONS.has(ext) ? (
<span className="text-xs text-amber-600">(unrecognised)</span>
) : null}
</label>
))}
</div>
{field.allowedExtensions.length === 0 ? (
<p className="text-xs text-red-500">Pick at least one extension.</p>
) : (
<p className="text-xs text-slate-500">
Uploads are rejected unless the file matches one of these.
</p>
)}
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">

View File

@@ -1,6 +1,6 @@
import { Freight } from "@edr/types"; import { Freight } from "@edr/types";
import type { ColumnFormat, FormFieldDef } from "@/pages/ruleEngine/config/resources"; 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 { vehiclesConfig, VEHICLE_TYPE_OPTIONS, FUEL_TYPE_OPTIONS, VEHICLE_STATUS_OPTIONS } from "./vehicles";
import { driversConfig, DRIVER_STATUS_OPTIONS } from "./drivers"; import { driversConfig, DRIVER_STATUS_OPTIONS } from "./drivers";
@@ -299,25 +299,25 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
], ],
formFields: [ formFields: [
// Run numbers are optional — a wagon sits in the fleet unassigned to any // 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. // that choice, so it is derived rather than typed.
{ {
name: "exportTrainNumber", name: "exportTrainNumber",
label: "Export train number", label: "Export train number",
type: "select", type: "text",
description: "Odd — Ethiopia → Djibouti runs", description: "Odd — Ethiopia → Djibouti runs",
placeholder: "e.g. 8001", 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, clearable: true,
}, },
{ {
name: "importTrainNumber", name: "importTrainNumber",
label: "Import train number", label: "Import train number",
type: "text", type: "select",
description: "Even — Djibouti → Ethiopia runs", description: "Even — Djibouti → Ethiopia runs",
placeholder: "e.g. 8002", placeholder: "e.g. 8002",
derivedValue: (values) => importRunFor(values.exportTrainNumber), options: IMPORT_TRAIN_OPTIONS,
// Follows the export run to NULL when that is cleared.
clearable: true, clearable: true,
}, },
{ name: "wagonNumber", label: "Wagon number", type: "text", required: true }, { name: "wagonNumber", label: "Wagon number", type: "text", required: true },

View File

@@ -281,9 +281,9 @@ export const TeamMembers = ({
}; };
const ActionDropdown = ({ employee }: { employee: TeamMemberDto }) => { const ActionDropdown = ({ employee }: { employee: TeamMemberDto }) => {
const status = getUserStatus(employee); // getUserStatus only ever yields "accepted" | "pending" — a member with no
const isPending = status === "pending"; // password set is "pending", so anything else has one already.
const isNotInvited = status === "Not Invited"; const isPending = getUserStatus(employee) === "pending";
return ( return (
<DropdownMenu> <DropdownMenu>
@@ -328,8 +328,11 @@ export const TeamMembers = ({
{t("positions.viewPositions")} {t("positions.viewPositions")}
</DropdownMenuItem> </DropdownMenuItem>
{/* Resend/Invite Option */} {/* Resend/Invite Option. Offered for accepted members too: the code
{onInviteEmployee && (isPending || isNotInvited) && ( 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 && (
<DropdownMenuItem <DropdownMenuItem
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -337,7 +340,7 @@ export const TeamMembers = ({
}} }}
className="cursor-pointer dark:text-gray-200 dark:hover:bg-gray-600"> className="cursor-pointer dark:text-gray-200 dark:hover:bg-gray-600">
<RefreshCw className="h-4 w-4 mr-2" /> <RefreshCw className="h-4 w-4 mr-2" />
{isPending ? t("Resend Invite") : t("Send Invite")} {isPending ? t("Resend Invite") : t("Send Password Reset")}
</DropdownMenuItem> </DropdownMenuItem>
)} )}

View File

@@ -213,12 +213,15 @@ const UserManagementTree = () => {
// Handle inviting or resending invitation to employees // Handle inviting or resending invitation to employees
const handleInviteEmployee = async (employee: TeamMemberDto) => { const handleInviteEmployee = async (employee: TeamMemberDto) => {
try { try {
// Show immediate feedback that action is being processed // A member who already set a password is being sent a fresh set-password
if (employee.status === "pending") { // code — that is a reset, not an invite, so say so. Keyed off
toast.loading("Resending verification code..."); // hasSetPassword (the same fact the menu label uses) rather than the
} else { // looser `status` field, so the wording cannot disagree with the menu.
toast.loading("Sending invitation..."); const isReset = employee.user.hasSetPassword;
}
toast.loading(
isReset ? "Sending password reset link..." : "Resending verification code...",
);
await resendVerificationCode({ await resendVerificationCode({
email: employee.user.email, email: employee.user.email,
phoneNumber: employee.user.phoneNumber, phoneNumber: employee.user.phoneNumber,
@@ -229,14 +232,14 @@ const UserManagementTree = () => {
? localizedName(employee.user.name) ? localizedName(employee.user.name)
: localizedName(employee.user.name) || "User"; : localizedName(employee.user.name) || "User";
if (employee.user.status === "pending") { if (isReset) {
toast.success("Verification Code Resent", { toast.success("Password Reset Link Sent", {
description: `A new verification code has been sent to ${userName}`, description: `${userName} can now set a new password. Any earlier code they had no longer works.`,
duration: 4000, duration: 4000,
}); });
} else { } else {
toast.success("Invitation Sent", { toast.success("Verification Code Resent", {
description: `Invitation sent to ${userName}`, description: `A new verification code has been sent to ${userName}`,
duration: 4000, duration: 4000,
}); });
} }

View File

@@ -81,12 +81,17 @@ export const OrganizationEmployeeSearch: React.FC<
const handleInviteEmployee = async (employee: TeamMemberDto) => { const handleInviteEmployee = async (employee: TeamMemberDto) => {
const lang = i18n.language; const lang = i18n.language;
try { try {
// Show immediate feedback that action is being processed // A member who already set a password is being sent a fresh set-password
if (employee.user.status === "pending") { // code — that is a reset, not an invite. Keyed off hasSetPassword (the
toast.loading(t("search.resendingVerification")); // same fact the menu label uses) rather than the looser `status` field,
} else { // so the wording cannot disagree with the menu the operator clicked.
toast.loading(t("search.sendingInvitation")); const isReset = employee.user.hasSetPassword;
}
toast.loading(
isReset
? t("search.sendingPasswordReset")
: t("search.resendingVerification"),
);
await resendVerificationCode({ await resendVerificationCode({
email: employee.user.email, email: employee.user.email,
@@ -97,14 +102,14 @@ export const OrganizationEmployeeSearch: React.FC<
const userName = const userName =
lang === "en" ? employee.user.name.en : employee.user.name.am; lang === "en" ? employee.user.name.en : employee.user.name.am;
if (employee.user.status === "pending") { if (isReset) {
toast.success(t("search.verificationCodeResent"), { toast.success(t("search.passwordResetSent"), {
description: t("search.verificationCodeSentTo", { name: userName }), description: t("search.passwordResetSentTo", { name: userName }),
duration: 4000, duration: 4000,
}); });
} else { } else {
toast.success(t("search.invitationSent"), { toast.success(t("search.verificationCodeResent"), {
description: t("search.invitationSentTo", { name: userName }), description: t("search.verificationCodeSentTo", { name: userName }),
duration: 4000, duration: 4000,
}); });
} }