From 552e6bcd16f27a86dea24d07e634799324d4953d Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 26 Jun 2026 12:31:08 +0000 Subject: [PATCH 01/38] fix: make the file sync work on onboarding --- .../onboarding/OnboardingWizardDialog.tsx | 190 ++++--- .../src/pages/accounts/CompanyProfileForm.tsx | 140 ++--- .../src/components/SmartFileInput/index.tsx | 479 +++++++++++------- 3 files changed, 501 insertions(+), 308 deletions(-) diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx index 8d6bc3d93..4d4c8664a 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -164,7 +164,9 @@ export default function OnboardingWizardDialog({ (company?.company?.nationality as CompanyNationality | null) ?? null; // Resume position from the backend-persisted step. - const resumeFormStep: FormStep = FORM_STEPS.includes(onboardingStep as FormStep) + const resumeFormStep: FormStep = FORM_STEPS.includes( + onboardingStep as FormStep, + ) ? (onboardingStep as FormStep) : "company"; @@ -275,7 +277,7 @@ export default function OnboardingWizardDialog({ const idx = FORM_STEPS.indexOf(step as FormStep); if (idx < 0 || idx <= furthestIdxRef.current) return; furthestIdxRef.current = idx; - api.companies.setOnboardingStep.call({ step }).catch(() => {}); + api.companies.setOnboardingStep.call({ step }).catch(() => { }); }, []); // Mirror the form's step locally (for the header/pill) and persist it. @@ -321,7 +323,7 @@ export default function OnboardingWizardDialog({ // Note: no "back to role selection" — once the draft is created the role(s) // are fixed; the form's first-step Back is a no-op so progress never resets. - const handleBackToRoles = useCallback(() => {}, []); + const handleBackToRoles = useCallback(() => { }, []); // Save the current step's fields to the draft (PATCH /profile). Returns the // server error message on failure so the form can show it (e.g. duplicate TIN). @@ -339,6 +341,31 @@ export default function OnboardingWizardDialog({ [], ); + // Auto-upload the documents the user just selected as they leave the documents + // step. Only the in-memory selections are sent; once uploaded they're cleared + // (so the final submit never re-uploads them) and the requirements query is + // refreshed so the "Already uploaded" badges light up. Partial uploads are + // allowed — the user may continue even with required docs still outstanding. + const handleUploadDocuments = useCallback(async (): Promise< + { ok: true } | { ok: false; error: string } + > => { + const companyId = company?.company?.id; + const hasNew = Object.values(documentFiles).some( + (f) => f !== null && (Array.isArray(f) ? f.length > 0 : true), + ); + if (!companyId || !hasNew) return { ok: true }; + try { + await companiesService.uploadDocuments(companyId, documentFiles); + setDocumentFiles({}); + await queryClient.invalidateQueries({ + queryKey: api.companies.onboardingRequirements.queryKey(), + }); + return { ok: true }; + } catch (err) { + return { ok: false, error: extractApiError(err).message }; + } + }, [company?.company?.id, documentFiles, queryClient]); + // Final confirm step → finalize onboarding (no company create; it already // exists as a draft that's been filled in step-by-step). const handleSubmit = useCallback( @@ -383,6 +410,25 @@ export default function OnboardingWizardDialog({ requirementsQuery.data?.documentSettingCode ?? documentSettingCode(effectiveNationality); + // Server-confirmed document state, used both to badge already-uploaded fields + // and to keep a refreshed resume from over-shooting the documents step. + const requirementDocuments = requirementsQuery.data?.documents ?? []; + const uploadedDocumentKeys = requirementDocuments + .filter((d) => d.uploaded) + .map((d) => d.fileKey); + // If any REQUIRED document is still missing, the resume must not rest past the + // documents step (don't skip to Business License) — clamp it back. This only + // changes the target once requirements load; the form follows the correction + // as long as the user hasn't navigated yet. + const requiredDocsMissing = requirementDocuments.some( + (d) => d.isRequired && !d.uploaded, + ); + const effectiveResumeStep: FormStep = + requiredDocsMissing && + FORM_STEPS.indexOf(resumeFormStep) > FORM_STEPS.indexOf("documents") + ? "documents" + : resumeFormStep; + const formProps = { documentSettingCode: resolvedDocumentSettingCode, documentFiles, @@ -392,7 +438,7 @@ export default function OnboardingWizardDialog({ isPending: finishMutation.isPending, onBack: handleBackToRoles, hideFirstStepBack: true, - initialStep: resumeFormStep, + initialStep: effectiveResumeStep, resyncOpen: opened, onStepChange: handleStepChange, onSaveStep: saveStep, @@ -400,6 +446,8 @@ export default function OnboardingWizardDialog({ roleProfiles, licenseFiles, onLicenseChange: setLicenseFiles, + uploadedDocumentKeys, + onUploadDocuments: handleUploadDocuments, // Surface a failed final submit (license/document upload or complete) inside // the form — otherwise the server message (e.g. a 500) would be invisible on // the submit step. @@ -413,7 +461,7 @@ export default function OnboardingWizardDialog({ withCloseButton={!completed} closeOnClickOutside={false} closeOnEscape={!completed} - size={720} + size={1440} radius="lg" padding="xl" centered @@ -422,11 +470,11 @@ export default function OnboardingWizardDialog({ overlayProps={{ backgroundOpacity: 0.55, blur: 4 }} styles={{ header: { - alignItems:"flex-start" + alignItems: "flex-start", }, title: { - flex: 1 - } + flex: 1, + }, }} title={ completed ? null : ( @@ -448,59 +496,64 @@ export default function OnboardingWizardDialog({ {completed ? ( ) : ( - - - {phase === "nationality" ? ( - - - - - - - ) : phase === "role" ? ( - - - {startError && ( - - {startError} - - )} - - - - - - ) : ( - - )} - + + {phase === "nationality" ? ( + + + + + + + ) : phase === "role" ? ( + + + {startError && ( + + {startError} + + )} + + + + + + ) : ( + + )} + )} ); @@ -518,7 +571,10 @@ function OnboardingCompletePanel({ onClose }: { onClose: () => void }) { className="flex h-16 w-16 items-center justify-center rounded-full" style={{ background: "var(--mantine-color-edr-green-1)" }} > - + @@ -538,14 +594,20 @@ function OnboardingCompletePanel({ onClose }: { onClose: () => void }) { style={{ background: "var(--mantine-color-edr-green-0)" }} > - + Each operational profile (importer, exporter, freight forwarder) is reviewed and approved individually. - + You can start creating bookings under a profile as soon as it's approved — we'll let you know the moment that happens. diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index 4f61c7a61..3f07d7059 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -53,7 +53,8 @@ type CompanyStep = | "additional"; /** Last 9 digits (Ethiopian national significant number) for tolerant compare. */ -const phoneDigits = (p?: string | null) => (p ?? "").replace(/\D/g, "").slice(-9); +const phoneDigits = (p?: string | null) => + (p ?? "").replace(/\D/g, "").slice(-9); const samePhone = (a?: string | null, b?: string | null) => { const da = phoneDigits(a); return da.length === 9 && da === phoneDigits(b); @@ -92,7 +93,6 @@ const onboardingSchema = z.object({ woreda: z.string().min(1, "Woreda is required"), kebele: z.string().min(1, "Kebele is required"), houseNo: z.string().min(1, "House number is required"), - etradePhone: z.string().optional(), contactPersonName: z.string().min(1, "Contact person name is required"), contactPersonPosition: z.string().optional(), contactPersonEmail: z @@ -143,7 +143,6 @@ const stepFields: Record = { "woreda", "kebele", "houseNo", - "etradePhone", ], personnel: [ "generalManagerName", @@ -216,7 +215,7 @@ function stepPayload( woreda: d.woreda, kebele: d.kebele, houseNo: d.houseNo, - etradePhone: d.etradePhone, + etradePhone: d.companyPhone, }; case "personnel": return { @@ -268,7 +267,6 @@ function toFormValues(p: ProfileResponse): FormData { woreda: p.woreda ?? "", kebele: p.kebele ?? "", houseNo: p.houseNo ?? "", - etradePhone: p.etradePhone ?? "", contactPersonName: p.contactPersonName ?? "", contactPersonPosition: p.contactPersonPosition ?? "", contactPersonEmail: p.contactPersonEmail ?? "", @@ -316,6 +314,8 @@ export default function CompanyProfileForm({ licenseFiles, onLicenseChange, submitError, + uploadedDocumentKeys, + onUploadDocuments, }: { documentSettingCode: string; documentFiles?: Record; @@ -345,6 +345,14 @@ export default function CompanyProfileForm({ onLicenseChange?: (value: Record) => void; /** Server error from the final submit (uploads/complete), shown verbatim. */ submitError?: string | null; + /** fileKeys whose company document is already uploaded server-side (resume). */ + uploadedDocumentKeys?: string[]; + /** + * Auto-upload the currently-selected company documents (the Documents step's + * "Continue" action). Resolves to an error message string on failure so the + * step can surface it and hold the user in place. + */ + onUploadDocuments?: () => Promise<{ ok: true } | { ok: false; error: string }>; }) { const [step, setStep] = useState(initialStep ?? "company"); const [saving, setSaving] = useState(false); @@ -356,17 +364,40 @@ export default function CompanyProfileForm({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [step]); + // Tracks whether the user has manually navigated the form this session. While + // false, the form still follows the parent's resume target (initialStep) — + // which can shift to an earlier step once server data lands (e.g. a required + // document turns out to be un-uploaded, so we must not rest on a later step). + const userNavigatedRef = useRef(false); + // On reopen, jump to the furthest step reached (initialStep) so progress - // never appears to reset. + // never appears to reset. Re-arm the follow-the-parent behaviour too. const wasOpen = useRef(resyncOpen); useEffect(() => { if (resyncOpen && !wasOpen.current && initialStep) { + userNavigatedRef.current = false; setStep(initialStep); setSaveError(null); } wasOpen.current = resyncOpen; // eslint-disable-next-line react-hooks/exhaustive-deps }, [resyncOpen]); + + // Follow a parent-driven resume correction: if initialStep changes (the wizard + // re-clamps it back once onboarding requirements load — e.g. a required + // document is still missing, so it must not skip ahead to Business License), + // adopt it, but only while the user hasn't started navigating themselves. + const lastInitialStep = useRef(initialStep); + useEffect(() => { + if (initialStep && initialStep !== lastInitialStep.current) { + lastInitialStep.current = initialStep; + if (!userNavigatedRef.current) { + setStep(initialStep); + setSaveError(null); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialStep]); const [internalFiles, setInternalFiles] = useState< Record >({}); @@ -410,7 +441,6 @@ export default function CompanyProfileForm({ woreda: "", kebele: "", houseNo: "", - etradePhone: "", contactPersonName: "", contactPersonPosition: "", contactPersonEmail: "", @@ -482,7 +512,7 @@ export default function CompanyProfileForm({ setValue("kebele", data.kebele); setValue("houseNo", data.houseNo); setValue( - "etradePhone", + "companyPhone", toEthiopianE164(data.regularPhone || data.mobilePhone), ); // companyAddress is composed reactively from the address fields below, so @@ -530,17 +560,6 @@ export default function CompanyProfileForm({ setValue("poaPhone", watch("contactPersonPhone")); }; - /** Populate the Contact Person from the currently logged-in user. */ - const useLoggedInUserAsContact = () => { - setValue("contactPersonName", user?.name?.en ?? "", { - shouldValidate: true, - }); - if (user?.email) setValue("contactPersonEmail", user.email); - setValue("contactPersonPhone", user?.phoneNumber ?? "", { - shouldValidate: true, - }); - }; - // --- Contact-phone SMS OTP verification ----------------------------------- // The phone we verify is the contact-person phone, normalised to E.164 so it // matches what the backend persists as `contactVerifiedPhone`. @@ -612,7 +631,7 @@ export default function CompanyProfileForm({ setOtpSent(false); // Persist the verified phone so the step resumes as "done" after a refresh // (best-effort — the OTP itself already succeeded server-side). - onSaveStep?.({ contactVerifiedPhone: contactPhoneE164 }).catch(() => {}); + onSaveStep?.({ contactVerifiedPhone: contactPhoneE164 }).catch(() => { }); } catch (err) { setOtpError(extractApiError(err).message); } finally { @@ -675,6 +694,7 @@ export default function CompanyProfileForm({ ); const nextStep = async () => { + userNavigatedRef.current = true; if (step === "additional") { if (!licenseComplete) { setSaveError( @@ -699,16 +719,34 @@ export default function CompanyProfileForm({ setStep(stepOrder[currentIdx + 1]); return; } - // The documents step has nothing to persist; field steps validate + save - // before advancing. - if (step !== "documents") { - const ok = await saveCurrentStep(); - if (!ok) return; + // The documents step auto-uploads whatever the user selected as they + // continue (partial uploads are allowed — required-doc completeness is + // re-checked on resume). A failed upload holds them on the step. + if (step === "documents") { + if (onUploadDocuments) { + setSaving(true); + try { + const res = await onUploadDocuments(); + if (!res.ok) { + setSaveError(res.error); + return; + } + } finally { + setSaving(false); + } + } + setSaveError(null); + setStep(stepOrder[currentIdx + 1]); + return; } + // Field steps validate + save before advancing. + const ok = await saveCurrentStep(); + if (!ok) return; setStep(stepOrder[currentIdx + 1]); }; const prevStep = () => { + userNavigatedRef.current = true; setSaveError(null); if (currentIdx === 0) onBack(); else setStep(stepOrder[currentIdx - 1]); @@ -864,11 +902,6 @@ export default function CompanyProfileForm({ error={errors.houseNo?.message} {...register("houseNo")} /> - )} @@ -922,15 +955,6 @@ export default function CompanyProfileForm({ Contact Person - {watch("generalManagerName") && ( ) : ( - - Enter the 6-digit code we sent to{" "} - {maskPhone(contactPhoneE164)}. - @@ -1056,7 +1079,9 @@ export default function CompanyProfileForm({ disabled={resendIn > 0 || sendingOtp} leftSection={} > - {resendIn > 0 ? `Resend in ${resendIn}s` : "Resend code"} + {resendIn > 0 + ? `Resend in ${resendIn}s` + : "Resend code"} @@ -1147,6 +1172,8 @@ export default function CompanyProfileForm({ )} @@ -1210,19 +1237,12 @@ export default function CompanyProfileForm({ } loading={isPending || saving} rightSection={ - !isPending && - !saving && - step !== "additional" && - step !== "documents" ? ( + !isPending && !saving && step !== "additional" ? ( ) : undefined } > - {step === "documents" || step === "verify" - ? "Continue" - : step === "additional" - ? "Submit for review" - : "Save & Continue"} + {step === "additional" ? "Submit for review" : "Continue"} diff --git a/packages/ui-common/src/components/SmartFileInput/index.tsx b/packages/ui-common/src/components/SmartFileInput/index.tsx index f025e2465..a6c8a202b 100644 --- a/packages/ui-common/src/components/SmartFileInput/index.tsx +++ b/packages/ui-common/src/components/SmartFileInput/index.tsx @@ -1,8 +1,5 @@ import React, { useState, useMemo, useRef } from "react"; -import { - IFileUploadSetting, - IFileUploadField, -} from "@edr/types/freight"; +import { IFileUploadSetting, IFileUploadField } from "@edr/types/freight"; import { UploadCloud, FileText, @@ -24,12 +21,20 @@ export interface SmartFileInputProps { onChange?: (value: Record) => void; /** External form errors mapped by fileKey. */ errors?: Record; + /** + * fileKeys whose document is already uploaded on the server. Such fields show + * an "Already uploaded" badge and a replace-oriented dropzone hint, even when + * no in-memory File is currently selected for them. + */ + uploadedKeys?: string[]; /** Disabled state for the entire file input group. */ disabled?: boolean; /** Display variant style. Default is "default" (large dropzone). Minimal renders a compact upload button. */ variant?: "default" | "minimal"; /** Optional custom container CSS classes. */ className?: string; + + containerClassName?: string; } /** Helper to format file sizes in bytes to a human-readable string. */ @@ -45,23 +50,23 @@ function formatBytes(bytes: number, decimals = 2) { /** Render a suitable icon based on file extension. */ function FileIcon({ name, className }: { name: string; className?: string }) { const ext = name.split(".").pop()?.toLowerCase() || ""; - + if (ext === "pdf") { return ; } - + if (["png", "jpg", "jpeg", "webp", "svg", "gif"].includes(ext)) { return ; } - + if (["csv", "xls", "xlsx"].includes(ext)) { return ; } - + if (["zip", "rar", "tar", "gz", "7z"].includes(ext)) { return ; } - + return ; } @@ -70,16 +75,20 @@ export function SmartFileInput({ value, onChange, errors, + uploadedKeys, disabled = false, variant = "default", className, + containerClassName, }: SmartFileInputProps) { // Local state to manage files when the component is used in an uncontrolled manner - const [internalFiles, setInternalFiles] = useState>({}); - + const [internalFiles, setInternalFiles] = useState>( + {}, + ); + // Local validation errors const [localErrors, setLocalErrors] = useState>({}); - + // Drag-and-drop state active per field const [dragActive, setDragActive] = useState>({}); @@ -93,10 +102,13 @@ export function SmartFileInput({ // Create a map of fields for quick lookup const fieldsMap = useMemo(() => { - return file.fields.reduce((acc, currentField) => { - acc[currentField.fileKey] = currentField; - return acc; - }, {} as Record); + return file.fields.reduce( + (acc, currentField) => { + acc[currentField.fileKey] = currentField; + return acc; + }, + {} as Record, + ); }, [file.fields]); // Resolve current files list for a field @@ -109,8 +121,8 @@ export function SmartFileInput({ const handleFilesChange = (fieldKey: string, newFiles: File[]) => { const field = fieldsMap[fieldKey]; if (!field) return; - - const newValue = field.isMultiple ? newFiles : (newFiles[0] || null); + + const newValue = field.isMultiple ? newFiles : newFiles[0] || null; if (onChange) { const updatedValues = { @@ -129,10 +141,10 @@ export function SmartFileInput({ const processFiles = (field: IFileUploadField, incomingFiles: File[]) => { const currentFiles = getFilesForField(field.fileKey); const maxAllowed = field.isMultiple ? Math.max(1, field.maxFiles) : 1; - + // Clean up extensions (e.g. '.pdf' or 'pdf' -> 'pdf') const allowedExts = field.allowedExtensions.map((ext) => - ext.toLowerCase().replace(/^\./, "") + ext.toLowerCase().replace(/^\./, ""), ); let validIncoming: File[] = []; @@ -140,13 +152,12 @@ export function SmartFileInput({ for (const fileObj of incomingFiles) { const ext = fileObj.name.split(".").pop()?.toLowerCase() || ""; - const isExtValid = - allowedExts.length === 0 || allowedExts.includes(ext); + const isExtValid = allowedExts.length === 0 || allowedExts.includes(ext); const isSizeValid = fileObj.size <= field.maxSizeMb * 1024 * 1024; if (!isExtValid) { errorMsg = `Invalid file extension. Allowed: ${field.allowedExtensions.join( - ", " + ", ", )}`; break; } @@ -187,7 +198,11 @@ export function SmartFileInput({ handleFilesChange(field.fileKey, newFilesList); }; - const handleDrag = (e: React.DragEvent, fieldKey: string, active: boolean) => { + const handleDrag = ( + e: React.DragEvent, + fieldKey: string, + active: boolean, + ) => { e.preventDefault(); e.stopPropagation(); if (disabled) return; @@ -208,7 +223,7 @@ export function SmartFileInput({ const handleFileSelect = ( e: React.ChangeEvent, - field: IFileUploadField + field: IFileUploadField, ) => { if (e.target.files && e.target.files.length > 0) { const filesArray = Array.from(e.target.files); @@ -246,179 +261,275 @@ export function SmartFileInput({ {file.description} )} - - {sortedFields.map((field) => { - const currentFiles = getFilesForField(field.fileKey); - const maxFiles = field.isMultiple ? Math.max(1, field.maxFiles) : 1; - const reachedLimit = currentFiles.length >= maxFiles; - const fieldError = errors?.[field.fileKey] || localErrors[field.fileKey]; - const isDragOver = dragActive[field.fileKey]; - // Format accepted files for the HTML input element - const acceptString = field.allowedExtensions - .map((ext) => (ext.startsWith(".") ? ext : `.${ext}`)) - .join(","); +
+ {sortedFields.map((field) => { + const currentFiles = getFilesForField(field.fileKey); + const maxFiles = field.isMultiple ? Math.max(1, field.maxFiles) : 1; + const reachedLimit = currentFiles.length >= maxFiles; + const fieldError = + errors?.[field.fileKey] || localErrors[field.fileKey]; + const isDragOver = dragActive[field.fileKey]; + // Already uploaded server-side and nothing newly picked to replace it. + const isUploaded = + (uploadedKeys?.includes(field.fileKey) ?? false) && + currentFiles.length === 0; - return ( -
- {/* Field Header */} -
- - - - Max size: {field.maxSizeMb}MB - {field.isMultiple && ` • Files: ${currentFiles.length}/${maxFiles}`} - -
+ // Format accepted files for the HTML input element + const acceptString = field.allowedExtensions + .map((ext) => (ext.startsWith(".") ? ext : `.${ext}`)) + .join(","); - {/* Help / Description Text */} - {field.helpText && ( -

{field.helpText}

- )} - - {/* Selected Files List */} - {currentFiles.length > 0 && ( -
- {currentFiles.map((fileObj, idx) => ( -
+ {/* Field Header */} +
+
); } From f51488e9897abc88290e289e8bce06362950ae2c Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 26 Jun 2026 12:41:52 +0000 Subject: [PATCH 02/38] fix: the use person x button to checkbox --- .../src/pages/accounts/CompanyProfileForm.tsx | 173 ++++++++++++------ 1 file changed, 121 insertions(+), 52 deletions(-) diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index 3f07d7059..09c632d56 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -9,6 +9,7 @@ import { Stack, Text, TextInput, + UnstyledButton, } from "@mantine/core"; import { zodResolver } from "@hookform/resolvers/zod"; import { useQuery } from "@tanstack/react-query"; @@ -16,9 +17,9 @@ import { AlertCircle, ArrowLeft, ArrowRight, + Check, CheckCircle2, RotateCw, - ShieldCheck, Smartphone, UserCheck, } from "lucide-react"; @@ -282,6 +283,53 @@ function toFormValues(p: ProfileResponse): FormData { }; } +/** + * A card styled as a large checkbox: clicking it toggles `checked`, which the + * caller uses to prefill + lock a set of fields (and clear them on uncheck). + */ +function LinkCheckboxCard({ + checked, + onToggle, + title, + description, +}: { + checked: boolean; + onToggle: (checked: boolean) => void; + title: string; + description: string; +}) { + return ( + onToggle(!checked)} + role="checkbox" + aria-checked={checked} + className={`w-full rounded-lg border! p-3! text-left transition-colors! ${checked + ? "border-[var(--mantine-color-edr-green-6)]! bg-[var(--mantine-color-edr-green-0)]!" + : "border-[var(--mantine-color-gray-3)]! hover:border-[var(--mantine-color-edr-green-4)]! hover:bg-[var(--mantine-color-edr-green-0)]!" + }`} + > + +
+ {checked && } +
+
+ + {title} + + + {description} + +
+
+
+ ); +} + /** A single read-only registration value rendered as a label/value pair. */ function ReadOnlyField({ label, value }: { label: string; value?: string }) { return ( @@ -352,7 +400,9 @@ export default function CompanyProfileForm({ * "Continue" action). Resolves to an error message string on failure so the * step can surface it and hold the user in place. */ - onUploadDocuments?: () => Promise<{ ok: true } | { ok: false; error: string }>; + onUploadDocuments?: () => Promise< + { ok: true } | { ok: false; error: string } + >; }) { const [step, setStep] = useState(initialStep ?? "company"); const [saving, setSaving] = useState(false); @@ -542,22 +592,54 @@ export default function CompanyProfileForm({ }); }; - /** Copy the General Manager into the Contact Person fields (still editable). */ - const useGmAsContact = () => { - setValue("contactPersonName", watch("generalManagerName"), { - shouldValidate: true, - }); - setValue("contactPersonEmail", watch("generalManagerEmail")); - setValue("contactPersonPhone", watch("generalManagerPhone"), { - shouldValidate: true, - }); + // "Same as …" links. A checked card prefills the target step's fields from the + // source step and disables them (kept mirrored while linked); unchecking clears + // them and re-enables editing. + const [contactSameAsGm, setContactSameAsGm] = useState(false); + const [poaSameAsContact, setPoaSameAsContact] = useState(false); + + const gmName = watch("generalManagerName"); + const gmEmail = watch("generalManagerEmail"); + const gmPhone = watch("generalManagerPhone"); + const contactName = watch("contactPersonName"); + const contactEmail = watch("contactPersonEmail"); + const contactPhone = watch("contactPersonPhone"); + + // While linked, mirror the source values into the (disabled) target fields so + // the copy stays current even if the user goes back and edits the source. + useEffect(() => { + if (!contactSameAsGm) return; + setValue("contactPersonName", gmName ?? "", { shouldValidate: true }); + setValue("contactPersonEmail", gmEmail ?? ""); + setValue("contactPersonPhone", gmPhone ?? "", { shouldValidate: true }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [contactSameAsGm, gmName, gmEmail, gmPhone]); + + useEffect(() => { + if (!poaSameAsContact) return; + setValue("poaName", contactName ?? ""); + setValue("poaEmail", contactEmail ?? ""); + setValue("poaPhone", contactPhone ?? ""); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [poaSameAsContact, contactName, contactEmail, contactPhone]); + + const toggleContactSameAsGm = (checked: boolean) => { + setContactSameAsGm(checked); + // Checked → the mirror effect fills the fields; unchecked → reset them. + if (!checked) { + setValue("contactPersonName", ""); + setValue("contactPersonEmail", ""); + setValue("contactPersonPhone", ""); + } }; - /** Copy the Contact Person into the PoA fields (still editable). */ - const useContactAsPoa = () => { - setValue("poaName", watch("contactPersonName")); - setValue("poaEmail", watch("contactPersonEmail")); - setValue("poaPhone", watch("contactPersonPhone")); + const togglePoaSameAsContact = (checked: boolean) => { + setPoaSameAsContact(checked); + if (!checked) { + setValue("poaName", ""); + setValue("poaEmail", ""); + setValue("poaPhone", ""); + } }; // --- Contact-phone SMS OTP verification ----------------------------------- @@ -950,24 +1032,17 @@ export default function CompanyProfileForm({ {step === "contact" && ( <> - - - Contact Person - - - {watch("generalManagerName") && ( - - )} - - + + Contact Person + + {watch("generalManagerName") && ( + + )} - - - Power of Attorney details are optional. Fill them in if you - have them, or skip to continue. - - {watch("contactPersonName") && ( - - )} - + + Power of Attorney details are optional. Fill them in if you have + them, or skip to continue. + + {watch("contactPersonName") && ( + + )} Date: Fri, 26 Jun 2026 13:00:11 +0000 Subject: [PATCH 03/38] fix: finish cleaning up the file syncing --- .../components/onboarding/RoleLicenseStep.tsx | 108 +++--- .../src/pages/accounts/CompanyProfileForm.tsx | 324 +----------------- .../companyProfileForm/LinkCheckboxCard.tsx | 51 +++ .../companyProfileForm/ReadOnlyField.tsx | 23 ++ .../accounts/companyProfileForm/helpers.ts | 142 ++++++++ .../accounts/companyProfileForm/schema.ts | 110 ++++++ 6 files changed, 404 insertions(+), 354 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/LinkCheckboxCard.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/ReadOnlyField.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts create mode 100644 apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts diff --git a/apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx b/apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx index 5206a4b32..adbeffc7c 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx @@ -2,13 +2,15 @@ import { Anchor, Badge, Card, - FileInput, Group, Stack, Text, ThemeIcon, } from "@mantine/core"; -import { FileText, Paperclip, Upload } from "lucide-react"; +import { FileText, Paperclip } from "lucide-react"; + +import { SmartFileInput } from "@edr/ui-common"; +import type { IFileUploadSetting } from "@edr/types/freight"; import type { LicenseFile } from "@/services/companies.service"; @@ -20,6 +22,48 @@ const ROLE_LABELS: Record = { transporter: "Transporter", }; +/** Field key the synthesized per-profile upload setting is keyed on. */ +const LICENSE_FILE_KEY = "business_license"; + +/** + * Build a single-field upload setting so each profile's license input can reuse + * the shared SmartFileInput (same dropzone + "uploaded" state as the documents + * step), instead of a bespoke file picker. + */ +function buildLicenseSetting( + profileId: string, + profileName: string, +): IFileUploadSetting { + return { + id: `license-setting-${profileId}`, + createdAt: "", + updatedAt: "", + deletedAt: null, + code: "business_license", + label: "Business license", + description: null, + entity: "customer", + fields: [ + { + id: `${LICENSE_FILE_KEY}-${profileId}`, + createdAt: "", + updatedAt: "", + deletedAt: null, + settingId: `license-setting-${profileId}`, + fileKey: LICENSE_FILE_KEY, + fileLabel: `Upload ${profileName} Business license file(s)`, + helpText: null, + isRequired: true, + isMultiple: true, + maxFiles: 10, + allowedExtensions: ["pdf", "png", "jpg", "jpeg"], + maxSizeMb: 10, + order: 1, + }, + ], + }; +} + export interface RoleLicenseProfile { id: string; type: string; @@ -38,8 +82,9 @@ interface RoleLicenseStepProps { /** * Final onboarding step: collect a business license (one or more files) for - * each operational role the company holds. Each role gets its own multi-file - * input; already-uploaded files are listed for context. + * each operational role the company holds. Each role gets its own SmartFileInput + * dropzone; already-uploaded files are listed (with download links) for context + * and surface the input's "uploaded" state. */ export default function RoleLicenseStep({ profiles, @@ -60,37 +105,11 @@ export default function RoleLicenseStep({ {profiles.map((profile) => { const label = ROLE_LABELS[profile.type] ?? profile.type; const selected = value[profile.id] ?? []; - const hasAny = selected.length > 0 || profile.existingFiles.length > 0; + const hasExisting = profile.existingFiles.length > 0; return ( - - - - - - -
- - {label} — Business License - - - {profile.reference} - -
-
- {hasAny && ( - - Provided - - )} -
- - {profile.existingFiles.length > 0 && ( + <> + {hasExisting && ( {profile.existingFiles.map((f) => ( @@ -108,20 +127,17 @@ export default function RoleLicenseStep({ )} - } - placeholder={ - profile.existingFiles.length > 0 - ? "Upload more / replace files" - : "Select license file(s)" - } - value={selected} - onChange={(files) => setFiles(profile.id, files ?? [])} + { + const next = v[LICENSE_FILE_KEY]; + const files = Array.isArray(next) ? next : next ? [next] : []; + setFiles(profile.id, files); + }} /> -
+ ); })} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index 09c632d56..bfde5c43b 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -9,7 +9,6 @@ import { Stack, Text, TextInput, - UnstyledButton, } from "@mantine/core"; import { zodResolver } from "@hookform/resolvers/zod"; import { useQuery } from "@tanstack/react-query"; @@ -17,7 +16,6 @@ import { AlertCircle, ArrowLeft, ArrowRight, - Check, CheckCircle2, RotateCw, Smartphone, @@ -25,17 +23,12 @@ import { } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; -import { z } from "zod"; import type { AuthUser } from "@/types/auth"; import type { CreateCompanyPayload } from "@/services/companies.service"; import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; import type { CompanyRegistrationData } from "@edr/types"; -import { - ControlledPhoneField, - isValidPhone, - toEthiopianE164, -} from "@/components/PhoneField"; +import { ControlledPhoneField, toEthiopianE164 } from "@/components/PhoneField"; import { SmartFileInput } from "@edr/ui-common"; import { api } from "@/services/api"; import RoleLicenseStep, { @@ -43,306 +36,21 @@ import RoleLicenseStep, { } from "@/components/onboarding/RoleLicenseStep"; import ETradeInfo from "@/components/onboarding/ETradeInfo"; import { extractApiError } from "@/utils/result"; - -type CompanyStep = - | "company" - | "personnel" - | "contact" - | "verify" - | "poa" - | "documents" - | "additional"; - -/** Last 9 digits (Ethiopian national significant number) for tolerant compare. */ -const phoneDigits = (p?: string | null) => - (p ?? "").replace(/\D/g, "").slice(-9); -const samePhone = (a?: string | null, b?: string | null) => { - const da = phoneDigits(a); - return da.length === 9 && da === phoneDigits(b); -}; -/** Mask all but the first 7 chars of an E.164 phone for display. */ -const maskPhone = (p: string) => - p.length > 4 ? `${p.slice(0, 7)}${"*".repeat(Math.max(0, p.length - 7))}` : p; - -const onboardingSchema = z.object({ - companyName: z.string().min(1, "Company name is required"), - companyEmail: z.string().email("Invalid email address"), - companyPhone: z - .string() - .min(1, "Company phone is required") - .refine(isValidPhone, "Enter a valid phone number"), - companyLocation: z.string().min(1, "Location is required"), - // Derived from the eTrade address parts (kebele/woreda/zone/region); no - // standalone input — the granular fields live in the registration section. - companyAddress: z.string().optional(), - tinNumber: z.string().length(10, "TIN must be exactly 10 digits"), - vatNumber: z - .string() - .min(1, "VAT number is required") - .length(10, "VAT number must be exactly 10 digits"), - fanNumber: z.string().length(16, "FAN must be exactly 16 digits"), - licenceNumber: z.string().optional(), - statusDescription: z.string().optional(), - dateRegistered: z.string().optional(), - renewedFrom: z.string().optional(), - renewalDate: z.string().optional(), - renewedTo: z.string().optional(), - // Address fields are user-entered and required (the registration/license - // fields above are read-only confirmations pulled from eTrade). - region: z.string().min(1, "Region is required"), - zone: z.string().min(1, "Zone is required"), - woreda: z.string().min(1, "Woreda is required"), - kebele: z.string().min(1, "Kebele is required"), - houseNo: z.string().min(1, "House number is required"), - contactPersonName: z.string().min(1, "Contact person name is required"), - contactPersonPosition: z.string().optional(), - contactPersonEmail: z - .string() - .email("Invalid email address") - .optional() - .or(z.literal("")), - contactPersonPhone: z - .string() - .min(1, "Contact person phone is required") - .refine(isValidPhone, "Enter a valid phone number"), - generalManagerName: z.string().min(1, "Manager name is required"), - generalManagerEmail: z.string().email("Invalid Manager email"), - generalManagerPhone: z - .string() - .min(1, "Manager phone is required") - .refine(isValidPhone, "Enter a valid phone number"), - poaName: z.string().optional(), - poaPhone: z - .string() - .optional() - .refine((v) => !v || isValidPhone(v), "Enter a valid phone number"), - poaAddress: z.string().optional(), - poaEmail: z.string().optional(), - poaLocation: z.string().optional(), -}); - -type FormData = z.infer; - -const stepFields: Record = { - company: [ - "companyName", - "companyEmail", - "companyPhone", - "companyLocation", - "companyAddress", - "tinNumber", - "vatNumber", - "fanNumber", - "licenceNumber", - "statusDescription", - "dateRegistered", - "renewedFrom", - "renewalDate", - "renewedTo", - "region", - "zone", - "woreda", - "kebele", - "houseNo", - ], - personnel: [ - "generalManagerName", - "generalManagerEmail", - "generalManagerPhone", - ], - contact: [ - "contactPersonName", - "contactPersonPosition", - "contactPersonEmail", - "contactPersonPhone", - ], - verify: [], - poa: [], - documents: [], - additional: [], -}; - -function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { - return { - companyName: data.companyName, - companyEmail: data.companyEmail, - companyPhone: data.companyPhone, - companyLocation: data.companyLocation, - companyAddress: data.companyAddress, - tin: data.tinNumber, - vatNumber: data.vatNumber, - fanNumber: data.fanNumber, - attributes: { - contactPersonName: data.contactPersonName, - contactPersonPosition: data.contactPersonPosition || undefined, - contactPersonEmail: data.contactPersonEmail || undefined, - contactPersonPhone: data.contactPersonPhone, - generalManagerName: data.generalManagerName, - generalManagerEmail: data.generalManagerEmail, - generalManagerPhone: data.generalManagerPhone, - poaName: data.poaName || undefined, - poaPhone: data.poaPhone || undefined, - poaAddress: data.poaAddress || undefined, - poaEmail: data.poaEmail || undefined, - poaLocation: data.poaLocation || undefined, - }, - }; -} - -/** Map one wizard step's form values to the profile-update payload it saves. */ -function stepPayload( - step: CompanyStep, - d: FormData, -): Partial { - switch (step) { - case "company": - return { - companyName: d.companyName, - companyEmail: d.companyEmail, - companyPhone: d.companyPhone, - companyLocation: d.companyLocation, - companyAddress: d.companyAddress, - tin: d.tinNumber, - vatNumber: d.vatNumber, - fanNumber: d.fanNumber, - licenceNumber: d.licenceNumber, - statusDescription: d.statusDescription, - dateRegistered: d.dateRegistered, - renewedFrom: d.renewedFrom, - renewalDate: d.renewalDate, - renewedTo: d.renewedTo, - region: d.region, - zone: d.zone, - woreda: d.woreda, - kebele: d.kebele, - houseNo: d.houseNo, - etradePhone: d.companyPhone, - }; - case "personnel": - return { - generalManagerName: d.generalManagerName, - generalManagerEmail: d.generalManagerEmail, - generalManagerPhone: d.generalManagerPhone, - }; - case "contact": - return { - contactPersonName: d.contactPersonName, - contactPersonPosition: d.contactPersonPosition || undefined, - contactPersonEmail: d.contactPersonEmail || undefined, - contactPersonPhone: d.contactPersonPhone, - }; - case "poa": - return { - poaName: d.poaName || undefined, - poaPhone: d.poaPhone || undefined, - poaEmail: d.poaEmail || undefined, - poaLocation: d.poaLocation || undefined, - poaAddress: d.poaAddress || undefined, - }; - default: - return {}; - } -} - -/** Seed the form from previously-saved profile data. */ -function toFormValues(p: ProfileResponse): FormData { - // The draft placeholder TIN ("D…") shouldn't show as a real value. - const tin = p.tinNumber && !p.tinNumber.startsWith("D") ? p.tinNumber : ""; - return { - companyName: p.companyName ?? "", - companyEmail: p.companyEmail ?? "", - companyPhone: p.companyPhone ?? "", - companyLocation: p.companyLocation ?? "", - companyAddress: p.companyAddress ?? "", - tinNumber: tin, - vatNumber: p.vatNumber ?? "", - fanNumber: p.fanNumber ?? "", - licenceNumber: p.licenceNumber ?? "", - statusDescription: p.statusDescription ?? "", - dateRegistered: p.dateRegistered ?? "", - renewedFrom: p.renewedFrom ?? "", - renewalDate: p.renewalDate ?? "", - renewedTo: p.renewedTo ?? "", - region: p.region ?? "", - zone: p.zone ?? "", - woreda: p.woreda ?? "", - kebele: p.kebele ?? "", - houseNo: p.houseNo ?? "", - contactPersonName: p.contactPersonName ?? "", - contactPersonPosition: p.contactPersonPosition ?? "", - contactPersonEmail: p.contactPersonEmail ?? "", - contactPersonPhone: p.contactPersonPhone ?? "", - generalManagerName: p.generalManagerName ?? "", - generalManagerEmail: p.generalManagerEmail ?? "", - generalManagerPhone: p.generalManagerPhone ?? "", - poaName: p.poaName ?? "", - poaPhone: p.poaPhone ?? "", - poaAddress: p.poaAddress ?? "", - poaEmail: p.poaEmail ?? "", - poaLocation: p.poaLocation ?? "", - }; -} - -/** - * A card styled as a large checkbox: clicking it toggles `checked`, which the - * caller uses to prefill + lock a set of fields (and clear them on uncheck). - */ -function LinkCheckboxCard({ - checked, - onToggle, - title, - description, -}: { - checked: boolean; - onToggle: (checked: boolean) => void; - title: string; - description: string; -}) { - return ( - onToggle(!checked)} - role="checkbox" - aria-checked={checked} - className={`w-full rounded-lg border! p-3! text-left transition-colors! ${checked - ? "border-[var(--mantine-color-edr-green-6)]! bg-[var(--mantine-color-edr-green-0)]!" - : "border-[var(--mantine-color-gray-3)]! hover:border-[var(--mantine-color-edr-green-4)]! hover:bg-[var(--mantine-color-edr-green-0)]!" - }`} - > - -
- {checked && } -
-
- - {title} - - - {description} - -
-
-
- ); -} - -/** A single read-only registration value rendered as a label/value pair. */ -function ReadOnlyField({ label, value }: { label: string; value?: string }) { - return ( - - - {label} - - - {value && value.trim() ? value : "—"} - - - ); -} +import { + type CompanyStep, + type FormData, + onboardingSchema, + stepFields, +} from "./companyProfileForm/schema"; +import { + buildPayload, + maskPhone, + samePhone, + stepPayload, + toFormValues, +} from "./companyProfileForm/helpers"; +import { LinkCheckboxCard } from "./companyProfileForm/LinkCheckboxCard"; +import { ReadOnlyField } from "./companyProfileForm/ReadOnlyField"; export default function CompanyProfileForm({ documentSettingCode, diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/LinkCheckboxCard.tsx b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/LinkCheckboxCard.tsx new file mode 100644 index 000000000..da0dd6254 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/LinkCheckboxCard.tsx @@ -0,0 +1,51 @@ +import { Group, Text, UnstyledButton } from "@mantine/core"; +import { Check } from "lucide-react"; + +/** + * A card styled as a large checkbox: clicking it toggles `checked`, which the + * caller uses to prefill + lock a set of fields (and clear them on uncheck). + */ +export function LinkCheckboxCard({ + checked, + onToggle, + title, + description, +}: { + checked: boolean; + onToggle: (checked: boolean) => void; + title: string; + description: string; +}) { + return ( + onToggle(!checked)} + role="checkbox" + aria-checked={checked} + className={`w-full rounded-lg border! p-3! text-left transition-colors! ${checked + ? "border-[var(--mantine-color-edr-green-6)]! bg-[var(--mantine-color-edr-green-0)]!" + : "border-[var(--mantine-color-gray-3)]! hover:border-[var(--mantine-color-edr-green-4)]! hover:bg-[var(--mantine-color-edr-green-0)]!" + }`} + > + +
+ {checked && } +
+
+ + {title} + + + {description} + +
+
+
+ ); +} + +export default LinkCheckboxCard; diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/ReadOnlyField.tsx b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/ReadOnlyField.tsx new file mode 100644 index 000000000..6465375e7 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/ReadOnlyField.tsx @@ -0,0 +1,23 @@ +import { Stack, Text } from "@mantine/core"; + +/** A single read-only registration value rendered as a label/value pair. */ +export function ReadOnlyField({ + label, + value, +}: { + label: string; + value?: string; +}) { + return ( + + + {label} + + + {value && value.trim() ? value : "—"} + + + ); +} + +export default ReadOnlyField; diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts new file mode 100644 index 000000000..8f8a24eca --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts @@ -0,0 +1,142 @@ +import type { AuthUser } from "@/types/auth"; +import type { CreateCompanyPayload } from "@/services/companies.service"; +import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; + +import type { CompanyStep, FormData } from "./schema"; + +/** Last 9 digits (Ethiopian national significant number) for tolerant compare. */ +export const phoneDigits = (p?: string | null) => + (p ?? "").replace(/\D/g, "").slice(-9); + +export const samePhone = (a?: string | null, b?: string | null) => { + const da = phoneDigits(a); + return da.length === 9 && da === phoneDigits(b); +}; + +/** Mask all but the first 7 chars of an E.164 phone for display. */ +export const maskPhone = (p: string) => + p.length > 4 ? `${p.slice(0, 7)}${"*".repeat(Math.max(0, p.length - 7))}` : p; + +export function buildPayload( + data: FormData, + _user: AuthUser, +): CreateCompanyPayload { + return { + companyName: data.companyName, + companyEmail: data.companyEmail, + companyPhone: data.companyPhone, + companyLocation: data.companyLocation, + companyAddress: data.companyAddress, + tin: data.tinNumber, + vatNumber: data.vatNumber, + fanNumber: data.fanNumber, + attributes: { + contactPersonName: data.contactPersonName, + contactPersonPosition: data.contactPersonPosition || undefined, + contactPersonEmail: data.contactPersonEmail || undefined, + contactPersonPhone: data.contactPersonPhone, + generalManagerName: data.generalManagerName, + generalManagerEmail: data.generalManagerEmail, + generalManagerPhone: data.generalManagerPhone, + poaName: data.poaName || undefined, + poaPhone: data.poaPhone || undefined, + poaAddress: data.poaAddress || undefined, + poaEmail: data.poaEmail || undefined, + poaLocation: data.poaLocation || undefined, + }, + }; +} + +/** Map one wizard step's form values to the profile-update payload it saves. */ +export function stepPayload( + step: CompanyStep, + d: FormData, +): Partial { + switch (step) { + case "company": + return { + companyName: d.companyName, + companyEmail: d.companyEmail, + companyPhone: d.companyPhone, + companyLocation: d.companyLocation, + companyAddress: d.companyAddress, + tin: d.tinNumber, + vatNumber: d.vatNumber, + fanNumber: d.fanNumber, + licenceNumber: d.licenceNumber, + statusDescription: d.statusDescription, + dateRegistered: d.dateRegistered, + renewedFrom: d.renewedFrom, + renewalDate: d.renewalDate, + renewedTo: d.renewedTo, + region: d.region, + zone: d.zone, + woreda: d.woreda, + kebele: d.kebele, + houseNo: d.houseNo, + etradePhone: d.companyPhone, + }; + case "personnel": + return { + generalManagerName: d.generalManagerName, + generalManagerEmail: d.generalManagerEmail, + generalManagerPhone: d.generalManagerPhone, + }; + case "contact": + return { + contactPersonName: d.contactPersonName, + contactPersonPosition: d.contactPersonPosition || undefined, + contactPersonEmail: d.contactPersonEmail || undefined, + contactPersonPhone: d.contactPersonPhone, + }; + case "poa": + return { + poaName: d.poaName || undefined, + poaPhone: d.poaPhone || undefined, + poaEmail: d.poaEmail || undefined, + poaLocation: d.poaLocation || undefined, + poaAddress: d.poaAddress || undefined, + }; + default: + return {}; + } +} + +/** Seed the form from previously-saved profile data. */ +export function toFormValues(p: ProfileResponse): FormData { + // The draft placeholder TIN ("D…") shouldn't show as a real value. + const tin = p.tinNumber && !p.tinNumber.startsWith("D") ? p.tinNumber : ""; + return { + companyName: p.companyName ?? "", + companyEmail: p.companyEmail ?? "", + companyPhone: p.companyPhone ?? "", + companyLocation: p.companyLocation ?? "", + companyAddress: p.companyAddress ?? "", + tinNumber: tin, + vatNumber: p.vatNumber ?? "", + fanNumber: p.fanNumber ?? "", + licenceNumber: p.licenceNumber ?? "", + statusDescription: p.statusDescription ?? "", + dateRegistered: p.dateRegistered ?? "", + renewedFrom: p.renewedFrom ?? "", + renewalDate: p.renewalDate ?? "", + renewedTo: p.renewedTo ?? "", + region: p.region ?? "", + zone: p.zone ?? "", + woreda: p.woreda ?? "", + kebele: p.kebele ?? "", + houseNo: p.houseNo ?? "", + contactPersonName: p.contactPersonName ?? "", + contactPersonPosition: p.contactPersonPosition ?? "", + contactPersonEmail: p.contactPersonEmail ?? "", + contactPersonPhone: p.contactPersonPhone ?? "", + generalManagerName: p.generalManagerName ?? "", + generalManagerEmail: p.generalManagerEmail ?? "", + generalManagerPhone: p.generalManagerPhone ?? "", + poaName: p.poaName ?? "", + poaPhone: p.poaPhone ?? "", + poaAddress: p.poaAddress ?? "", + poaEmail: p.poaEmail ?? "", + poaLocation: p.poaLocation ?? "", + }; +} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts new file mode 100644 index 000000000..31ee21ced --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts @@ -0,0 +1,110 @@ +import { z } from "zod"; + +import { isValidPhone } from "@/components/PhoneField"; + +export type CompanyStep = + | "company" + | "personnel" + | "contact" + | "verify" + | "poa" + | "documents" + | "additional"; + +export const onboardingSchema = z.object({ + companyName: z.string().min(1, "Company name is required"), + companyEmail: z.string().email("Invalid email address"), + companyPhone: z + .string() + .min(1, "Company phone is required") + .refine(isValidPhone, "Enter a valid phone number"), + companyLocation: z.string().min(1, "Location is required"), + // Derived from the eTrade address parts (kebele/woreda/zone/region); no + // standalone input — the granular fields live in the registration section. + companyAddress: z.string().optional(), + tinNumber: z.string().length(10, "TIN must be exactly 10 digits"), + vatNumber: z + .string() + .min(1, "VAT number is required") + .length(10, "VAT number must be exactly 10 digits"), + fanNumber: z.string().length(16, "FAN must be exactly 16 digits"), + licenceNumber: z.string().optional(), + statusDescription: z.string().optional(), + dateRegistered: z.string().optional(), + renewedFrom: z.string().optional(), + renewalDate: z.string().optional(), + renewedTo: z.string().optional(), + // Address fields are user-entered and required (the registration/license + // fields above are read-only confirmations pulled from eTrade). + region: z.string().min(1, "Region is required"), + zone: z.string().min(1, "Zone is required"), + woreda: z.string().min(1, "Woreda is required"), + kebele: z.string().min(1, "Kebele is required"), + houseNo: z.string().min(1, "House number is required"), + contactPersonName: z.string().min(1, "Contact person name is required"), + contactPersonPosition: z.string().optional(), + contactPersonEmail: z + .string() + .email("Invalid email address") + .optional() + .or(z.literal("")), + contactPersonPhone: z + .string() + .min(1, "Contact person phone is required") + .refine(isValidPhone, "Enter a valid phone number"), + generalManagerName: z.string().min(1, "Manager name is required"), + generalManagerEmail: z.string().email("Invalid Manager email"), + generalManagerPhone: z + .string() + .min(1, "Manager phone is required") + .refine(isValidPhone, "Enter a valid phone number"), + poaName: z.string().optional(), + poaPhone: z + .string() + .optional() + .refine((v) => !v || isValidPhone(v), "Enter a valid phone number"), + poaAddress: z.string().optional(), + poaEmail: z.string().optional(), + poaLocation: z.string().optional(), +}); + +export type FormData = z.infer; + +export const stepFields: Record = { + company: [ + "companyName", + "companyEmail", + "companyPhone", + "companyLocation", + "companyAddress", + "tinNumber", + "vatNumber", + "fanNumber", + "licenceNumber", + "statusDescription", + "dateRegistered", + "renewedFrom", + "renewalDate", + "renewedTo", + "region", + "zone", + "woreda", + "kebele", + "houseNo", + ], + personnel: [ + "generalManagerName", + "generalManagerEmail", + "generalManagerPhone", + ], + contact: [ + "contactPersonName", + "contactPersonPosition", + "contactPersonEmail", + "contactPersonPhone", + ], + verify: [], + poa: [], + documents: [], + additional: [], +}; From 1c076e0694b9403f032be4dff73d7c902d5d7550 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 26 Jun 2026 13:05:34 +0000 Subject: [PATCH 04/38] fix: rm the global approve --- .../pages/customers/CustomerDetailPage.tsx | 43 ++++++------------- 1 file changed, 12 insertions(+), 31 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx index b1e05ab3d..0d8a4fa29 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -22,7 +22,7 @@ import { LayoutGrid, Package, } from "lucide-react"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { useMemo } from "react"; import { useNavigate, useParams } from "react-router-dom"; @@ -84,9 +84,6 @@ export default function CustomerDetailPage() { enabled: Boolean(id), }), ); - const approveMutation = useMutation( - api.customers.setCompanyStatus.mutationOptions(), - ); const bookingsQuery = useQuery( api.customers.bookings.queryOptions({ input: { id: id ?? "" }, @@ -394,28 +391,12 @@ export default function CustomerDetailPage() { ]} backTo="/dashboard/customers" title={company.name} - subtitle={`TIN ${company.tin}${ - company.country ? ` · ${company.country}` : "" - }`} + subtitle={`TIN ${company.tin}${company.country ? ` · ${company.country}` : "" + }`} meta={ - {company.status === "pending" && ( - - )} } /> @@ -546,9 +527,9 @@ export default function CustomerDetailPage() { error={ bookingsQuery.isError ? { - message: "Failed to load bookings.", - onRetry: () => void bookingsQuery.refetch(), - } + message: "Failed to load bookings.", + onRetry: () => void bookingsQuery.refetch(), + } : undefined } /> @@ -567,9 +548,9 @@ export default function CustomerDetailPage() { error={ documentsQuery.isError ? { - message: "Failed to load documents.", - onRetry: () => void documentsQuery.refetch(), - } + message: "Failed to load documents.", + onRetry: () => void documentsQuery.refetch(), + } : undefined } /> @@ -588,9 +569,9 @@ export default function CustomerDetailPage() { error={ paymentsQuery.isError ? { - message: "Failed to load payments.", - onRetry: () => void paymentsQuery.refetch(), - } + message: "Failed to load payments.", + onRetry: () => void paymentsQuery.refetch(), + } : undefined } /> From 9c9348d12d3d0d5da936ae8d21ab3c7aa41341d1 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 26 Jun 2026 13:07:29 +0000 Subject: [PATCH 05/38] fix: unused imports --- .../src/components/onboarding/RoleLicenseStep.tsx | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx b/apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx index adbeffc7c..451222841 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx @@ -1,13 +1,5 @@ -import { - Anchor, - Badge, - Card, - Group, - Stack, - Text, - ThemeIcon, -} from "@mantine/core"; -import { FileText, Paperclip } from "lucide-react"; +import { Anchor, Group, Stack, Text } from "@mantine/core"; +import { Paperclip } from "lucide-react"; import { SmartFileInput } from "@edr/ui-common"; import type { IFileUploadSetting } from "@edr/types/freight"; From 108d91b5fa1aa118021d4d4dd568ac1c324c7226 Mon Sep 17 00:00:00 2001 From: hagiye Date: Sat, 27 Jun 2026 04:58:21 +0300 Subject: [PATCH 06/38] Invoice and clearance seal approval --- .../warehouses/dto/bulk-receive.dto.ts | 5 + .../warehouses/warehouse-inventory.service.ts | 204 ++++++++++++++++-- .../warehouse-release-document.service.ts | 116 +++++++--- .../modules/warehouses/warehouses.module.ts | 2 + .../warehouses/ReceiveInventoryModal.tsx | 147 ++++++++++++- .../backoffice/src/types/warehouse.ts | 8 + 6 files changed, 427 insertions(+), 55 deletions(-) diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts index 2feadaccd..4f0ce0012 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts @@ -22,6 +22,11 @@ export class TruckEntranceDto { @IsString() tin?: string; + @ApiPropertyOptional() + @IsOptional() + @IsString() + customerPhone?: string; + @ApiProperty() @IsString() truckPlateNumber!: string; 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 ab3adb64a..662122438 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 @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; @@ -6,6 +6,7 @@ import { Cargo } from '../cargoes/entities/cargoes.entity'; import { InterchangeDocumentsService } from '../interchange-documents/interchange-documents.service'; import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity'; import { LastMileService } from '../last-mile/last-mile.service'; +import { NotificationsService } from '../notifications/notifications.service'; import { BulkInspectDto } from './dto/bulk-inspect.dto'; import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto'; import { DeliverInventoryDto } from './dto/deliver-inventory.dto'; @@ -191,6 +192,9 @@ export interface EligibleBookingRow { reference: string; customerId: string | null; customer: string | null; + customerTin: string | null; + customerPhone: string | null; + lastMileRequested: boolean; direction: string; origin: string | null; destination: string | null; @@ -205,6 +209,10 @@ export interface EligibleBookingRow { firstMileVehicleId: string | null; firstMileTruckPlateNumber: string | null; firstMileTrailerPlateNumber: string | null; + firstMileDriverName: string | null; + firstMileDriverPhone: string | null; + firstMileDriverLicenseNumber: string | null; + firstMileTruckType: string | null; } export interface BulkReceiveResult { @@ -289,6 +297,8 @@ export interface ImportUnloadedRow { @Injectable() export class WarehouseInventoryService { + private readonly logger = new Logger(WarehouseInventoryService.name); + constructor( private readonly dataSource: DataSource, private readonly inventoryRepository: WarehouseInventoryRepository, @@ -301,6 +311,7 @@ export class WarehouseInventoryService { private readonly releaseDocuments: WarehouseReleaseDocumentService, private readonly interchangeDocuments: InterchangeDocumentsService, private readonly lastMileService: LastMileService, + private readonly notifications: NotificationsService, ) {} /** @@ -644,6 +655,10 @@ export class WarehouseInventoryService { b.reference AS "reference", b.company_id AS "customerId", company.name AS "customer", + company.tin AS "customerTin", + COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone", + (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL + OR COALESCE(st.includes_last_mile, false)) AS "lastMileRequested", oy.code AS "origin", dy.code AS "destination", oy.country AS "originCountry", @@ -659,7 +674,14 @@ export class WarehouseInventoryService { fm.status AS "firstMileStatus", fm.vehicle_id AS "firstMileVehicleId", v.plate_number AS "firstMileTruckPlateNumber", - v.trailer_plate_no AS "firstMileTrailerPlateNumber" + v.trailer_plate_no AS "firstMileTrailerPlateNumber", + COALESCE( + NULLIF(TRIM(CONCAT(COALESCE(driver.first_name, ''), ' ', COALESCE(driver.last_name, ''))), ''), + v.assigned_driver_name + ) AS "firstMileDriverName", + driver.phone_number AS "firstMileDriverPhone", + driver.license_number AS "firstMileDriverLicenseNumber", + v.vehicle_type AS "firstMileTruckType" FROM freight.bookings b LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id @@ -675,6 +697,7 @@ export class WarehouseInventoryService { LIMIT 1 ) fm ON true LEFT JOIN freight.vehicles v ON v.id = fm.vehicle_id + LEFT JOIN freight.drivers driver ON driver.id = v.assigned_driver_id WHERE b.deleted_at IS NULL AND b.payment_status = 'PAID' AND inv.id IS NULL @@ -695,7 +718,6 @@ export class WarehouseInventoryService { /** Bulk-receive eligible PAID bookings into a location. Skips duplicates / wrong direction. */ async bulkReceive(dto: BulkReceiveDto): Promise { const result: BulkReceiveResult = { receivedCount: 0, skippedCount: 0, results: [] }; - this.assertTruckEntrance(dto.truckEntrance); await this.dataSource.transaction(async (manager) => { await this.validateLocation(manager, { @@ -711,23 +733,40 @@ export class WarehouseInventoryService { }; const [booking] = await manager.query( - `SELECT b.payment_status AS "paymentStatus", b.cargo_total_weight_vgm AS "weight", + `SELECT b.reference AS "reference", + b.payment_status AS "paymentStatus", + b.cargo_total_weight_vgm AS "weight", + company.name AS "customer", + company.tin AS "customerTin", + COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone", oy.country AS "originCountry", dy.country AS "destinationCountry", (NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL OR COALESCE(st.includes_first_mile, false)) AS "hasFirstMile", fm.id AS "firstMileRequestId", - fm.status AS "firstMileStatus" + fm.status AS "firstMileStatus", + v.plate_number AS "firstMileTruckPlateNumber", + v.trailer_plate_no AS "firstMileTrailerPlateNumber", + COALESCE( + NULLIF(TRIM(CONCAT(COALESCE(driver.first_name, ''), ' ', COALESCE(driver.last_name, ''))), ''), + v.assigned_driver_name + ) AS "firstMileDriverName", + driver.phone_number AS "firstMileDriverPhone", + driver.license_number AS "firstMileDriverLicenseNumber", + v.vehicle_type AS "firstMileTruckType" FROM freight.bookings b + LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id LEFT JOIN freight.service_types st ON st.id = b.service_type_id LEFT JOIN LATERAL ( - SELECT first_mile.id, first_mile.status + SELECT first_mile.id, first_mile.status, first_mile.vehicle_id FROM freight.first_mile first_mile WHERE first_mile.booking_id = b.id AND first_mile.deleted_at IS NULL ORDER BY first_mile.created_at DESC LIMIT 1 ) fm ON true + LEFT JOIN freight.vehicles v ON v.id = fm.vehicle_id + LEFT JOIN freight.drivers driver ON driver.id = v.assigned_driver_id WHERE b.id = $1 AND b.deleted_at IS NULL LIMIT 1`, [bookingId], ); @@ -758,11 +797,13 @@ export class WarehouseInventoryService { const now = new Date(); const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now); + const truckEntrance = this.mergeSystemTruckEntrance(dto.truckEntrance, booking); + this.assertTruckEntrance(truckEntrance); const receiveNote = this.buildReceiveNote({ grnNumber, direction: dto.direction, notes: `Bulk received (${dto.direction})`, - truckEntrance: dto.truckEntrance, + truckEntrance, }); const saved = await manager.getRepository(WarehouseInventory).save( manager.getRepository(WarehouseInventory).create({ @@ -783,12 +824,21 @@ export class WarehouseInventoryService { activityType: 'INVENTORY_RECEIVED', inventoryId: saved.id, warehouseId: dto.warehouseId, - description: `GRN ${grnNumber}: bulk received ${dto.direction} booking via truck ${dto.truckEntrance.truckPlateNumber}`, + description: `GRN ${grnNumber}: bulk received ${dto.direction} booking via truck ${truckEntrance.truckPlateNumber}`, performedBy: dto.performedBy, }, manager, ); + await this.notifyOwnerInventoryReceived({ + phone: truckEntrance.customerPhone, + ownerName: truckEntrance.ownerName, + bookingReference: truckEntrance.edrDigitalBookingId, + grnNumber, + direction: dto.direction, + warehouseId: dto.warehouseId, + }); + result.receivedCount += 1; result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber }); } @@ -1483,7 +1533,6 @@ export class WarehouseInventoryService { } async receive(dto: ReceiveWarehouseInventoryDto): Promise { - this.assertTruckEntrance(dto.truckEntrance); const weight = Number(dto.weight) || 0; const volume = Number(dto.volume) || 0; const containerCount = dto.containerId ? Math.round(Number(dto.quantity) || 0) : 0; @@ -1495,6 +1544,10 @@ export class WarehouseInventoryService { if (dto.bookingId) { await this.assertBookingExists(manager, dto.bookingId); } + const truckEntrance = dto.bookingId + ? this.mergeSystemTruckEntrance(dto.truckEntrance, await this.getBookingTruckEntranceSource(manager, dto.bookingId)) + : dto.truckEntrance; + this.assertTruckEntrance(truckEntrance); this.assertCapacity('Warehouse', warehouse, weight, volume, containerCount); this.assertCapacity('Yard', yard, weight, volume, containerCount); @@ -1505,7 +1558,7 @@ export class WarehouseInventoryService { const receiveNote = this.buildReceiveNote({ grnNumber, notes: dto.notes?.trim() || 'Single booking received', - truckEntrance: dto.truckEntrance, + truckEntrance, }); const saved = await manager.getRepository(WarehouseInventory).save( manager.getRepository(WarehouseInventory).create({ @@ -1529,14 +1582,23 @@ export class WarehouseInventoryService { await this.activityLog.record( { - activityType: 'INVENTORY_RECEIVED', - inventoryId: saved.id, - warehouseId: dto.warehouseId, - description: `GRN ${grnNumber}: received ${weight}kg via truck ${dto.truckEntrance.truckPlateNumber}`, - performedBy: dto.performedBy, - }, - manager, - ); + activityType: 'INVENTORY_RECEIVED', + inventoryId: saved.id, + warehouseId: dto.warehouseId, + description: `GRN ${grnNumber}: received ${weight}kg via truck ${truckEntrance.truckPlateNumber}`, + performedBy: dto.performedBy, + }, + manager, + ); + + await this.notifyOwnerInventoryReceived({ + phone: truckEntrance.customerPhone, + ownerName: truckEntrance.ownerName, + bookingReference: truckEntrance.edrDigitalBookingId ?? dto.bookingId, + grnNumber, + direction: bookingDirection, + warehouseId: dto.warehouseId, + }); return saved.id; }); @@ -2522,6 +2584,111 @@ export class WarehouseInventoryService { } } + private mergeSystemTruckEntrance( + submitted: TruckEntranceDto, + booking: { + reference?: string | null; + customer?: string | null; + customerTin?: string | null; + customerPhone?: string | null; + firstMileTruckPlateNumber?: string | null; + firstMileTrailerPlateNumber?: string | null; + firstMileDriverName?: string | null; + firstMileDriverPhone?: string | null; + firstMileDriverLicenseNumber?: string | null; + firstMileTruckType?: string | null; + }, + ): TruckEntranceDto { + return { + ...submitted, + ownerName: booking.customer?.trim() || submitted.ownerName, + edrDigitalBookingId: booking.reference?.trim() || submitted.edrDigitalBookingId, + tin: booking.customerTin?.trim() || submitted.tin, + customerPhone: booking.customerPhone?.trim() || submitted.customerPhone, + truckPlateNumber: booking.firstMileTruckPlateNumber?.trim() || submitted.truckPlateNumber, + trailerPlateNumber: booking.firstMileTrailerPlateNumber?.trim() || submitted.trailerPlateNumber, + driverName: booking.firstMileDriverName?.trim() || submitted.driverName, + driverPhone: booking.firstMileDriverPhone?.trim() || submitted.driverPhone, + driverLicenseNumber: booking.firstMileDriverLicenseNumber?.trim() || submitted.driverLicenseNumber, + truckType: booking.firstMileTruckType?.trim() || submitted.truckType, + }; + } + + private async getBookingTruckEntranceSource( + manager: EntityManager, + bookingId: string, + ): Promise<{ + reference?: string | null; + customer?: string | null; + customerTin?: string | null; + customerPhone?: string | null; + firstMileTruckPlateNumber?: string | null; + firstMileTrailerPlateNumber?: string | null; + firstMileDriverName?: string | null; + firstMileDriverPhone?: string | null; + firstMileDriverLicenseNumber?: string | null; + firstMileTruckType?: string | null; + }> { + const [booking] = await manager.query( + `SELECT b.reference AS "reference", + company.name AS "customer", + company.tin AS "customerTin", + COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone", + v.plate_number AS "firstMileTruckPlateNumber", + v.trailer_plate_no AS "firstMileTrailerPlateNumber", + COALESCE( + NULLIF(TRIM(CONCAT(COALESCE(driver.first_name, ''), ' ', COALESCE(driver.last_name, ''))), ''), + v.assigned_driver_name + ) AS "firstMileDriverName", + driver.phone_number AS "firstMileDriverPhone", + driver.license_number AS "firstMileDriverLicenseNumber", + v.vehicle_type AS "firstMileTruckType" + FROM freight.bookings b + LEFT JOIN freight.companies company ON company.id = b.company_id + LEFT JOIN LATERAL ( + SELECT first_mile.vehicle_id + FROM freight.first_mile first_mile + WHERE first_mile.booking_id = b.id AND first_mile.deleted_at IS NULL + ORDER BY first_mile.created_at DESC + LIMIT 1 + ) fm ON true + LEFT JOIN freight.vehicles v ON v.id = fm.vehicle_id + LEFT JOIN freight.drivers driver ON driver.id = v.assigned_driver_id + WHERE b.id = $1 AND b.deleted_at IS NULL + LIMIT 1`, + [bookingId], + ); + return booking ?? {}; + } + + private async notifyOwnerInventoryReceived(params: { + phone?: string | null; + ownerName?: string | null; + bookingReference?: string | null; + grnNumber: string; + direction?: string | null; + warehouseId?: string | null; + }): Promise { + const phone = params.phone?.trim(); + if (!phone) return; + + const ownerName = params.ownerName?.trim() || 'Customer'; + const bookingReference = params.bookingReference?.trim(); + const message = + `Dear ${ownerName}, your cargo has been received by EDR warehouse. ` + + (bookingReference ? `Booking: ${bookingReference}. ` : '') + + `GRN: ${params.grnNumber}. ` + + (params.direction ? `Direction: ${params.direction}. ` : '') + + `Thank you.`; + + try { + await this.notifications.directSend('sms', phone, message); + } catch (error) { + // Receiving inventory must not be rolled back because an SMS provider is unavailable. + this.logger.error(`Failed to notify owner for GRN ${params.grnNumber}: ${String(error)}`); + } + } + private generateGrnNumber(direction: string, referenceId: string, date: Date): string { const stamp = date.toISOString().slice(0, 10).replace(/-/g, ''); const suffix = referenceId.replace(/-/g, '').slice(0, 8).toUpperCase(); @@ -2542,6 +2709,7 @@ export class WarehouseInventoryService { truck.consigneeDetails ? `Consignee: ${truck.consigneeDetails}` : null, truck.edrDigitalBookingId ? `EDR Digital Booking ID: ${truck.edrDigitalBookingId}` : null, truck.tin ? `TIN: ${truck.tin}` : null, + truck.customerPhone ? `Customer Phone: ${truck.customerPhone}` : null, `Truck Plate: ${truck.truckPlateNumber}`, truck.trailerPlateNumber ? `Trailer Plate: ${truck.trailerPlateNumber}` : null, truck.assignedEquipmentNumber ? `Assigned Wagon / Container: ${truck.assignedEquipmentNumber}` : null, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts index 4398d943e..a77a46c29 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts @@ -99,26 +99,51 @@ export class WarehouseReleaseDocumentService { } private htmlToBasicPdfBuffer(html: string): Buffer { - const text = this.htmlToPlainText(html); - const lines = this.wrapLines(text, 86).slice(0, 52); - const body = lines - .map((line, index) => { - const y = 770 - index * 12; - const isTitle = index < 2 || /clearance|release order/i.test(line); - const size = index === 0 ? 13 : isTitle ? 11 : 9.6; - const font = isTitle ? 'F2' : 'F1'; - return this.textOp(line, 48, y, size, font); - }) - .join('\n'); + const doc = this.extractReleaseDocument(html); + const body: string[] = [ + this.lineOp(36, 810, 559, 810, '0 0 0', 2.2), + this.textOp('ETHIO-DJIBOUTI RAILWAY S.C.', 36, 787, 9, 'F2', '0.08 0.32 0.18'), + this.textOp('WAREHOUSE GATE', 36, 764, 24, 'F2', '0.02 0.08 0.16'), + this.textOp('CLEARANCE / RELEASE', 36, 742, 24, 'F2', '0.02 0.08 0.16'), + this.textOp('ORDER', 36, 720, 24, 'F2', '0.02 0.08 0.16'), + this.textOp('OFFICIAL WAREHOUSE RELEASE AND EXIT AUTHORIZATION', 36, 696, 8.5, 'F1', '0.25 0.34 0.45'), + this.textOp('Document / Release No.', 424, 781, 8.5, 'F1', '0.15 0.22 0.32'), + this.textOp(doc.reference, 504 - doc.reference.length * 2.2, 761, 15, 'F2', '0.02 0.08 0.16'), + this.textOp(`Issued: ${doc.issuedAt}`, 424, 742, 8.5, 'F1', '0.15 0.22 0.32'), + this.lineOp(36, 682, 559, 682, '0.08 0.32 0.18', 2), + this.rectOp(36, 625, 410, 52, '0.95 1 0.96', '0.38 0.85 0.55', 0.8), + this.lineOp(39, 625, 39, 677, '0.08 0.48 0.25', 2.2), + ...this.wrapLines(doc.notice, 68) + .slice(0, 4) + .map((line, index) => this.textOp(line, 52, 659 - index * 12, 9.2, 'F1')), + this.textOp('RELEASE PARTICULARS', 36, 604, 10, 'F2', '0.08 0.32 0.18'), + ]; + + let y = 586; + const rowHeight = 20; + for (const [label, value] of doc.rows.slice(0, 14)) { + body.push(this.rectOp(36, y - rowHeight + 3, 160, rowHeight, '0.97 0.98 0.99', '0.70 0.77 0.85', 0.6)); + body.push(this.rectOp(196, y - rowHeight + 3, 363, rowHeight, '1 1 1', '0.70 0.77 0.85', 0.6)); + body.push(this.textOp(label, 46, y - 10, 8.6, 'F2', '0.02 0.08 0.16')); + body.push(this.textOp(value || '-', 206, y - 10, 8.6, 'F1', '0.02 0.08 0.16')); + y -= rowHeight; + } + + body.push(this.textOp('AUTHORIZATION CLAUSE', 36, y - 10, 10, 'F2', '0.08 0.32 0.18')); + body.push(this.rectOp(36, y - 76, 523, 48, '1 1 1', '0.70 0.77 0.85', 0.7)); + body.push( + ...this.wrapLines(doc.clause, 92) + .slice(0, 4) + .map((line, index) => this.textOp(line, 48, y - 45 - index * 10, 8.2, 'F1')), + ); + const stream = [ - this.lineOp(48, 752, 548, 752), - body, - this.circularSealOps(184, 154), - this.lineOp(48, 92, 278, 92, '0 0 0'), - this.textOp('Officer in charge name / signature / date', 48, 76, 9, 'F1'), - this.lineOp(326, 92, 548, 92, '0 0 0'), - this.textOp('Customer or driver name / signature / date', 326, 76, 9, 'F1'), - this.textOp('OFFICIAL WAREHOUSE GATE CLEARANCE DOCUMENT', 145, 52, 9, 'F2', '0.08 0.32 0.18'), + ...body, + this.lineOp(36, 60, 218, 60, '0 0 0', 1), + this.textOp('Officer in charge name / signature / date', 36, 47, 7.4, 'F1'), + this.circularSealOps(286, 62, 38), + this.lineOp(341, 60, 559, 60, '0 0 0', 1), + this.textOp('Customer or driver name / signature / date', 341, 47, 7.4, 'F1'), ].join('\n'); const objects = [ @@ -149,6 +174,31 @@ export class WarehouseReleaseDocumentService { return Buffer.from(pdf, 'latin1'); } + private extractReleaseDocument(html: string): { + reference: string; + issuedAt: string; + notice: string; + clause: string; + rows: Array<[string, string]>; + } { + const textFromHtml = (value: string) => this.htmlToPlainText(value).replace(/\n/g, ' ').trim(); + const reference = textFromHtml(html.match(/([\s\S]*?)<\/strong>/i)?.[1] ?? 'DO'); + const issuedAt = textFromHtml(html.match(/Issued:\s*([^<]+)/i)?.[1] ?? '-'); + const notice = textFromHtml( + html.match(/
([\s\S]*?)<\/div>/i)?.[1] ?? + 'This clearance document confirms that the listed booking/goods are authorized for warehouse exit, subject to gate identity verification and confirmation that no blocking warehouse fees remain unpaid.', + ); + const clause = textFromHtml( + html.match(/
([\s\S]*?)<\/div>/i)?.[1] ?? + 'The warehouse officer and gate staff shall verify this document against the booking reference, customer or driver identity, cargo details, clearance status, and payment records before permitting exit from the warehouse premises.', + ); + const rows: Array<[string, string]> = []; + for (const match of html.matchAll(/([\s\S]*?)<\/th>([\s\S]*?)<\/td><\/tr>/gi)) { + rows.push([textFromHtml(match[1]), textFromHtml(match[2])]); + } + return { reference, issuedAt, notice, clause, rows }; + } + private htmlToPlainText(html: string): string { return html .replace(//gi, '') @@ -203,24 +253,36 @@ export class WarehouseReleaseDocumentService { return `BT\n${color} rg\n/${font} ${size} Tf\n${x} ${y} Td\n(${this.escapePdfText(text)}) Tj\nET`; } - private lineOp(x1: number, y1: number, x2: number, y2: number, color = '0.08 0.32 0.18'): string { - return `q\n${color} RG\n0.8 w\n${x1} ${y1} m\n${x2} ${y2} l\nS\nQ`; + private lineOp(x1: number, y1: number, x2: number, y2: number, color = '0.08 0.32 0.18', width = 0.8): string { + return `q\n${color} RG\n${width} w\n${x1} ${y1} m\n${x2} ${y2} l\nS\nQ`; } - private circularSealOps(cx: number, cy: number): string { + private rectOp( + x: number, + y: number, + width: number, + height: number, + fillColor = '1 1 1', + strokeColor = '0.08 0.32 0.18', + lineWidth = 0.8, + ): string { + return `q\n${fillColor} rg\n${strokeColor} RG\n${lineWidth} w\n${x} ${y} ${width} ${height} re\nB\nQ`; + } + + private circularSealOps(cx: number, cy: number, radius = 51): string { return [ 'q', '0.08 0.32 0.18 RG', '0.08 0.32 0.18 rg', '2.2 w', - this.circlePath(cx, cy, 51), + this.circlePath(cx, cy, radius), 'S', '0.8 w', - this.circlePath(cx, cy, 41), + this.circlePath(cx, cy, radius - 10), 'S', - this.textOp('EDR', cx - 14, cy + 18, 14, 'F2', '0.08 0.32 0.18'), - this.textOp('WAREHOUSE', cx - 31, cy + 2, 9, 'F2', '0.08 0.32 0.18'), - this.textOp('CLEARED', cx - 27, cy - 16, 11, 'F2', '0.08 0.32 0.18'), + this.textOp('EDR', cx - 11, cy + 13, 11, 'F2', '0.08 0.32 0.18'), + this.textOp('WAREHOUSE', cx - 25, cy, 7.5, 'F2', '0.08 0.32 0.18'), + this.textOp('CLEARED', cx - 21, cy - 13, 9, 'F2', '0.08 0.32 0.18'), 'Q', ].join('\n'); } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts index 116bfabf1..cbdb5522c 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts @@ -6,6 +6,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { FilesModule } from '../files/files.module'; import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module'; import { LastMileModule } from '../last-mile/last-mile.module'; +import { NotificationsModule } from '../notifications/notifications.module'; import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity'; import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity'; import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity'; @@ -71,6 +72,7 @@ import { WarehousesService } from './warehouses.service'; FilesModule, InterchangeDocumentsModule, forwardRef(() => LastMileModule), + NotificationsModule, ExchangeModule.forRootAsync({ inject: [ConfigService], useFactory: (config: ConfigService): ExchangeOptions => diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index a2437fe8f..df282e224 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -58,6 +58,7 @@ interface TruckEntranceFormState { consigneeDetails: string; edrDigitalBookingId: string; tin: string; + customerPhone: string; truckPlateNumber: string; trailerPlateNumber: string; assignedEquipmentNumber: string; @@ -85,11 +86,21 @@ interface TruckEntranceFormState { warehouseManagerName: string; } +interface LockedTruckEntranceFields { + ownerName?: boolean; + tin?: boolean; + edrDigitalBookingId?: boolean; + customerPhone?: boolean; +} + +type PackagingFreightType = 'CONTAINER' | 'BULK' | 'MIXED'; + const emptyTruckEntrance = (): TruckEntranceFormState => ({ ownerName: '', consigneeDetails: '', edrDigitalBookingId: '', tin: '', + customerPhone: '', truckPlateNumber: '', trailerPlateNumber: '', assignedEquipmentNumber: '', @@ -122,6 +133,7 @@ const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayl consigneeDetails: form.consigneeDetails.trim() || undefined, edrDigitalBookingId: form.edrDigitalBookingId.trim() || undefined, tin: form.tin.trim() || undefined, + customerPhone: form.customerPhone.trim() || undefined, truckPlateNumber: form.truckPlateNumber.trim(), trailerPlateNumber: form.trailerPlateNumber.trim() || undefined, assignedEquipmentNumber: form.assignedEquipmentNumber.trim() || undefined, @@ -149,13 +161,106 @@ const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayl warehouseManagerName: form.warehouseManagerName.trim() || undefined, }); +const commonNonEmptyValue = (values: Array) => { + const unique = [...new Set(values.map((value) => value?.trim()).filter(Boolean))] as string[]; + return unique.length === 1 ? unique[0] : ''; +}; + +const truckEntranceFromBookings = (bookings: EligibleBooking[]): { + form: TruckEntranceFormState; + lockedFields: LockedTruckEntranceFields; + packagingFreightType: PackagingFreightType; +} => { + const ownerName = commonNonEmptyValue(bookings.map((booking) => booking.customer)); + const tin = commonNonEmptyValue(bookings.map((booking) => booking.customerTin)); + const customerPhone = commonNonEmptyValue(bookings.map((booking) => booking.customerPhone)); + const edrDigitalBookingId = + bookings.length === 1 + ? bookings[0]?.reference ?? bookings[0]?.id ?? '' + : commonNonEmptyValue(bookings.map((booking) => booking.reference)); + const firstMileBooking = bookings.length === 1 ? bookings[0] : null; + const freightTypes = [...new Set(bookings.map((booking) => booking.freightType).filter(Boolean))]; + const packagingFreightType = + freightTypes.length === 1 && freightTypes[0] === 'CONTAINER' + ? 'CONTAINER' + : freightTypes.length === 1 && freightTypes[0] === 'BULK' + ? 'BULK' + : 'MIXED'; + + return { + form: { + ...emptyTruckEntrance(), + ownerName, + tin, + customerPhone, + edrDigitalBookingId, + truckPlateNumber: firstMileBooking?.firstMileTruckPlateNumber ?? '', + trailerPlateNumber: firstMileBooking?.firstMileTrailerPlateNumber ?? '', + driverName: firstMileBooking?.firstMileDriverName ?? '', + driverPhone: firstMileBooking?.firstMileDriverPhone ?? '', + driverLicenseNumber: firstMileBooking?.firstMileDriverLicenseNumber ?? '', + truckType: firstMileBooking?.firstMileTruckType ?? '', + }, + lockedFields: { + ownerName: Boolean(ownerName), + tin: Boolean(tin), + edrDigitalBookingId: Boolean(edrDigitalBookingId), + customerPhone: Boolean(customerPhone), + }, + packagingFreightType, + }; +}; + +const BULK_PACKAGING_TYPE_OPTIONS = [ + { value: 'BAG', label: 'Bag' }, + { value: 'SACK', label: 'Sack' }, + { value: 'BALE', label: 'Bale' }, + { value: 'CARTON', label: 'Carton' }, + { value: 'CRATE', label: 'Crate' }, + { value: 'DRUM', label: 'Drum' }, + { value: 'BARREL', label: 'Barrel' }, + { value: 'PALLET', label: 'Pallet' }, + { value: 'LOOSE_BULK', label: 'Loose bulk' }, + { value: 'OTHER', label: 'Other' }, +]; + +const CONTAINER_PACKAGING_TYPE_OPTIONS = [ + { value: 'CONTAINER_20FT', label: '20 ft container' }, + { value: 'CONTAINER_40FT', label: '40 ft container' }, + { value: 'CONTAINER_45FT', label: '45 ft container' }, + { value: 'REEFER_CONTAINER', label: 'Reefer container' }, + { value: 'TANK_CONTAINER', label: 'Tank container' }, + { value: 'FLAT_RACK_CONTAINER', label: 'Flat rack container' }, + { value: 'OPEN_TOP_CONTAINER', label: 'Open top container' }, + { value: 'OTHER_CONTAINER', label: 'Other container' }, +]; + +const packagingOptionsFor = (freightType: PackagingFreightType) => + freightType === 'CONTAINER' + ? CONTAINER_PACKAGING_TYPE_OPTIONS + : freightType === 'BULK' + ? BULK_PACKAGING_TYPE_OPTIONS + : [...CONTAINER_PACKAGING_TYPE_OPTIONS, ...BULK_PACKAGING_TYPE_OPTIONS]; + function TruckEntranceFields({ value, onChange, + lockedFields, + packagingFreightType = 'MIXED', }: { value: TruckEntranceFormState; onChange: (next: TruckEntranceFormState) => void; + lockedFields?: LockedTruckEntranceFields; + packagingFreightType?: PackagingFreightType; }) { + const packagingOptions = packagingOptionsFor(packagingFreightType); + const quantityLabel = + packagingFreightType === 'CONTAINER' + ? 'Container quantity' + : packagingFreightType === 'BULK' + ? 'Unit count' + : 'Quantity'; + return ( Customer and cargo ownership @@ -163,6 +268,7 @@ function TruckEntranceFields({ onChange({ ...value, ownerName: e.currentTarget.value })} /> onChange({ ...value, edrDigitalBookingId: e.currentTarget.value })} /> onChange({ ...value, tin: e.currentTarget.value })} /> + onChange({ ...value, customerPhone: e.currentTarget.value })} + /> Transport and equipment tracking @@ -285,13 +399,16 @@ function TruckEntranceFields({ /> - onChange({ ...value, packagingType: e.currentTarget.value })} + onChange={(v) => onChange({ ...value, packagingType: v ?? '' })} /> onChange({ ...value, unitCount: v === '' ? '' : Number(v) })} @@ -466,6 +583,8 @@ function EligibleTab({ const [truckOpen, setTruckOpen] = useState(false); const [pendingReceiveIds, setPendingReceiveIds] = useState([]); const [truckForm, setTruckForm] = useState(emptyTruckEntrance()); + const [lockedTruckFields, setLockedTruckFields] = useState({}); + const [packagingFreightType, setPackagingFreightType] = useState('MIXED'); const locationReady = Boolean(location.warehouseId && location.yardId && location.zoneId); const canReceiveBooking = (row: EligibleBooking) => @@ -564,13 +683,14 @@ function EligibleTab({ toast({ variant: 'destructive', title: 'No selected booking is ready to receive' }); return; } - const row = filteredIds.length === 1 ? rows.find((item) => item.id === filteredIds[0]) : null; + const selectedRows = filteredIds + .map((id) => rows.find((item) => item.id === id)) + .filter(Boolean) as EligibleBooking[]; + const { form, lockedFields, packagingFreightType: nextPackagingFreightType } = truckEntranceFromBookings(selectedRows); setPendingReceiveIds(filteredIds); - setTruckForm({ - ...emptyTruckEntrance(), - truckPlateNumber: row?.firstMileTruckPlateNumber ?? '', - trailerPlateNumber: row?.firstMileTrailerPlateNumber ?? '', - }); + setTruckForm(form); + setLockedTruckFields(lockedFields); + setPackagingFreightType(nextPackagingFreightType); setTruckOpen(true); }; @@ -593,6 +713,8 @@ function EligibleTab({ setSelected(new Set()); setTruckOpen(false); setPendingReceiveIds([]); + setLockedTruckFields({}); + setPackagingFreightType('MIXED'); onChanged?.(); } catch (error) { toast({ variant: 'destructive', title: 'Receive failed', description: extractErrorMessage(error) }); @@ -805,7 +927,12 @@ function EligibleTab({ Register the arriving truck and driver before receiving {pendingReceiveIds.length} booking{pendingReceiveIds.length === 1 ? '' : 's'}. - +