diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 31487e2bb..b1481d0d4 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -13,6 +13,8 @@ import { ResponseCompanyDto } from './dto/response-company.dto'; import { ResponseExternalProfileDto } from './dto/response-external-profile.dto'; import { ResponseFFClientDto } from './dto/response-ff-client.dto'; import { CompanyInfoResponseDto } from './dto/company-info-response.dto'; +import { UpdateProfileDto } from './dto/update-profile.dto'; +import { ProfileResponseDto } from './dto/profile-response.dto'; interface CurrentIamUser { id: string; @@ -36,6 +38,22 @@ export class CompaniesController { return new CompanyInfoResponseDto(profile, company); } + @Get('profile') + @ApiOperation({ summary: 'Get flattened profile for the settings page' }) + async getProfile(@CurrentUser() user: CurrentIamUser): Promise { + const { profile, company } = await this.companiesService.getCompanyInfoByUserId(user.id); + return new ProfileResponseDto(profile, company); + } + + @Patch('profile') + @ApiOperation({ summary: 'Update profile (flattened settings page)' }) + async updateProfile( + @CurrentUser() user: CurrentIamUser, + @Body() dto: UpdateProfileDto, + ): Promise { + return this.companiesService.updateProfile(user.id, dto); + } + @Post('create') @ApiOperation({ summary: 'Create a company with its associated external profile (onboarding)' }) async createWithProfile( diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 03c104798..f383b9e55 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -7,6 +7,8 @@ import { UpdateCompanyDto } from './dto/update-company.dto'; import { CreateExternalProfileDto } from './dto/create-external-profile.dto'; import { CreateFFClientDto } from './dto/create-ff-client.dto'; import { CreateCompanyWithProfileDto } from './dto/create-company-with-profile.dto'; +import { UpdateProfileDto } from './dto/update-profile.dto'; +import { ProfileResponseDto } from './dto/profile-response.dto'; import { Company } from './entities/company.entity'; import { ExternalProfile } from './entities/external-profile.entity'; import { FFClient } from './entities/ff-client.entity'; @@ -103,6 +105,42 @@ export class CompaniesService { return updated; } + async updateProfile(userId: string, dto: UpdateProfileDto): Promise { + const { profile, company } = await this.getCompanyInfoByUserId(userId); + + const companyUpdates: Record = {}; + const attrUpdates: Record = { ...(company.attributes ?? {}) }; + + if (dto.companyName !== undefined) companyUpdates.name = dto.companyName; + if (dto.companyEmail !== undefined) companyUpdates.email = dto.companyEmail; + if (dto.companyPhone !== undefined) companyUpdates.phone = dto.companyPhone; + if (dto.companyLocation !== undefined) companyUpdates.country = dto.companyLocation; + if (dto.companyAddress !== undefined) companyUpdates.address = dto.companyAddress; + if (dto.tin !== undefined) companyUpdates.tin = dto.tin; + if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber; + if (dto.fanNumber !== undefined) { + companyUpdates.businessLicense = dto.fanNumber; + companyUpdates.fanNumber = dto.fanNumber; + } + + if (dto.contactPersonName !== undefined) attrUpdates.contactPersonName = dto.contactPersonName; + if (dto.contactPersonPhone !== undefined) attrUpdates.contactPersonPhone = dto.contactPersonPhone; + if (dto.generalManagerName !== undefined) attrUpdates.generalManagerName = dto.generalManagerName; + if (dto.generalManagerEmail !== undefined) attrUpdates.generalManagerEmail = dto.generalManagerEmail; + if (dto.generalManagerPhone !== undefined) attrUpdates.generalManagerPhone = dto.generalManagerPhone; + if (dto.poaName !== undefined) attrUpdates.poaName = dto.poaName; + if (dto.poaPhone !== undefined) attrUpdates.poaPhone = dto.poaPhone; + if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail; + if (dto.poaLocation !== undefined) attrUpdates.poaLocation = dto.poaLocation; + if (dto.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress; + + companyUpdates.attributes = attrUpdates; + + const updated = await this.companiesRepo.update(company.id, companyUpdates); + if (!updated) throw new NotFoundException(`Company ${company.id} not found`); + return new ProfileResponseDto(profile, updated); + } + async deleteCompany(id: string): Promise { await this.findCompanyById(id); await this.companiesRepo.softDelete(id); diff --git a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts new file mode 100644 index 000000000..ee8ede34f --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts @@ -0,0 +1,53 @@ +import { Company } from '../entities/company.entity'; +import { ExternalProfile } from '../entities/external-profile.entity'; + +export class ProfileResponseDto { + companyId: string; + companyName: string; + companyEmail: string | null; + companyPhone: string | null; + companyLocation: string; + companyAddress: string | null; + tinNumber: string; + vatNumber: string | null; + fanNumber: string | null; + + contactPersonName: string | null; + contactPersonPhone: string | null; + generalManagerName: string | null; + generalManagerEmail: string | null; + generalManagerPhone: string | null; + + poaName: string | null; + poaPhone: string | null; + poaEmail: string | null; + poaLocation: string | null; + poaAddress: string | null; + + profileId: string; + + constructor(profile: ExternalProfile, company: Company) { + this.companyId = company.id; + this.companyName = company.name; + this.companyEmail = company.email ?? null; + this.companyPhone = company.phone ?? null; + this.companyLocation = company.country; + this.companyAddress = company.address ?? null; + this.tinNumber = company.tin; + this.vatNumber = company.vatNumber ?? null; + this.fanNumber = company.fanNumber ?? null; + this.profileId = profile.id; + + const attrs = company.attributes ?? {}; + this.contactPersonName = attrs.contactPersonName ?? null; + this.contactPersonPhone = attrs.contactPersonPhone ?? null; + this.generalManagerName = attrs.generalManagerName ?? null; + this.generalManagerEmail = attrs.generalManagerEmail ?? null; + this.generalManagerPhone = attrs.generalManagerPhone ?? null; + this.poaName = attrs.poaName ?? null; + this.poaPhone = attrs.poaPhone ?? null; + this.poaEmail = attrs.poaEmail ?? null; + this.poaLocation = attrs.poaLocation ?? null; + this.poaAddress = attrs.poaAddress ?? null; + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts new file mode 100644 index 000000000..0acdf60a1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts @@ -0,0 +1,83 @@ +import { IsString, IsOptional, IsEmail, MaxLength, Length, Matches } from 'class-validator'; + +export class UpdateProfileDto { + @IsOptional() + @IsString() + @MaxLength(200) + companyName?: string; + + @IsOptional() + @IsEmail() + @MaxLength(150) + companyEmail?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + companyPhone?: string; + + @IsOptional() + @IsString() + @MaxLength(32) + companyLocation?: string; + + @IsOptional() + @IsString() + companyAddress?: string; + + @IsOptional() + @IsString() + @Length(10, 10) + @Matches(/^\d+$/, { message: 'TIN must contain only digits' }) + tin?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + vatNumber?: string; + + @IsOptional() + @IsString() + @MaxLength(16) + fanNumber?: string; + + @IsOptional() + @IsString() + contactPersonName?: string; + + @IsOptional() + @IsString() + contactPersonPhone?: string; + + @IsOptional() + @IsString() + generalManagerName?: string; + + @IsOptional() + @IsEmail() + generalManagerEmail?: string; + + @IsOptional() + @IsString() + generalManagerPhone?: string; + + @IsOptional() + @IsString() + poaName?: string; + + @IsOptional() + @IsString() + poaPhone?: string; + + @IsOptional() + @IsEmail() + poaEmail?: string; + + @IsOptional() + @IsString() + poaLocation?: string; + + @IsOptional() + @IsString() + poaAddress?: string; +} diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 3e065169f..147668d91 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -14,11 +14,13 @@ import { Home, Loader2, User, + Settings, } from "lucide-react"; import useAuth from "./hooks/useAuth"; import ProfilePage from "./pages/ProfilePage"; +import SettingsPage from "./pages/SettingsPage"; import MyPortalPage from "./pages/MyPortalPage"; import EDRFreightLandingPage from "./pages/EDRFreightLandingPage"; import SignupPage from "./pages/accounts/SignupPage"; @@ -40,6 +42,7 @@ const sidebarItems: SidebarItem[] = [ { label: "Tracking", href: "/tracking", icon: }, { label: "Billing", href: "/billing", icon: }, { label: "Profile", href: "/profile", icon: }, + { label: "Settings", href: "/settings", icon: }, ]; const App = () => { @@ -108,6 +111,7 @@ const App = () => { } /> } /> } /> + } /> } /> diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index 4fb326462..b2dd0f254 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -82,6 +82,7 @@ export const URL_CONSTANTS = { COMPANIES_API: { GET_INFO: "/api/companies/getInfo", CREATE: "/api/companies/create", + PROFILE: "/api/companies/profile", DOCUMENTS: (id: string) => `/api/companies/${id}/documents`, }, diff --git a/apps/edr-freight-web/portal/src/hooks/useAuth.ts b/apps/edr-freight-web/portal/src/hooks/useAuth.ts index 8d58d3ada..7f4c02585 100644 --- a/apps/edr-freight-web/portal/src/hooks/useAuth.ts +++ b/apps/edr-freight-web/portal/src/hooks/useAuth.ts @@ -29,7 +29,6 @@ const useAuth = () => { const authQuery = useQuery( api.auth.getMyInfo.queryOptions({ - enabled: !!getCookie("auth-token"), retry: false, staleTime: 10 * 60 * 1000, }), diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx index 440617f3e..6cb111bb7 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { Link } from "react-router-dom"; import { ArrowRight, @@ -14,6 +14,8 @@ import { Plus, Receipt, Truck, + UploadCloud, + X, } from "lucide-react"; import { @@ -48,6 +50,7 @@ export default function MyPortalPage() { const outstandingInvoices = myInvoices.filter( (inv) => inv.status === "Sent" || inv.status === "Overdue", ); + const [dismissed, setDismissed] = useState(false); const totalOutstanding = outstandingInvoices .filter((inv) => inv.currency === "USD") .reduce((sum, inv) => sum + inv.amount, 0); @@ -61,6 +64,34 @@ export default function MyPortalPage() { return (
+ {/* Documents banner */} + {!me.documentsComplete && !dismissed && ( +
+ +
+

Upload your documents

+

+ To enable all account features, please upload your Business + License, TIN Certificate, and National ID / Passport. +

+ + Upload now + +
+ +
+ )} + {/* Welcome banner */}
diff --git a/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx b/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx index e0d364bc1..9c4354723 100644 --- a/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx +++ b/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx @@ -1,135 +1,46 @@ -import { useMemo } from "react"; -import { - User, - Building2, - Phone, - Mail, - MapPin, - ShieldCheck, - Briefcase, - UserCheck, - Building, - Globe, - Fingerprint, - FileCheck, - Settings2, - ExternalLink, -} from "lucide-react"; -import useAuth from "@/hooks/useAuth"; -import { - Card, - CardHeader, - CardTitle, - CardDescription, - CardContent, - CardAction, - Badge, - Separator, - SmartFileInput, - Button, -} from "@edr/ui-common"; -import type { IFileUploadSetting } from "@edr/types/freight"; -import { cn } from "@/lib/utils"; +import { User, Building2, Phone, Mail, MapPin, ShieldCheck, Briefcase, UserCheck, Fingerprint, FileCheck, Globe, Building } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; +import { api } from "@/services/api"; +import { Card, CardHeader, CardTitle, CardDescription, CardContent, Badge, Separator } from "@edr/ui-common"; + +function InfoItem({ icon, label, value }: { icon?: React.ReactNode; label: string; value?: string | null }) { + return ( +
+ {icon &&
{icon}
} +
+

{label}

+

{value || "—"}

+
+
+ ); +} export default function ProfilePage() { - const { user, customer, isPending } = useAuth(); - - const documentSettings = useMemo(() => ({ - id: "profile-docs", - code: "customer_documents", - label: "Customer Documents", - entity: "customer", - createdAt: new Date(), - updatedAt: new Date(), - fields: [ - { - id: "doc-tin", - settingId: "profile-docs", - fileKey: "tin_certificate", - fileLabel: "TIN Certificate", - isRequired: true, - isMultiple: false, - maxFiles: 1, - allowedExtensions: [".pdf", ".jpg", ".jpeg", ".png"], - maxSizeMb: 5, - order: 1, - createdAt: new Date(), - updatedAt: new Date(), - }, - { - id: "doc-license", - settingId: "profile-docs", - fileKey: "business_license", - fileLabel: "Business/Investment License", - isRequired: true, - isMultiple: false, - maxFiles: 1, - allowedExtensions: [".pdf", ".jpg", ".jpeg", ".png"], - maxSizeMb: 5, - order: 2, - createdAt: new Date(), - updatedAt: new Date(), - }, - { - id: "doc-reg", - settingId: "profile-docs", - fileKey: "registration_certificate", - fileLabel: "Business Registration Certificate", - isRequired: true, - isMultiple: false, - maxFiles: 1, - allowedExtensions: [".pdf", ".jpg", ".jpeg", ".png"], - maxSizeMb: 5, - order: 3, - createdAt: new Date(), - updatedAt: new Date(), - }, - { - id: "doc-id", - settingId: "profile-docs", - fileKey: "national_id", - fileLabel: "National ID", - isRequired: true, - isMultiple: false, - maxFiles: 1, - allowedExtensions: [".pdf", ".jpg", ".jpeg", ".png"], - maxSizeMb: 5, - order: 4, - createdAt: new Date(), - updatedAt: new Date(), - }, - { - id: "doc-poa", - settingId: "profile-docs", - fileKey: "power_of_attorney", - fileLabel: "Power of Attorney", - isRequired: false, - isMultiple: false, - maxFiles: 1, - allowedExtensions: [".pdf", ".jpg", ".jpeg", ".png"], - maxSizeMb: 5, - order: 5, - createdAt: new Date(), - updatedAt: new Date(), - }, - ], - }), []); + const { data: profile, isPending } = useQuery( + api.companies.getProfile.queryOptions(), + ); if (isPending) { return (
-
+
); } - const displayName = user?.name?.en || user?.username || user?.email || "User"; + if (!profile) { + return ( +
+

No company profile found.

+
+ ); + } return ( -
-
- {/* Header Section */} -
+
+
+
+ {/* Header */}
@@ -137,7 +48,7 @@ export default function ProfilePage() {

- {displayName} + {profile.companyName}

Verified @@ -145,182 +56,129 @@ export default function ProfilePage() {

- {customer?.companyName || "No Company Linked"} + {profile.companyName}

-
- -
-
- + -
- {/* Left Column - Personal & Company Info */} -
-
- {/* Personal Details Card */} +
+ {/* Left Column */} +
+
+ {/* Company Details */} + + + + + Company Details + + Business registration information + + + } label="Location" value={profile.companyLocation} /> + } label="Address" value={profile.companyAddress} /> + } label="TIN Number" value={profile.tinNumber} /> + } label="FAN Number" value={profile.fanNumber} /> + } label="Email" value={profile.companyEmail} /> + } label="Phone" value={profile.companyPhone} /> + + + + {/* Personal Details (from ExternalProfile) */} + + + + + Profile Details + + Your linked user profile + + + } label="Profile" value="Primary Contact" /> + + +
+ + {/* Personnel Card */} - - Personal Details + + Key Personnel - Your account contact information - - - + Management and contact persons - - } label="Email Address" value={user?.email} /> - } label="Phone Number" value={user?.phoneNumber} /> - } label="Username" value={user?.username} /> + +
+

+ Contact Person +

+
+ + +
+
+
+

+ General Manager +

+
+ + + +
+
- {/* Company Details Card */} - - - - - Company Details - - Business registration information - - - } label="Location" value={customer?.companyLocation} /> - } label="Address" value={customer?.companyAddress} /> - } label="TIN Number" value={customer?.tinNumber} /> - } label="FAN Number" value={customer?.fanNumber} /> + {/* Power of Attorney */} + {profile.poaName && ( + + + + + Power of Attorney + + Authorized representative details + + + + + + + + + )} +
+ + {/* Right Column */} +
+ +
+ +
+ +

Secure Account

+

+ Your information is protected by enterprise-grade security. + Contact support for verified information updates. +

+
- - {/* Personnel Card */} - - - - - Key Personnel - - Management and contact persons - - -
-

- Contact Person -

-
- - -
-
-
-

- General Manager -

-
- - - -
-
-
-
- - {/* Power of Attorney Section (Conditional) */} - {customer?.poaName && ( - - - - - Power of Attorney - - Authorized representative details - - - - - - - - - )} -
- - {/* Right Column - Documents */} -
- - - - - Documents - - Manage required business documents - - - - - - - -
- -
- -

Secure Account

-

- Your information is protected by enterprise-grade security. - Contact support for verified information updates. -

-
- -
-
-
); } - -function InfoItem({ - icon, - label, - value, -}: { - icon?: React.ReactNode; - label: string; - value?: string | null; -}) { - return ( -
- {icon && ( -
- {icon} -
- )} -
-

- {label} -

-

- {value || "—"} -

-
-
- ); -} diff --git a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx new file mode 100644 index 000000000..faec1b5b2 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx @@ -0,0 +1,617 @@ +import { useState, useMemo } from "react"; +import { useSearchParams } from "react-router-dom"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { + Building2, + User, + Briefcase, + UserCheck, + FileCheck, + Loader2, + Save, + UploadCloud, + CheckCircle2, + XCircle, +} from "lucide-react"; +import { api } from "@/services/api"; +import { companiesService } from "@/services/companies.service"; +import PhoneInput from "@/components/auth/PhoneInput"; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + CardFooter, + Button, + Input, + Field, + FieldLabel, + FieldError, + FieldGroup, + SmartFileInput, + Badge, +} from "@edr/ui-common"; +import { cn } from "@/lib/utils"; + +type SettingsTab = + | "company" + | "contact" + | "gm" + | "poa" + | "documents"; + +const settingsSchema = 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"), + companyPhoneCountryCode: z.string().min(1, "Country code is required"), + companyLocation: z.string().min(1, "Location is required"), + companyAddress: z.string().min(1, "Address is required"), + tinNumber: z.string().length(10, "TIN must be exactly 10 digits"), + fanNumber: z.string().length(16, "FAN must be exactly 16 digits"), + contactPersonName: z.string().min(1, "Contact person name is required"), + contactPersonPhone: z.string().min(1, "Contact person phone is required"), + contactPersonPhoneCountryCode: z.string().min(1, "Country code is required"), + generalManagerName: z.string().min(1, "GM name is required"), + generalManagerEmail: z.string().email("Invalid GM email"), + generalManagerPhone: z.string().min(1, "GM phone is required"), + generalManagerPhoneCountryCode: z.string().min(1, "Country code is required"), + poaName: z.string().optional(), + poaEmail: z.string().optional(), + poaPhone: z.string().optional(), + poaPhoneCountryCode: z.string().optional(), + poaLocation: z.string().optional(), + poaAddress: z.string().optional(), +}); + +type FormData = z.infer; + +const TABS: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [ + { id: "company", label: "Company Profile", icon: }, + { id: "contact", label: "Contact Person", icon: }, + { id: "gm", label: "General Manager", icon: }, + { id: "poa", label: "Power of Attorney", icon: }, + { id: "documents", label: "Documents", icon: }, +]; + +function splitPhone(fullPhone?: string | null): { code: string; number: string } { + if (!fullPhone) return { code: "+251", number: "" }; + const match = fullPhone.match(/^(\+\d{1,3})(.*)$/); + if (match) return { code: match[1], number: match[2] }; + return { code: "+251", number: fullPhone }; +} + +export default function SettingsPage() { + const queryClient = useQueryClient(); + const [searchParams, setSearchParams] = useSearchParams(); + const tab = (searchParams.get("tab") as SettingsTab) || "company"; + const setTab = (t: SettingsTab) => { + setSearchParams((prev) => { + const next = new URLSearchParams(prev); + next.set("tab", t); + return next; + }, { replace: true }); + }; + const [documentFiles, setDocumentFiles] = useState< + Record + >({}); + + const profileQuery = useQuery( + api.companies.getProfile.queryOptions(), + ); + + const docSettingQuery = useQuery( + api.fileUploadSettings.getByCode.queryOptions({ + input: { code: "customer_documents" }, + enabled: tab === "documents", + }), + ); + + const profile = profileQuery.data; + + const defaultValues = useMemo((): FormData => { + if (!profile) { + return { + companyName: "", + companyEmail: "", + companyPhone: "", + companyPhoneCountryCode: "+251", + companyLocation: "", + companyAddress: "", + tinNumber: "", + fanNumber: "", + contactPersonName: "", + contactPersonPhone: "", + contactPersonPhoneCountryCode: "+251", + generalManagerName: "", + generalManagerEmail: "", + generalManagerPhone: "", + generalManagerPhoneCountryCode: "+251", + poaName: "", + poaEmail: "", + poaPhone: "", + poaPhoneCountryCode: "+251", + poaLocation: "", + poaAddress: "", + }; + } + const contactPhone = splitPhone(profile.contactPersonPhone); + const gmPhone = splitPhone(profile.generalManagerPhone); + const poaPhone = splitPhone(profile.poaPhone); + return { + companyName: profile.companyName, + companyEmail: profile.companyEmail ?? "", + companyPhone: profile.companyPhone ?? "", + companyPhoneCountryCode: splitPhone(profile.companyPhone).code, + companyLocation: profile.companyLocation, + companyAddress: profile.companyAddress ?? "", + tinNumber: profile.tinNumber, + fanNumber: profile.fanNumber ?? "", + contactPersonName: profile.contactPersonName ?? "", + contactPersonPhone: contactPhone.number, + contactPersonPhoneCountryCode: contactPhone.code, + generalManagerName: profile.generalManagerName ?? "", + generalManagerEmail: profile.generalManagerEmail ?? "", + generalManagerPhone: gmPhone.number, + generalManagerPhoneCountryCode: gmPhone.code, + poaName: profile.poaName ?? "", + poaEmail: profile.poaEmail ?? "", + poaPhone: poaPhone.number, + poaPhoneCountryCode: poaPhone.code, + poaLocation: profile.poaLocation ?? "", + poaAddress: profile.poaAddress ?? "", + }; + }, [profile]); + + const { + register, + handleSubmit, + reset, + formState: { errors, isDirty }, + } = useForm({ + resolver: zodResolver(settingsSchema), + values: defaultValues, + }); + + const updateMutation = useMutation({ + mutationFn: (data: FormData) => + api.companies.updateProfile.call({ + companyName: data.companyName, + companyEmail: data.companyEmail, + companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`, + companyLocation: data.companyLocation, + companyAddress: data.companyAddress, + tin: data.tinNumber, + fanNumber: data.fanNumber, + contactPersonName: data.contactPersonName, + contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`, + generalManagerName: data.generalManagerName, + generalManagerEmail: data.generalManagerEmail, + generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`, + poaName: data.poaName || undefined, + poaPhone: + data.poaPhone && data.poaPhoneCountryCode + ? `${data.poaPhoneCountryCode}${data.poaPhone}` + : undefined, + poaEmail: data.poaEmail || undefined, + poaLocation: data.poaLocation || undefined, + poaAddress: data.poaAddress || undefined, + }), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: api.companies.getProfile.queryKey(), + }); + }, + }); + + const docUploadMutation = useMutation({ + mutationFn: (files: Record) => + companiesService.uploadDocuments(profile!.companyId, files), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: api.companies.getProfile.queryKey(), + }); + }, + }); + + const isPending = profileQuery.isPending || updateMutation.isPending || docUploadMutation.isPending; + + if (profileQuery.isPending) { + return ( +
+
+
+ ); + } + + if (!profile) { + return ( +
+

No company profile found.

+
+ ); + } + + const onSubmit = (data: FormData) => { + updateMutation.mutate(data); + }; + + return ( +
+
+
+

+ Account Settings +

+

+ Manage your company profile, personnel, and documents +

+
+ + Verified + +
+ + {/* Tab Bar */} +
+ {TABS.map((t) => ( + + ))} +
+ +
+ + + + {tab === "company" && <> Company Profile} + {tab === "contact" && <> Contact Person} + {tab === "gm" && <> General Manager} + {tab === "poa" && <> Power of Attorney} + {tab === "documents" && <> Documents} + + + {tab === "company" && "Edit your company registration details"} + {tab === "contact" && "Manage the primary contact person for your account"} + {tab === "gm" && "Manage the general manager information"} + {tab === "poa" && "Power of Attorney details are optional"} + {tab === "documents" && "Upload and manage required business documents"} + + + + + + {/* Company Profile Tab */} + {tab === "company" && ( + <> + + Company Name + + + + +
+ + Company Email + + + + + +
+ +
+ + Location + + + + + + Address + + + +
+ +
+ + TIN Number (10 digits) + + + + + + FAN Number (16 digits) + + + +
+ + )} + + {/* Contact Person Tab */} + {tab === "contact" && ( + <> + + Full Name + + + + + + + )} + + {/* General Manager Tab */} + {tab === "gm" && ( + <> + + Full Name + + + + +
+ + Email Address + + + + + +
+ + )} + + {/* Power of Attorney Tab */} + {tab === "poa" && ( + <> +

+ Power of Attorney details are optional. Fill them in if you have + an authorized representative, or leave blank. +

+ + + PoA Full Name + + + + +
+ + PoA Email + + + + + +
+ +
+ + PoA Location + + + + + + PoA Address + + + +
+ + )} + + {/* Documents Tab */} + {tab === "documents" && ( + <> + {docSettingQuery.isLoading ? ( +
+ +
+ ) : !docSettingQuery.data ? ( +

+ No document requirements configured for your account. +

+ ) : ( + + )} + + {docSettingQuery.data && ( +
+
+ {docUploadMutation.isSuccess && ( + + + Documents uploaded successfully + + )} + {docUploadMutation.isError && ( + + + Upload failed + + )} +
+ +
+ )} + + )} +
+
+ + {tab !== "documents" && ( + +
+ {updateMutation.isSuccess && ( + + + Saved successfully + + )} + {updateMutation.isError && ( + + + Save failed + + )} +
+
+ + +
+
+ )} +
+
+
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/customers/customers.mock.ts b/apps/edr-freight-web/portal/src/pages/customers/customers.mock.ts index 92ec6ff80..a1a9bf1c2 100644 --- a/apps/edr-freight-web/portal/src/pages/customers/customers.mock.ts +++ b/apps/edr-freight-web/portal/src/pages/customers/customers.mock.ts @@ -14,6 +14,7 @@ export interface Customer { country: string; address: string; notes: string; + documentsComplete: boolean; } const seedCustomers: Customer[] = [ @@ -30,6 +31,7 @@ const seedCustomers: Customer[] = [ country: "Ethiopia", address: "Bole Road, Sub-City 03, Building 17", notes: "Top-tier importer. Prefers weekly invoicing.", + documentsComplete: false, }, { id: 2, @@ -44,6 +46,7 @@ const seedCustomers: Customer[] = [ country: "Ethiopia", address: "Industrial Park, Zone B, Warehouse 4", notes: "Awaiting compliance documents.", + documentsComplete: false, }, { id: 3, @@ -58,6 +61,7 @@ const seedCustomers: Customer[] = [ country: "Djibouti", address: "Port Quarter, Avenue 26, Block 9", notes: "Account paused since last quarter.", + documentsComplete: true, }, ]; @@ -99,6 +103,7 @@ const generated: Customer[] = extras.map((entry, i) => { country: entry.country, address: `Block ${i + 1}, Street ${10 + i}, Quarter ${(i % 5) + 1}`, notes: `Mock customer #${id}.`, + documentsComplete: i % 3 === 0, }; }); diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index 115395f1a..baca5cd34 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -33,6 +33,7 @@ import type { CompanyInfoResponse, CreateCompanyPayload, } from "./companies.service"; +import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; import type { AuthUser, GenerateVerificationCodePayload, @@ -101,6 +102,18 @@ export const api = { "create", companiesService.create, ), + + getProfile: endpoint( + "companies", + "getProfile", + companiesService.getProfile, + ), + + updateProfile: endpoint( + "companies", + "updateProfile", + companiesService.updateProfile, + ), }, bookings: { diff --git a/apps/edr-freight-web/portal/src/services/companies.service.ts b/apps/edr-freight-web/portal/src/services/companies.service.ts index 08f757529..359881a9b 100644 --- a/apps/edr-freight-web/portal/src/services/companies.service.ts +++ b/apps/edr-freight-web/portal/src/services/companies.service.ts @@ -2,6 +2,7 @@ import { client } from "@/utils/api"; import { unwrap } from "@/utils/endpoint"; import { URL_CONSTANTS } from "@/constants/URLS"; import type { ApiResponse } from "@/types/apiResponse"; +import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; import { isAxiosError } from "axios"; export interface ExternalProfileResponse { @@ -81,6 +82,21 @@ export const companiesService = { return unwrap(response.data); }, + getProfile: async (): Promise => { + const response = await client.get>( + URL_CONSTANTS.COMPANIES_API.PROFILE, + ); + return unwrap(response.data); + }, + + updateProfile: async (payload: UpdateProfilePayload): Promise => { + const response = await client.patch>( + URL_CONSTANTS.COMPANIES_API.PROFILE, + payload, + ); + return unwrap(response.data); + }, + uploadDocuments: async ( companyId: string, files: Record, diff --git a/apps/edr-freight-web/portal/src/types/profile.ts b/apps/edr-freight-web/portal/src/types/profile.ts new file mode 100644 index 000000000..cab34ee23 --- /dev/null +++ b/apps/edr-freight-web/portal/src/types/profile.ts @@ -0,0 +1,43 @@ +export interface ProfileResponse { + companyId: string; + companyName: string; + companyEmail: string | null; + companyPhone: string | null; + companyLocation: string; + companyAddress: string | null; + tinNumber: string; + vatNumber: string | null; + fanNumber: string | null; + contactPersonName: string | null; + contactPersonPhone: string | null; + generalManagerName: string | null; + generalManagerEmail: string | null; + generalManagerPhone: string | null; + poaName: string | null; + poaPhone: string | null; + poaEmail: string | null; + poaLocation: string | null; + poaAddress: string | null; + profileId: string; +} + +export interface UpdateProfilePayload { + companyName?: string; + companyEmail?: string; + companyPhone?: string; + companyLocation?: string; + companyAddress?: string; + tin?: string; + vatNumber?: string; + fanNumber?: string; + contactPersonName?: string; + contactPersonPhone?: string; + generalManagerName?: string; + generalManagerEmail?: string; + generalManagerPhone?: string; + poaName?: string; + poaPhone?: string; + poaEmail?: string; + poaLocation?: string; + poaAddress?: string; +}