Merge pull request #89 from Tria-plc/freight/feat/onboarding-docs

freight/feat/onboarding docs
This commit is contained in:
Nathnael Wondisha
2026-06-04 15:42:56 +03:00
committed by GitHub
24 changed files with 2264 additions and 773 deletions

View File

@@ -30,6 +30,7 @@ import {
} from "./seed/edr-freight.seed"; } from "./seed/edr-freight.seed";
import { EdrOrgSeeder } from "./seed/edr-org.seeder"; import { EdrOrgSeeder } from "./seed/edr-org.seeder";
import { DemoUsersSeeder } from "./seed/demo-users.seeder"; import { DemoUsersSeeder } from "./seed/demo-users.seeder";
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
@Module({ @Module({
imports: [ imports: [
@@ -71,18 +72,20 @@ import { DemoUsersSeeder } from "./seed/demo-users.seeder";
BackofficeModule, BackofficeModule,
DemoPermissionsModule, DemoPermissionsModule,
], ],
providers: [EdrOrgSeeder, DemoUsersSeeder], providers: [EdrOrgSeeder, DemoUsersSeeder, FileUploadSettingsSeeder],
}) })
export class AppModule implements OnApplicationBootstrap { export class AppModule implements OnApplicationBootstrap {
constructor( constructor(
private readonly seeder: DataSeeder, private readonly seeder: DataSeeder,
private readonly edrOrgSeeder: EdrOrgSeeder, private readonly edrOrgSeeder: EdrOrgSeeder,
private readonly demoUsersSeeder: DemoUsersSeeder, private readonly demoUsersSeeder: DemoUsersSeeder,
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
) { } ) { }
async onApplicationBootstrap() { async onApplicationBootstrap() {
await this.seeder.run(); await this.seeder.run();
await this.edrOrgSeeder.run(); await this.edrOrgSeeder.run();
await this.demoUsersSeeder.run(); await this.demoUsersSeeder.run();
await this.fileUploadSettingsSeeder.run();
} }
} }

View File

@@ -1,6 +1,8 @@
import { Controller, Get, Post, Patch, Delete, Body, Param, Query, ParseUUIDPipe, HttpCode, HttpStatus } from '@nestjs/common'; import { Controller, Get, Post, Patch, Delete, Body, Param, Query, ParseUUIDPipe, HttpCode, HttpStatus, UseInterceptors, UploadedFiles } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { AnyFilesInterceptor } from '@nestjs/platform-express';
import { ApiOperation, ApiTags, ApiConsumes } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common'; import { CurrentUser } from '@edr/api-common';
import { FilesService } from '../files/files.service';
import { CompaniesService } from './companies.service'; import { CompaniesService } from './companies.service';
import { CreateCompanyDto } from './dto/create-company.dto'; import { CreateCompanyDto } from './dto/create-company.dto';
import { UpdateCompanyDto } from './dto/update-company.dto'; import { UpdateCompanyDto } from './dto/update-company.dto';
@@ -11,6 +13,8 @@ import { ResponseCompanyDto } from './dto/response-company.dto';
import { ResponseExternalProfileDto } from './dto/response-external-profile.dto'; import { ResponseExternalProfileDto } from './dto/response-external-profile.dto';
import { ResponseFFClientDto } from './dto/response-ff-client.dto'; import { ResponseFFClientDto } from './dto/response-ff-client.dto';
import { CompanyInfoResponseDto } from './dto/company-info-response.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 { interface CurrentIamUser {
id: string; id: string;
@@ -22,7 +26,10 @@ interface CurrentIamUser {
@ApiTags('Companies') @ApiTags('Companies')
@Controller('companies') @Controller('companies')
export class CompaniesController { export class CompaniesController {
constructor(private readonly companiesService: CompaniesService) {} constructor(
private readonly companiesService: CompaniesService,
private readonly filesService: FilesService,
) {}
@Get('getInfo') @Get('getInfo')
@ApiOperation({ summary: 'Get company info for the current user' }) @ApiOperation({ summary: 'Get company info for the current user' })
@@ -31,6 +38,22 @@ export class CompaniesController {
return new CompanyInfoResponseDto(profile, company); return new CompanyInfoResponseDto(profile, company);
} }
@Get('profile')
@ApiOperation({ summary: 'Get flattened profile for the settings page' })
async getProfile(@CurrentUser() user: CurrentIamUser): Promise<ProfileResponseDto> {
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<ProfileResponseDto> {
return this.companiesService.updateProfile(user.id, dto);
}
@Post('create') @Post('create')
@ApiOperation({ summary: 'Create a company with its associated external profile (onboarding)' }) @ApiOperation({ summary: 'Create a company with its associated external profile (onboarding)' })
async createWithProfile( async createWithProfile(
@@ -105,6 +128,17 @@ export class CompaniesController {
await this.companiesService.deleteCompany(id); await this.companiesService.deleteCompany(id);
} }
@Post(':companyId/documents')
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Upload documents for a company (onboarding)' })
async uploadDocuments(
@Param('companyId', ParseUUIDPipe) companyId: string,
@UploadedFiles() files: Array<Express.Multer.File>,
) {
return this.filesService.uploadMany(companyId, 'companies', files);
}
@Post(':companyId/profiles') @Post(':companyId/profiles')
@ApiOperation({ summary: 'Add a profile (employee) to a company' }) @ApiOperation({ summary: 'Add a profile (employee) to a company' })
async createProfile( async createProfile(

View File

@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { FilesModule } from '../files/files.module';
import { CompaniesController } from './companies.controller'; import { CompaniesController } from './companies.controller';
import { CompaniesService } from './companies.service'; import { CompaniesService } from './companies.service';
import { CompaniesRepository } from './companies.repository'; import { CompaniesRepository } from './companies.repository';
@@ -10,7 +11,7 @@ import { ExternalProfile } from './entities/external-profile.entity';
import { FFClient } from './entities/ff-client.entity'; import { FFClient } from './entities/ff-client.entity';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient])], imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient]), FilesModule],
controllers: [CompaniesController], controllers: [CompaniesController],
providers: [CompaniesService, CompaniesRepository, ExternalProfileRepository, FFClientRepository], providers: [CompaniesService, CompaniesRepository, ExternalProfileRepository, FFClientRepository],
exports: [CompaniesService], exports: [CompaniesService],

View File

@@ -7,6 +7,8 @@ import { UpdateCompanyDto } from './dto/update-company.dto';
import { CreateExternalProfileDto } from './dto/create-external-profile.dto'; import { CreateExternalProfileDto } from './dto/create-external-profile.dto';
import { CreateFFClientDto } from './dto/create-ff-client.dto'; import { CreateFFClientDto } from './dto/create-ff-client.dto';
import { CreateCompanyWithProfileDto } from './dto/create-company-with-profile.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 { Company } from './entities/company.entity';
import { ExternalProfile } from './entities/external-profile.entity'; import { ExternalProfile } from './entities/external-profile.entity';
import { FFClient } from './entities/ff-client.entity'; import { FFClient } from './entities/ff-client.entity';
@@ -103,6 +105,42 @@ export class CompaniesService {
return updated; return updated;
} }
async updateProfile(userId: string, dto: UpdateProfileDto): Promise<ProfileResponseDto> {
const { profile, company } = await this.getCompanyInfoByUserId(userId);
const companyUpdates: Record<string, any> = {};
const attrUpdates: Record<string, any> = { ...(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<void> { async deleteCompany(id: string): Promise<void> {
await this.findCompanyById(id); await this.findCompanyById(id);
await this.companiesRepo.softDelete(id); await this.companiesRepo.softDelete(id);

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -0,0 +1,125 @@
import { Injectable, Logger } from "@nestjs/common";
import { DataSource } from "typeorm";
import { FileUploadField } from "../modules/file-upload-settings/entities/file-upload-field.entity";
import { FileUploadSetting } from "../modules/file-upload-settings/entities/file-upload-setting.entity";
const COMPANY_ONBOARDING_DOCUMENTS = [
{
code: "company_onboarding_documents_customer",
label: "Customer onboarding documents",
entity: "customer",
},
{
code: "company_onboarding_documents_forwarder",
label: "Forwarder onboarding documents",
entity: "other",
},
{
code: "company_onboarding_documents_transporter",
label: "Transporter onboarding documents",
entity: "other",
},
{
code: "company_onboarding_documents_forwarder_dj",
label: "Djibouti forwarder onboarding documents",
entity: "other",
},
] as const;
const COMPANY_ONBOARDING_DESCRIPTION =
"Required documents for external company onboarding. The same set applies to customers, forwarders, transporters, and brokers.";
const COMPANY_ONBOARDING_FIELDS = [
{
fileKey: "business_license",
fileLabel: "Business License / Trade License",
helpText: "Verified against the government trade system during registration.",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
maxSizeMb: 10,
displayOrder: 1,
},
{
fileKey: "tin_certificate",
fileLabel: "TIN Certificate",
helpText: "Verified against the TIN registry during registration.",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
maxSizeMb: 10,
displayOrder: 2,
},
{
fileKey: "national_id_passport",
fileLabel: "National ID / Passport",
helpText: "Verified against the National ID API during registration.",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
maxSizeMb: 10,
displayOrder: 3,
},
] as const;
@Injectable()
export class FileUploadSettingsSeeder {
private readonly logger = new Logger(FileUploadSettingsSeeder.name);
constructor(private readonly dataSource: DataSource) {}
async run() {
await this.dataSource.transaction(async (manager) => {
const settingRepository = manager.getRepository(FileUploadSetting);
const fieldRepository = manager.getRepository(FileUploadField);
for (const documentSetting of COMPANY_ONBOARDING_DOCUMENTS) {
await settingRepository.upsert(
{
code: documentSetting.code,
label: documentSetting.label,
description: COMPANY_ONBOARDING_DESCRIPTION,
entity: documentSetting.entity,
},
{
conflictPaths: { code: true },
},
);
const setting = await settingRepository.findOne({
where: { code: documentSetting.code },
select: { id: true, code: true },
});
if (!setting) {
throw new Error(`file_upload_setting_seed_failed:${documentSetting.code}`);
}
await fieldRepository.delete({ settingId: setting.id });
await fieldRepository.insert(
COMPANY_ONBOARDING_FIELDS.map((field, index) => ({
settingId: setting.id,
fileKey: field.fileKey,
fileLabel: field.fileLabel,
helpText: field.helpText,
isRequired: field.isRequired,
isMultiple: field.isMultiple,
maxFiles: field.maxFiles,
allowedExtensions: [...field.allowedExtensions],
maxSizeMb: field.maxSizeMb,
displayOrder: field.displayOrder ?? index + 1,
})),
);
}
});
this.logger.log(
"Ensured company onboarding file upload settings for external companies",
);
}
}

View File

@@ -76,9 +76,7 @@ export const useContainerTypeOptions = (
enabled = true, enabled = true,
) => ) =>
useQuery({ useQuery({
queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions("container-types", { queryKey: api.ruleEngine.list.queryKey(),
includeNone,
}),
queryFn: () => queryFn: () =>
api.ruleEngine.list.call({ api.ruleEngine.list.call({
resource: "container-types", resource: "container-types",

View File

@@ -14,11 +14,13 @@ import {
Home, Home,
Loader2, Loader2,
User, User,
Settings,
} from "lucide-react"; } from "lucide-react";
import useAuth from "./hooks/useAuth"; import useAuth from "./hooks/useAuth";
import ProfilePage from "./pages/ProfilePage"; import ProfilePage from "./pages/ProfilePage";
import SettingsPage from "./pages/SettingsPage";
import MyPortalPage from "./pages/MyPortalPage"; import MyPortalPage from "./pages/MyPortalPage";
import EDRFreightLandingPage from "./pages/EDRFreightLandingPage"; import EDRFreightLandingPage from "./pages/EDRFreightLandingPage";
import SignupPage from "./pages/accounts/SignupPage"; import SignupPage from "./pages/accounts/SignupPage";
@@ -40,12 +42,14 @@ const sidebarItems: SidebarItem[] = [
{ label: "Tracking", href: "/tracking", icon: <MapPin /> }, { label: "Tracking", href: "/tracking", icon: <MapPin /> },
{ label: "Billing", href: "/billing", icon: <Receipt /> }, { label: "Billing", href: "/billing", icon: <Receipt /> },
{ label: "Profile", href: "/profile", icon: <User /> }, { label: "Profile", href: "/profile", icon: <User /> },
{ label: "Settings", href: "/settings", icon: <Settings /> },
]; ];
const App = () => { const App = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { user, isPending, logout, customer, customerQuery } = useAuth(); const { user, isPending, logout, customer, customerQuery } = useAuth();
useEffect(() => { useEffect(() => {
if (isPending) return; if (isPending) return;
const isInProtectedRoutes = sidebarItems.find((item) => const isInProtectedRoutes = sidebarItems.find((item) =>
@@ -107,6 +111,7 @@ const App = () => {
<Route path="/tracking" element={<TrackingPage />} /> <Route path="/tracking" element={<TrackingPage />} />
<Route path="/billing" element={<BillingPage />} /> <Route path="/billing" element={<BillingPage />} />
<Route path="/profile" element={<ProfilePage />} /> <Route path="/profile" element={<ProfilePage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route> </Route>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>

View File

@@ -82,6 +82,8 @@ export const URL_CONSTANTS = {
COMPANIES_API: { COMPANIES_API: {
GET_INFO: "/api/companies/getInfo", GET_INFO: "/api/companies/getInfo",
CREATE: "/api/companies/create", CREATE: "/api/companies/create",
PROFILE: "/api/companies/profile",
DOCUMENTS: (id: string) => `/api/companies/${id}/documents`,
}, },
BOOKINGS: { BOOKINGS: {

View File

@@ -29,7 +29,6 @@ const useAuth = () => {
const authQuery = useQuery( const authQuery = useQuery(
api.auth.getMyInfo.queryOptions({ api.auth.getMyInfo.queryOptions({
enabled: !!getCookie("auth-token"),
retry: false, retry: false,
staleTime: 10 * 60 * 1000, staleTime: 10 * 60 * 1000,
}), }),

View File

@@ -1,4 +1,4 @@
import { useMemo } from "react"; import { useMemo, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { import {
ArrowRight, ArrowRight,
@@ -14,6 +14,8 @@ import {
Plus, Plus,
Receipt, Receipt,
Truck, Truck,
UploadCloud,
X,
} from "lucide-react"; } from "lucide-react";
import { import {
@@ -48,6 +50,7 @@ export default function MyPortalPage() {
const outstandingInvoices = myInvoices.filter( const outstandingInvoices = myInvoices.filter(
(inv) => inv.status === "Sent" || inv.status === "Overdue", (inv) => inv.status === "Sent" || inv.status === "Overdue",
); );
const [dismissed, setDismissed] = useState(false);
const totalOutstanding = outstandingInvoices const totalOutstanding = outstandingInvoices
.filter((inv) => inv.currency === "USD") .filter((inv) => inv.currency === "USD")
.reduce((sum, inv) => sum + inv.amount, 0); .reduce((sum, inv) => sum + inv.amount, 0);
@@ -61,6 +64,34 @@ export default function MyPortalPage() {
return ( return (
<div className="min-h-screen bg-background p-6"> <div className="min-h-screen bg-background p-6">
<div className="mx-auto max-w-7xl space-y-6"> <div className="mx-auto max-w-7xl space-y-6">
{/* Documents banner */}
{!me.documentsComplete && !dismissed && (
<div className="flex items-start gap-3 rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">
<UploadCloud className="mt-0.5 h-5 w-5 shrink-0 text-amber-500" />
<div className="flex-1">
<p className="font-semibold">Upload your documents</p>
<p className="mt-0.5 text-amber-700">
To enable all account features, please upload your Business
License, TIN Certificate, and National ID / Passport.
</p>
<Link
to="/settings?tab=documents"
className="mt-2 inline-flex items-center gap-1 font-medium text-amber-900 underline underline-offset-2 transition hover:text-amber-700"
>
Upload now
</Link>
</div>
<button
type="button"
onClick={() => setDismissed(true)}
className="shrink-0 rounded-lg p-1 text-amber-400 transition hover:bg-amber-100 hover:text-amber-600"
aria-label="Dismiss"
>
<X className="h-4 w-4" />
</button>
</div>
)}
{/* Welcome banner */} {/* Welcome banner */}
<div className="overflow-hidden rounded-3xl bg-gradient-to-r from-[#059669] to-[#10B981] p-6 text-white shadow-sm"> <div className="overflow-hidden rounded-3xl bg-gradient-to-r from-[#059669] to-[#10B981] p-6 text-white shadow-sm">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">

View File

@@ -1,135 +1,46 @@
import { useMemo } from "react"; import { User, Building2, Phone, Mail, MapPin, ShieldCheck, Briefcase, UserCheck, Fingerprint, FileCheck, Globe, Building } from "lucide-react";
import { import { useQuery } from "@tanstack/react-query";
User, import { api } from "@/services/api";
Building2, import { Card, CardHeader, CardTitle, CardDescription, CardContent, Badge, Separator } from "@edr/ui-common";
Phone,
Mail, function InfoItem({ icon, label, value }: { icon?: React.ReactNode; label: string; value?: string | null }) {
MapPin, return (
ShieldCheck, <div className="flex items-start gap-3">
Briefcase, {icon && <div className="mt-1 text-muted-foreground [&_svg]:size-4">{icon}</div>}
UserCheck, <div className="flex flex-col gap-0.5">
Building, <p className="text-[10px] font-bold uppercase tracking-tight text-muted-foreground">{label}</p>
Globe, <p className="text-sm font-bold text-foreground">{value || "—"}</p>
Fingerprint, </div>
FileCheck, </div>
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";
export default function ProfilePage() { export default function ProfilePage() {
const { user, customer, isPending } = useAuth(); const { data: profile, isPending } = useQuery(
api.companies.getProfile.queryOptions(),
const documentSettings = useMemo<IFileUploadSetting>(() => ({ );
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(),
},
],
}), []);
if (isPending) { if (isPending) {
return ( return (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-primary"></div> <div className="h-8 w-8 animate-spin rounded-full border-b-2 border-primary" />
</div> </div>
); );
} }
const displayName = user?.name?.en || user?.username || user?.email || "User"; if (!profile) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground">No company profile found.</p>
</div>
);
}
return ( return (
<div className="container mx-auto max-w-7xl px-4 py-8"> <div className="px-4 py-8">
<div className="flex flex-col gap-8"> <div className="mx-auto max-w-7xl">
{/* Header Section */} <div className="flex flex-col gap-8">
<div className="flex flex-col gap-6 md:flex-row md:items-center md:justify-between"> {/* Header */}
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
<div className="flex size-24 items-center justify-center rounded-2xl bg-primary/10 text-primary shadow-inner"> <div className="flex size-24 items-center justify-center rounded-2xl bg-primary/10 text-primary shadow-inner">
<User className="size-12" /> <User className="size-12" />
@@ -137,7 +48,7 @@ export default function ProfilePage() {
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h1 className="text-3xl font-black tracking-tight text-foreground"> <h1 className="text-3xl font-black tracking-tight text-foreground">
{displayName} {profile.companyName}
</h1> </h1>
<Badge variant="secondary" className="font-bold uppercase tracking-wider"> <Badge variant="secondary" className="font-bold uppercase tracking-wider">
Verified Verified
@@ -145,182 +56,129 @@ export default function ProfilePage() {
</div> </div>
<p className="flex items-center gap-2 font-medium text-muted-foreground"> <p className="flex items-center gap-2 font-medium text-muted-foreground">
<Building className="size-4" /> <Building className="size-4" />
{customer?.companyName || "No Company Linked"} {profile.companyName}
</p> </p>
</div> </div>
</div> </div>
<div className="flex items-center gap-3">
<Button variant="outline">
<Settings2 data-icon="inline-start" />
Account Settings
</Button>
</div>
</div>
<Separator /> <Separator />
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
{/* Left Column - Personal & Company Info */} {/* Left Column */}
<div className="flex flex-col gap-8 lg:col-span-2"> <div className="flex flex-col gap-8 lg:col-span-2">
<div className="grid grid-cols-1 gap-8 md:grid-cols-2"> <div className="grid grid-cols-1 gap-8 md:grid-cols-2">
{/* Personal Details Card */} {/* Company Details */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 className="size-5 text-primary" />
Company Details
</CardTitle>
<CardDescription>Business registration information</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<InfoItem icon={<Globe />} label="Location" value={profile.companyLocation} />
<InfoItem icon={<MapPin />} label="Address" value={profile.companyAddress} />
<InfoItem icon={<FileCheck />} label="TIN Number" value={profile.tinNumber} />
<InfoItem icon={<ShieldCheck />} label="FAN Number" value={profile.fanNumber} />
<InfoItem icon={<Mail />} label="Email" value={profile.companyEmail} />
<InfoItem icon={<Phone />} label="Phone" value={profile.companyPhone} />
</CardContent>
</Card>
{/* Personal Details (from ExternalProfile) */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Fingerprint className="size-5 text-primary" />
Profile Details
</CardTitle>
<CardDescription>Your linked user profile</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<InfoItem icon={<User />} label="Profile" value="Primary Contact" />
</CardContent>
</Card>
</div>
{/* Personnel Card */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Fingerprint className="size-5 text-primary" /> <Briefcase className="size-5 text-primary" />
Personal Details Key Personnel
</CardTitle> </CardTitle>
<CardDescription>Your account contact information</CardDescription> <CardDescription>Management and contact persons</CardDescription>
<CardAction>
<Button variant="ghost" size="icon">
<ExternalLink />
</Button>
</CardAction>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-4"> <CardContent className="grid grid-cols-1 gap-8 md:grid-cols-2">
<InfoItem icon={<Mail />} label="Email Address" value={user?.email} /> <div className="flex flex-col gap-4">
<InfoItem icon={<Phone />} label="Phone Number" value={user?.phoneNumber} /> <h3 className="border-l-4 border-primary pl-3 text-sm font-bold uppercase tracking-wide text-foreground">
<InfoItem icon={<UserCheck />} label="Username" value={user?.username} /> Contact Person
</h3>
<div className="flex flex-col gap-3 pl-4">
<InfoItem label="Name" value={profile.contactPersonName} />
<InfoItem label="Phone" value={profile.contactPersonPhone} />
</div>
</div>
<div className="flex flex-col gap-4">
<h3 className="border-l-4 border-accent pl-3 text-sm font-bold uppercase tracking-wide text-foreground">
General Manager
</h3>
<div className="flex flex-col gap-3 pl-4">
<InfoItem label="Name" value={profile.generalManagerName} />
<InfoItem label="Email" value={profile.generalManagerEmail} />
<InfoItem label="Phone" value={profile.generalManagerPhone} />
</div>
</div>
</CardContent> </CardContent>
</Card> </Card>
{/* Company Details Card */} {/* Power of Attorney */}
<Card> {profile.poaName && (
<CardHeader> <Card className="border-dashed">
<CardTitle className="flex items-center gap-2"> <CardHeader>
<Building2 className="size-5 text-primary" /> <CardTitle className="flex items-center gap-2">
Company Details <UserCheck className="size-5 text-accent" />
</CardTitle> Power of Attorney
<CardDescription>Business registration information</CardDescription> </CardTitle>
</CardHeader> <CardDescription>Authorized representative details</CardDescription>
<CardContent className="flex flex-col gap-4"> </CardHeader>
<InfoItem icon={<Globe />} label="Location" value={customer?.companyLocation} /> <CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<InfoItem icon={<MapPin />} label="Address" value={customer?.companyAddress} /> <InfoItem label="PoA Name" value={profile.poaName} />
<InfoItem icon={<FileCheck />} label="TIN Number" value={customer?.tinNumber} /> <InfoItem label="PoA Email" value={profile.poaEmail} />
<InfoItem icon={<ShieldCheck />} label="FAN Number" value={customer?.fanNumber} /> <InfoItem label="PoA Phone" value={profile.poaPhone} />
<InfoItem label="PoA Location" value={profile.poaLocation} />
</CardContent>
</Card>
)}
</div>
{/* Right Column */}
<div className="flex flex-col gap-8">
<Card className="relative overflow-hidden border-none bg-foreground text-background shadow-xl">
<div className="absolute right-0 top-0 p-4 opacity-10">
<ShieldCheck className="size-32" />
</div>
<CardContent className="relative z-10 flex flex-col gap-4 px-6 py-8">
<h3 className="text-xl font-black">Secure Account</h3>
<p className="text-sm leading-relaxed text-muted-foreground/80">
Your information is protected by enterprise-grade security.
Contact support for verified information updates.
</p>
<div className="pt-2">
<a
href="/settings"
className="inline-flex h-9 items-center justify-center rounded-md bg-background px-4 text-sm font-medium text-foreground hover:bg-background/90"
>
Edit Settings
</a>
</div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{/* Personnel Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Briefcase className="size-5 text-primary" />
Key Personnel
</CardTitle>
<CardDescription>Management and contact persons</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-8 md:grid-cols-2">
<div className="flex flex-col gap-4">
<h3 className="border-l-4 border-primary pl-3 text-sm font-bold uppercase tracking-wide text-foreground">
Contact Person
</h3>
<div className="flex flex-col gap-3 pl-4">
<InfoItem label="Name" value={customer?.contactPersonName} />
<InfoItem label="Phone" value={customer?.contactPersonPhone} />
</div>
</div>
<div className="flex flex-col gap-4">
<h3 className="border-l-4 border-accent pl-3 text-sm font-bold uppercase tracking-wide text-foreground">
General Manager
</h3>
<div className="flex flex-col gap-3 pl-4">
<InfoItem label="Name" value={customer?.generalManagerName} />
<InfoItem label="Email" value={customer?.generalManagerEmail} />
<InfoItem label="Phone" value={customer?.generalManagerPhone} />
</div>
</div>
</CardContent>
</Card>
{/* Power of Attorney Section (Conditional) */}
{customer?.poaName && (
<Card className="border-dashed">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<UserCheck className="size-5 text-accent" />
Power of Attorney
</CardTitle>
<CardDescription>Authorized representative details</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<InfoItem label="PoA Name" value={customer.poaName} />
<InfoItem label="PoA Email" value={customer.poaEmail} />
<InfoItem label="PoA Phone" value={customer.poaPhone} />
<InfoItem label="PoA Location" value={customer.poaLocation} />
</CardContent>
</Card>
)}
</div>
{/* Right Column - Documents */}
<div className="flex flex-col gap-8">
<Card className="border-primary/20 bg-primary/[0.02] shadow-md">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileCheck className="size-6 text-primary" />
Documents
</CardTitle>
<CardDescription>Manage required business documents</CardDescription>
</CardHeader>
<CardContent className="px-6 pb-6 pt-0">
<SmartFileInput
file={documentSettings}
variant="minimal"
className="flex flex-col gap-4"
/>
</CardContent>
</Card>
<Card className="relative overflow-hidden border-none bg-foreground text-background shadow-xl">
<div className="absolute right-0 top-0 p-4 opacity-10">
<ShieldCheck className="size-32" />
</div>
<CardContent className="relative z-10 flex flex-col gap-4 px-6 py-8">
<h3 className="text-xl font-black">Secure Account</h3>
<p className="text-sm leading-relaxed text-muted-foreground/80">
Your information is protected by enterprise-grade security.
Contact support for verified information updates.
</p>
<div className="pt-2">
<Button variant="secondary" size="sm">
Contact Support
</Button>
</div>
</CardContent>
</Card>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
); );
} }
function InfoItem({
icon,
label,
value,
}: {
icon?: React.ReactNode;
label: string;
value?: string | null;
}) {
return (
<div className="flex items-start gap-3">
{icon && (
<div className="mt-1 text-muted-foreground [&_svg]:size-4">
{icon}
</div>
)}
<div className="flex flex-col gap-0.5">
<p className="text-[10px] font-bold uppercase tracking-tight text-muted-foreground">
{label}
</p>
<p className="text-sm font-bold text-foreground">
{value || "—"}
</p>
</div>
</div>
);
}

View File

@@ -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<typeof settingsSchema>;
const TABS: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [
{ id: "company", label: "Company Profile", icon: <Building2 className="size-4" /> },
{ id: "contact", label: "Contact Person", icon: <User className="size-4" /> },
{ id: "gm", label: "General Manager", icon: <Briefcase className="size-4" /> },
{ id: "poa", label: "Power of Attorney", icon: <UserCheck className="size-4" /> },
{ id: "documents", label: "Documents", icon: <FileCheck className="size-4" /> },
];
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<string, File | File[] | null>
>({});
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<FormData>({
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<string, File | File[] | null>) =>
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 (
<div className="flex h-full items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-primary" />
</div>
);
}
if (!profile) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground">No company profile found.</p>
</div>
);
}
const onSubmit = (data: FormData) => {
updateMutation.mutate(data);
};
return (
<div className="px-4 py-8">
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-2xl font-black tracking-tight text-foreground">
Account Settings
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Manage your company profile, personnel, and documents
</p>
</div>
<Badge variant="secondary" className="font-bold uppercase tracking-wider">
Verified
</Badge>
</div>
{/* Tab Bar */}
<div className="mb-6 flex flex-wrap gap-1 border-b border-border">
{TABS.map((t) => (
<button
key={t.id}
type="button"
onClick={() => setTab(t.id)}
className={cn(
"flex items-center gap-2 border-b-2 px-4 py-3 text-sm font-semibold transition-colors",
tab === t.id
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground",
)}
>
{t.icon}
{t.label}
</button>
))}
</div>
<form onSubmit={handleSubmit(onSubmit)}>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{tab === "company" && <><Building2 className="size-5 text-primary" /> Company Profile</>}
{tab === "contact" && <><User className="size-5 text-primary" /> Contact Person</>}
{tab === "gm" && <><Briefcase className="size-5 text-primary" /> General Manager</>}
{tab === "poa" && <><UserCheck className="size-5 text-accent" /> Power of Attorney</>}
{tab === "documents" && <><FileCheck className="size-5 text-primary" /> Documents</>}
</CardTitle>
<CardDescription>
{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"}
</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup className="gap-4">
{/* Company Profile Tab */}
{tab === "company" && (
<>
<Field data-invalid={Boolean(errors.companyName)}>
<FieldLabel>Company Name</FieldLabel>
<Input
placeholder="Global Logistics Ltd"
aria-invalid={Boolean(errors.companyName)}
{...register("companyName")}
/>
<FieldError errors={[errors.companyName]} />
</Field>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.companyEmail)}>
<FieldLabel>Company Email</FieldLabel>
<Input
type="email"
placeholder="ops@company.com"
aria-invalid={Boolean(errors.companyEmail)}
{...register("companyEmail")}
/>
<FieldError errors={[errors.companyEmail]} />
</Field>
<PhoneInput
countryCode={{ ...register("companyPhoneCountryCode") }}
phone={{
...register("companyPhone"),
placeholder: "912345678",
}}
countryCodeError={errors.companyPhoneCountryCode}
phoneError={errors.companyPhone}
label="Company Phone"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.companyLocation)}>
<FieldLabel>Location</FieldLabel>
<Input
placeholder="Addis Ababa, Ethiopia"
aria-invalid={Boolean(errors.companyLocation)}
{...register("companyLocation")}
/>
<FieldError errors={[errors.companyLocation]} />
</Field>
<Field data-invalid={Boolean(errors.companyAddress)}>
<FieldLabel>Address</FieldLabel>
<Input
placeholder="Bole Subcity, Woreda 03"
aria-invalid={Boolean(errors.companyAddress)}
{...register("companyAddress")}
/>
<FieldError errors={[errors.companyAddress]} />
</Field>
</div>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.tinNumber)}>
<FieldLabel>TIN Number (10 digits)</FieldLabel>
<Input
placeholder="1234567890"
maxLength={10}
aria-invalid={Boolean(errors.tinNumber)}
{...register("tinNumber")}
/>
<FieldError errors={[errors.tinNumber]} />
</Field>
<Field data-invalid={Boolean(errors.fanNumber)}>
<FieldLabel>FAN Number (16 digits)</FieldLabel>
<Input
placeholder="1234567890123456"
maxLength={16}
aria-invalid={Boolean(errors.fanNumber)}
{...register("fanNumber")}
/>
<FieldError errors={[errors.fanNumber]} />
</Field>
</div>
</>
)}
{/* Contact Person Tab */}
{tab === "contact" && (
<>
<Field data-invalid={Boolean(errors.contactPersonName)}>
<FieldLabel>Full Name</FieldLabel>
<Input
placeholder="Jane Smith"
aria-invalid={Boolean(errors.contactPersonName)}
{...register("contactPersonName")}
/>
<FieldError errors={[errors.contactPersonName]} />
</Field>
<PhoneInput
countryCode={{ ...register("contactPersonPhoneCountryCode") }}
phone={{
...register("contactPersonPhone"),
placeholder: "912345678",
}}
countryCodeError={errors.contactPersonPhoneCountryCode}
phoneError={errors.contactPersonPhone}
label="Phone Number"
/>
</>
)}
{/* General Manager Tab */}
{tab === "gm" && (
<>
<Field data-invalid={Boolean(errors.generalManagerName)}>
<FieldLabel>Full Name</FieldLabel>
<Input
placeholder="Abebe Bikila"
aria-invalid={Boolean(errors.generalManagerName)}
{...register("generalManagerName")}
/>
<FieldError errors={[errors.generalManagerName]} />
</Field>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.generalManagerEmail)}>
<FieldLabel>Email Address</FieldLabel>
<Input
type="email"
placeholder="gm@company.com"
aria-invalid={Boolean(errors.generalManagerEmail)}
{...register("generalManagerEmail")}
/>
<FieldError errors={[errors.generalManagerEmail]} />
</Field>
<PhoneInput
countryCode={{ ...register("generalManagerPhoneCountryCode") }}
phone={{
...register("generalManagerPhone"),
placeholder: "912345678",
}}
countryCodeError={errors.generalManagerPhoneCountryCode}
phoneError={errors.generalManagerPhone}
label="Phone Number"
/>
</div>
</>
)}
{/* Power of Attorney Tab */}
{tab === "poa" && (
<>
<p className="text-sm text-muted-foreground">
Power of Attorney details are optional. Fill them in if you have
an authorized representative, or leave blank.
</p>
<Field data-invalid={Boolean(errors.poaName)}>
<FieldLabel>PoA Full Name</FieldLabel>
<Input
placeholder="Authorized Representative Name"
aria-invalid={Boolean(errors.poaName)}
{...register("poaName")}
/>
<FieldError errors={[errors.poaName]} />
</Field>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.poaEmail)}>
<FieldLabel>PoA Email</FieldLabel>
<Input
type="email"
placeholder="poa@company.com"
aria-invalid={Boolean(errors.poaEmail)}
{...register("poaEmail")}
/>
<FieldError errors={[errors.poaEmail]} />
</Field>
<PhoneInput
countryCode={{ ...register("poaPhoneCountryCode") }}
phone={{ ...register("poaPhone"), placeholder: "912345678" }}
label="PoA Phone"
countryCodeError={errors.poaPhoneCountryCode}
phoneError={errors.poaPhone}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.poaLocation)}>
<FieldLabel>PoA Location</FieldLabel>
<Input
placeholder="City, Country"
aria-invalid={Boolean(errors.poaLocation)}
{...register("poaLocation")}
/>
<FieldError errors={[errors.poaLocation]} />
</Field>
<Field data-invalid={Boolean(errors.poaAddress)}>
<FieldLabel>PoA Address</FieldLabel>
<Input
placeholder="Full Address"
aria-invalid={Boolean(errors.poaAddress)}
{...register("poaAddress")}
/>
<FieldError errors={[errors.poaAddress]} />
</Field>
</div>
</>
)}
{/* Documents Tab */}
{tab === "documents" && (
<>
{docSettingQuery.isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : !docSettingQuery.data ? (
<p className="text-sm text-muted-foreground text-center py-4">
No document requirements configured for your account.
</p>
) : (
<SmartFileInput
file={docSettingQuery.data}
value={documentFiles}
onChange={setDocumentFiles}
/>
)}
{docSettingQuery.data && (
<div className="flex items-center justify-between pt-4">
<div className="flex items-center gap-2">
{docUploadMutation.isSuccess && (
<span className="flex items-center gap-1.5 text-sm font-medium text-emerald-600">
<CheckCircle2 className="size-4" />
Documents uploaded successfully
</span>
)}
{docUploadMutation.isError && (
<span className="flex items-center gap-1.5 text-sm font-medium text-red-600">
<XCircle className="size-4" />
Upload failed
</span>
)}
</div>
<Button
type="button"
onClick={() => docUploadMutation.mutate(documentFiles)}
disabled={docUploadMutation.isPending}
>
{docUploadMutation.isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Uploading...
</>
) : (
<>
<UploadCloud className="size-4" />
Upload Documents
</>
)}
</Button>
</div>
)}
</>
)}
</FieldGroup>
</CardContent>
{tab !== "documents" && (
<CardFooter className="flex items-center justify-between gap-4 border-t border-border px-6 py-4">
<div className="flex items-center gap-2">
{updateMutation.isSuccess && (
<span className="flex items-center gap-1.5 text-sm font-medium text-emerald-600">
<CheckCircle2 className="size-4" />
Saved successfully
</span>
)}
{updateMutation.isError && (
<span className="flex items-center gap-1.5 text-sm font-medium text-red-600">
<XCircle className="size-4" />
Save failed
</span>
)}
</div>
<div className="flex items-center gap-3">
<Button
type="button"
variant="outline"
disabled={isPending || !isDirty}
onClick={() => reset()}
>
Reset
</Button>
<Button type="submit" disabled={isPending}>
{updateMutation.isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Saving...
</>
) : (
<>
<Save className="size-4" />
Save Changes
</>
)}
</Button>
</div>
</CardFooter>
)}
</Card>
</form>
</div>
);
}

View File

@@ -14,10 +14,8 @@ import {
ChevronLeft, ChevronLeft,
UploadCloud, UploadCloud,
} from "lucide-react"; } from "lucide-react";
import type { OnboardingUserType } from "./types";
import type { AuthUser } from "@/types/auth"; import type { AuthUser } from "@/types/auth";
import type { CreateCompanyPayload } from "@/services/companies.service"; import type { CreateCompanyPayload } from "@/services/companies.service";
import type { FileUploadSetting } from "@/types/fileUploadSettings";
import PhoneInput from "@/components/auth/PhoneInput"; import PhoneInput from "@/components/auth/PhoneInput";
import { import {
Button, Button,
@@ -30,7 +28,7 @@ import {
} from "@edr/ui-common"; } from "@edr/ui-common";
import { api } from "@/services/api"; import { api } from "@/services/api";
type CompanyStep = "company" | "personnel" | "poa" | "documents"; type CompanyStep = "company" | "personnel" | "poa" | "documents" | "confirm";
const onboardingSchema = z.object({ const onboardingSchema = z.object({
companyName: z.string().min(1, "Company name is required"), companyName: z.string().min(1, "Company name is required"),
@@ -85,6 +83,7 @@ const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
], ],
poa: [], poa: [],
documents: [], documents: [],
confirm: [],
}; };
function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
@@ -116,26 +115,34 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
} }
export default function CompanyProfileForm({ export default function CompanyProfileForm({
userType, documentSettingCode,
documentFiles: controlledFiles,
onDocumentFilesChange,
user, user,
onSubmit, onSubmit,
isPending, isPending,
onBack, onBack,
}: { }: {
userType: OnboardingUserType; documentSettingCode: string;
documentFiles?: Record<string, File | File[] | null>;
onDocumentFilesChange?: (
files: Record<string, File | File[] | null>,
) => void;
user: AuthUser; user: AuthUser;
onSubmit: (data: CreateCompanyPayload) => void; onSubmit: (data: CreateCompanyPayload) => void;
isPending: boolean; isPending: boolean;
onBack: () => void; onBack: () => void;
}) { }) {
const [step, setStep] = useState<CompanyStep>("company"); const [step, setStep] = useState<CompanyStep>("company");
const [documentFiles, setDocumentFiles] = useState< const [internalFiles, setInternalFiles] = useState<
Record<string, File | File[] | null> Record<string, File | File[] | null>
>({}); >({});
const documentFiles = controlledFiles ?? internalFiles;
const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles;
const { data: uploadSettings = [], isLoading: loadingDocuments } = useQuery( const { data: uploadSetting, isLoading: loadingDocuments } = useQuery(
api.fileUploadSettings.getByEntity.queryOptions({ api.fileUploadSettings.getByCode.queryOptions({
input: { entity: "customer" }, input: { code: documentSettingCode },
refetchOnMount: false, refetchOnMount: false,
}), }),
); );
@@ -144,6 +151,7 @@ export default function CompanyProfileForm({
register, register,
handleSubmit, handleSubmit,
trigger, trigger,
watch,
formState: { errors }, formState: { errors },
} = useForm<FormData>({ } = useForm<FormData>({
resolver: zodResolver(onboardingSchema), resolver: zodResolver(onboardingSchema),
@@ -173,18 +181,20 @@ export default function CompanyProfileForm({
}, },
}); });
const hasDocuments = uploadSettings.length > 0; const formValues = watch();
const hasDocuments = Boolean(uploadSetting?.fields?.length);
const totalSteps = 5;
const nextStep = async () => { const nextStep = async () => {
if (step === "poa") { if (step === "poa") {
if (hasDocuments) { setStep("documents");
setStep("documents");
} else {
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
}
return; return;
} }
if (step === "documents") { if (step === "documents") {
setStep("confirm");
return;
}
if (step === "confirm") {
handleSubmit((data) => onSubmit(buildPayload(data, user)))(); handleSubmit((data) => onSubmit(buildPayload(data, user)))();
return; return;
} }
@@ -201,8 +211,10 @@ export default function CompanyProfileForm({
setStep("company"); setStep("company");
} else if (step === "poa") { } else if (step === "poa") {
setStep("personnel"); setStep("personnel");
} else { } else if (step === "documents") {
setStep("poa"); setStep("poa");
} else {
setStep("documents");
} }
}; };
@@ -228,31 +240,41 @@ export default function CompanyProfileForm({
<StepIcon <StepIcon
icon={<User className="size-5" />} icon={<User className="size-5" />}
active={step === "personnel"} active={step === "personnel"}
completed={step === "poa"} completed={
step === "poa" || step === "documents" || step === "confirm"
}
/> />
<StepIcon <StepIcon
icon={<FileText className="size-5" />} icon={<FileText className="size-5" />}
active={step === "poa"} active={step === "poa"}
completed={hasDocuments ? step === "documents" : step === "personnel"} completed={step === "documents" || step === "confirm"}
/>
<StepIcon
icon={<UploadCloud className="size-5" />}
active={step === "documents"}
completed={step === "confirm"}
/>
<StepIcon
icon={<CheckCircle2 className="size-5" />}
active={step === "confirm"}
completed={false}
/> />
{hasDocuments && (
<StepIcon
icon={<UploadCloud className="size-5" />}
active={step === "documents"}
completed={false}
/>
)}
</div> </div>
<p className="text-center text-sm text-muted-foreground mt-3"> <p className="text-center text-sm text-muted-foreground mt-3">
{step === "company" && `Step 1 of ${hasDocuments ? 4 : 3} — Company Information`} {step === "company" &&
{step === "personnel" && `Step 2 of ${hasDocuments ? 4 : 3} — Personnel Details`} `Step 1 of ${totalSteps} — Company Information`}
{step === "poa" && `Step 3 of ${hasDocuments ? 4 : 3} — Power of Attorney (Optional)`} {step === "personnel" &&
{step === "documents" && "Step 4 of 4 — Upload Documents (Optional)"} `Step 2 of ${totalSteps} — Personnel Details`}
{step === "poa" &&
`Step 3 of ${totalSteps} — Power of Attorney (Optional)`}
{step === "documents" &&
`Step 4 of ${totalSteps} — Upload Documents (Optional)`}
{step === "confirm" && `Step 5 of ${totalSteps} — Review & Confirm`}
</p> </p>
</div> </div>
<form <form
onSubmit={handleSubmit((data) => onSubmit(buildPayload(data, user)))} onSubmit={(e) => e.preventDefault()}
className="flex flex-col gap-4" className="flex flex-col gap-4"
> >
<FieldGroup className="gap-4"> <FieldGroup className="gap-4">
@@ -353,11 +375,6 @@ export default function CompanyProfileForm({
{step === "personnel" && ( {step === "personnel" && (
<> <>
<p className="text-sm text-muted-foreground">
Personal details are pulled from your account. Contact and
management info is collected below.
</p>
<div> <div>
<h3 className="text-sm font-semibold text-foreground mb-3"> <h3 className="text-sm font-semibold text-foreground mb-3">
Contact Person Contact Person
@@ -500,62 +517,155 @@ export default function CompanyProfileForm({
{step === "documents" && ( {step === "documents" && (
<> <>
<p className="text-sm text-muted-foreground">
Upload required documents for your registration. You can skip
this step and upload later from your account settings.
</p>
{loadingDocuments ? ( {loadingDocuments ? (
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-8">
<Loader2 className="size-6 animate-spin text-muted-foreground" /> <Loader2 className="size-6 animate-spin text-muted-foreground" />
</div> </div>
) : uploadSettings.length === 0 ? ( ) : !uploadSetting ? (
<p className="text-sm text-muted-foreground text-center py-4"> <p className="text-sm text-muted-foreground text-center py-4">
No document requirements found for your account type. No document requirements found for your account type.
</p> </p>
) : ( ) : (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{uploadSettings.map((setting) => ( <SmartFileInput
<SmartFileInput file={uploadSetting}
key={setting.id} value={documentFiles}
file={setting} onChange={setDocumentFiles}
value={documentFiles} />
onChange={setDocumentFiles}
/>
))}
</div> </div>
)} )}
</> </>
)} )}
{step === "confirm" && (
<div className="space-y-4 rounded-xl border border-border bg-card p-4">
<div>
<h3 className="text-base font-semibold text-foreground">
Review your registration
</h3>
<p className="text-sm text-muted-foreground mt-1">
Confirm the company details below before saving.
</p>
</div>
<div className="grid gap-3 md:grid-cols-2">
<ReviewRow
label="Company name"
value={formValues.companyName}
/>
<ReviewRow
label="Company email"
value={formValues.companyEmail}
/>
<ReviewRow
label="Company phone"
value={formValues.companyPhone}
/>
<ReviewRow
label="Location"
value={formValues.companyLocation}
/>
<ReviewRow label="Address" value={formValues.companyAddress} />
<ReviewRow label="TIN" value={formValues.tinNumber} />
<ReviewRow label="VAT" value={formValues.vatNumber} />
<ReviewRow label="FAN" value={formValues.fanNumber} />
<ReviewRow
label="Contact person"
value={formValues.contactPersonName}
/>
<ReviewRow
label="Contact phone"
value={`${formValues.contactPersonPhoneCountryCode}${formValues.contactPersonPhone}`}
/>
<ReviewRow
label="General manager"
value={formValues.generalManagerName}
/>
<ReviewRow
label="GM email"
value={formValues.generalManagerEmail}
/>
<ReviewRow
label="GM phone"
value={`${formValues.generalManagerPhoneCountryCode}${formValues.generalManagerPhone}`}
/>
<ReviewRow
label="PoA name"
value={formValues.poaName || undefined}
/>
<ReviewRow
label="PoA phone"
value={
formValues.poaPhone && formValues.poaPhoneCountryCode
? `${formValues.poaPhoneCountryCode}${formValues.poaPhone}`
: undefined
}
/>
<ReviewRow
label="PoA email"
value={formValues.poaEmail || undefined}
/>
<ReviewRow
label="PoA location"
value={formValues.poaLocation || undefined}
/>
</div>
</div>
)}
</FieldGroup> </FieldGroup>
<div className="flex items-center justify-between pt-2"> <div className="flex items-center justify-between pt-2">
<Button type="button" variant="outline" onClick={prevStep}> <Button type="button" variant="outline" onClick={prevStep}>
<ArrowLeft /> <ArrowLeft />
{step === "company" ? "Change Type" : "Back"} {step === "company"
? "Change Type"
: step === "confirm"
? "Back to Documents"
: "Back"}
</Button> </Button>
<Button type="button" onClick={nextStep} disabled={isPending}> <div className="flex items-center gap-3">
{isPending ? ( <Button
<> type="button"
<Loader2 className="animate-spin" /> onClick={step === "confirm" ? handleSubmit((data) => onSubmit(buildPayload(data, user))) : nextStep}
Submitting... disabled={isPending || (step === "documents" && !hasDocuments && loadingDocuments)}
</> >
) : step === "documents" ? ( {isPending ? (
"Complete Registration" <>
) : ( <Loader2 className="animate-spin" />
<> Submitting...
Next Step </>
<ArrowRight /> ) : step === "documents" ? (
</> "Continue"
)} ) : step === "confirm" ? (
</Button> "Submit Registration"
) : (
<>
Next Step
<ArrowRight />
</>
)}
</Button>
</div>
</div> </div>
</form> </form>
</> </>
); );
} }
function ReviewRow({ label, value }: { label: string; value?: string | null }) {
return (
<div className="rounded-lg border border-border bg-muted/20 p-3">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{label}
</div>
<div className="mt-1 text-sm font-medium text-foreground">
{value?.trim() ? value : "Not provided"}
</div>
</div>
);
}
function StepIcon({ function StepIcon({
icon, icon,
active, active,

View File

@@ -1,5 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useQuery } from "@tanstack/react-query";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import { import {
@@ -10,6 +11,7 @@ import {
CheckCircle2, CheckCircle2,
Loader2, Loader2,
ChevronLeft, ChevronLeft,
UploadCloud,
} from "lucide-react"; } from "lucide-react";
import type { AuthUser } from "@/types/auth"; import type { AuthUser } from "@/types/auth";
import type { CreateCompanyPayload } from "@/services/companies.service"; import type { CreateCompanyPayload } from "@/services/companies.service";
@@ -21,9 +23,11 @@ import {
FieldLabel, FieldLabel,
FieldError, FieldError,
FieldGroup, FieldGroup,
SmartFileInput,
} from "@edr/ui-common"; } from "@edr/ui-common";
import { api } from "@/services/api";
type DjiboutiStep = "company" | "representative"; type DjiboutiStep = "company" | "representative" | "documents" | "confirm";
const djiboutiSchema = z.object({ const djiboutiSchema = z.object({
companyName: z.string().min(1, "Company name is required"), companyName: z.string().min(1, "Company name is required"),
@@ -40,9 +44,23 @@ const djiboutiSchema = z.object({
type FormData = z.infer<typeof djiboutiSchema>; type FormData = z.infer<typeof djiboutiSchema>;
const stepLabels: Record<DjiboutiStep, string> = { const stepFields: Record<DjiboutiStep, (keyof FormData)[]> = {
company: "Step 1 of 2 — Company Information", company: [
representative: "Step 2 of 2 — Representative Details", "companyName",
"companyEmail",
"companyPhone",
"companyPhoneCountryCode",
"companyLocation",
"companyAddress",
],
representative: [
"repName",
"repEmail",
"repPhone",
"repPhoneCountryCode",
],
documents: [],
confirm: [],
}; };
function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
@@ -64,22 +82,43 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
} }
export default function DjiboutiAgentForm({ export default function DjiboutiAgentForm({
documentSettingCode,
documentFiles: controlledFiles,
onDocumentFilesChange,
user, user,
onSubmit, onSubmit,
isPending, isPending,
onBack, onBack,
}: { }: {
documentSettingCode: string;
documentFiles?: Record<string, File | File[] | null>;
onDocumentFilesChange?: (
files: Record<string, File | File[] | null>,
) => void;
user: AuthUser; user: AuthUser;
onSubmit: (data: CreateCompanyPayload) => void; onSubmit: (data: CreateCompanyPayload) => void;
isPending: boolean; isPending: boolean;
onBack: () => void; onBack: () => void;
}) { }) {
const [step, setStep] = useState<DjiboutiStep>("company"); const [step, setStep] = useState<DjiboutiStep>("company");
const [internalFiles, setInternalFiles] = useState<
Record<string, File | File[] | null>
>({});
const documentFiles = controlledFiles ?? internalFiles;
const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles;
const { data: uploadSetting, isLoading: loadingDocuments } = useQuery(
api.fileUploadSettings.getByCode.queryOptions({
input: { code: documentSettingCode },
refetchOnMount: false,
}),
);
const { const {
register, register,
handleSubmit, handleSubmit,
trigger, trigger,
watch,
formState: { errors }, formState: { errors },
} = useForm<FormData>({ } = useForm<FormData>({
resolver: zodResolver(djiboutiSchema), resolver: zodResolver(djiboutiSchema),
@@ -97,32 +136,42 @@ export default function DjiboutiAgentForm({
}, },
}); });
const formValues = watch();
const hasDocuments = Boolean(uploadSetting?.fields?.length);
const totalSteps = 4;
const nextStep = async () => { const nextStep = async () => {
if (step === "representative") { if (step === "representative") {
setStep("documents");
return;
}
if (step === "documents") {
setStep("confirm");
return;
}
if (step === "confirm") {
handleSubmit((data) => onSubmit(buildPayload(data, user)))(); handleSubmit((data) => onSubmit(buildPayload(data, user)))();
return; return;
} }
const fields: (keyof FormData)[] = const fields = stepFields[step];
step === "company"
? [
"companyName",
"companyEmail",
"companyPhone",
"companyPhoneCountryCode",
"companyLocation",
"companyAddress",
]
: ["repName", "repEmail", "repPhone", "repPhoneCountryCode"];
const isValid = await trigger(fields); const isValid = await trigger(fields);
if (!isValid) return; if (!isValid) return;
setStep("representative"); setStep("representative");
}; };
const skipDocuments = () => {
setStep("confirm");
};
const prevStep = () => { const prevStep = () => {
if (step === "company") { if (step === "company") {
onBack(); onBack();
} else { } else if (step === "representative") {
setStep("company"); setStep("company");
} else if (step === "documents") {
setStep("representative");
} else {
setStep("documents");
} }
}; };
@@ -143,21 +192,34 @@ export default function DjiboutiAgentForm({
<StepIcon <StepIcon
icon={<Building2 className="size-5" />} icon={<Building2 className="size-5" />}
active={step === "company"} active={step === "company"}
completed={step === "representative"} completed={step !== "company"}
/> />
<StepIcon <StepIcon
icon={<UserRound className="size-5" />} icon={<UserRound className="size-5" />}
active={step === "representative"} active={step === "representative"}
completed={step === "documents" || step === "confirm"}
/>
<StepIcon
icon={<UploadCloud className="size-5" />}
active={step === "documents"}
completed={step === "confirm"}
/>
<StepIcon
icon={<CheckCircle2 className="size-5" />}
active={step === "confirm"}
completed={false} completed={false}
/> />
</div> </div>
<p className="text-center text-sm text-muted-foreground mt-3"> <p className="text-center text-sm text-muted-foreground mt-3">
{stepLabels[step]} {step === "company" && `Step 1 of ${totalSteps} — Company Information`}
{step === "representative" && `Step 2 of ${totalSteps} — Representative Details`}
{step === "documents" && `Step 3 of ${totalSteps} — Upload Documents (Optional)`}
{step === "confirm" && `Step 4 of ${totalSteps} — Review & Confirm`}
</p> </p>
</div> </div>
<form <form
onSubmit={handleSubmit((data) => onSubmit(buildPayload(data, user)))} onSubmit={(e) => e.preventDefault()}
className="flex flex-col gap-4" className="flex flex-col gap-4"
> >
<FieldGroup className="gap-4"> <FieldGroup className="gap-4">
@@ -262,35 +324,120 @@ export default function DjiboutiAgentForm({
</div> </div>
</> </>
)} )}
{step === "documents" && (
<>
{loadingDocuments ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : !uploadSetting ? (
<p className="text-sm text-muted-foreground text-center py-4">
No document requirements found for your account type.
</p>
) : (
<div className="flex flex-col gap-6">
<SmartFileInput
file={uploadSetting}
value={documentFiles}
onChange={setDocumentFiles}
/>
</div>
)}
</>
)}
{step === "confirm" && (
<div className="space-y-4 rounded-xl border border-border bg-card p-4">
<div>
<h3 className="text-base font-semibold text-foreground">
Review your registration
</h3>
<p className="text-sm text-muted-foreground mt-1">
Confirm the company details below before saving.
</p>
</div>
<div className="grid gap-3 md:grid-cols-2">
<ReviewRow label="Company name" value={formValues.companyName} />
<ReviewRow label="Company email" value={formValues.companyEmail} />
<ReviewRow label="Company phone" value={formValues.companyPhone} />
<ReviewRow label="Location" value={formValues.companyLocation} />
<ReviewRow label="Address" value={formValues.companyAddress} />
<ReviewRow label="Rep. name" value={formValues.repName} />
<ReviewRow label="Rep. email" value={formValues.repEmail} />
<ReviewRow
label="Rep. phone"
value={`${formValues.repPhoneCountryCode}${formValues.repPhone}`}
/>
</div>
</div>
)}
</FieldGroup> </FieldGroup>
<div className="flex items-center justify-between pt-2"> <div className="flex items-center justify-between pt-2">
<Button type="button" variant="outline" onClick={prevStep}> <Button type="button" variant="outline" onClick={prevStep}>
<ArrowLeft /> <ArrowLeft />
{step === "company" ? "Change Type" : "Back"} {step === "company"
? "Change Type"
: step === "confirm"
? "Back to Documents"
: "Back"}
</Button> </Button>
<Button type="button" onClick={nextStep} disabled={isPending}> <div className="flex items-center gap-3">
{isPending ? ( {step === "documents" && (
<> <Button
<Loader2 className="animate-spin" /> type="button"
Submitting... variant="outline"
</> onClick={skipDocuments}
) : step === "representative" ? ( disabled={isPending}
"Complete Registration" >
) : ( Skip for now
<> </Button>
Next Step
<ArrowRight />
</>
)} )}
</Button>
<Button
type="button"
onClick={step === "confirm" ? handleSubmit((data) => onSubmit(buildPayload(data, user))) : nextStep}
disabled={isPending || (step === "documents" && !hasDocuments && loadingDocuments)}
>
{isPending ? (
<>
<Loader2 className="animate-spin" />
Submitting...
</>
) : step === "documents" ? (
"Continue"
) : step === "confirm" ? (
"Submit Registration"
) : (
<>
Next Step
<ArrowRight />
</>
)}
</Button>
</div>
</div> </div>
</form> </form>
</> </>
); );
} }
function ReviewRow({ label, value }: { label: string; value?: string | null }) {
return (
<div className="rounded-lg border border-border bg-muted/20 p-3">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{label}
</div>
<div className="mt-1 text-sm font-medium text-foreground">
{value?.trim() ? value : "Not provided"}
</div>
</div>
);
}
function StepIcon({ function StepIcon({
icon, icon,
active, active,

View File

@@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useQuery } from "@tanstack/react-query";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import { import {
@@ -11,11 +11,11 @@ import {
FileText, FileText,
CheckCircle2, CheckCircle2,
Loader2, Loader2,
ChevronLeft,
UploadCloud,
} from "lucide-react"; } from "lucide-react";
import useAuth from "@/hooks/useAuth"; import type { AuthUser } from "@/types/auth";
import { api } from "@/services/api"; import type { CreateCompanyPayload } from "@/services/companies.service";
import type { CreateCustomerDto } from "@/types/customers";
import AuthLayout from "@/components/auth/AuthLayout";
import PhoneInput from "@/components/auth/PhoneInput"; import PhoneInput from "@/components/auth/PhoneInput";
import { import {
Button, Button,
@@ -24,14 +24,13 @@ import {
FieldLabel, FieldLabel,
FieldError, FieldError,
FieldGroup, FieldGroup,
SmartFileInput,
} from "@edr/ui-common"; } from "@edr/ui-common";
import TransporterOnboarding from "./TransportrOnBoarding"; import { api } from "@/services/api";
import DjiboutiForwardingAgentForm from "./DjiboutiFreightForwardingAgent";
import ImportExportOnBoarding from "./ImportExportOnBoarding";
type OnboardingStep = "company" | "personnel" | "poa"; type ForwarderStep = "company" | "personnel" | "poa" | "documents" | "confirm";
const onboardingSchema = z.object({ const forwarderSchema = z.object({
companyName: z.string().min(1, "Company name is required"), companyName: z.string().min(1, "Company name is required"),
companyEmail: z.string().email("Invalid email address"), companyEmail: z.string().email("Invalid email address"),
companyPhone: z.string().min(1, "Company phone is required"), companyPhone: z.string().min(1, "Company phone is required"),
@@ -59,9 +58,9 @@ const onboardingSchema = z.object({
poaLocation: z.string().optional(), poaLocation: z.string().optional(),
}); });
type FormData = z.infer<typeof onboardingSchema>; type FormData = z.infer<typeof forwarderSchema>;
const stepFields: Record<OnboardingStep, (keyof FormData)[]> = { const stepFields: Record<ForwarderStep, (keyof FormData)[]> = {
company: [ company: [
"companyName", "companyName",
"companyEmail", "companyEmail",
@@ -83,20 +82,79 @@ const stepFields: Record<OnboardingStep, (keyof FormData)[]> = {
"generalManagerPhoneCountryCode", "generalManagerPhoneCountryCode",
], ],
poa: [], poa: [],
documents: [],
confirm: [],
}; };
export default function CustomerOnboardingPage() { function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
const queryClient = useQueryClient(); return {
const { user } = useAuth(); companyName: data.companyName,
const [step, setStep] = useState<OnboardingStep>("company"); companyEmail: data.companyEmail,
companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`,
companyLocation: data.companyLocation,
companyAddress: data.companyAddress,
tin: data.tinNumber,
vatNumber: data.vatNumber,
fanNumber: data.fanNumber,
attributes: {
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,
poaAddress: data.poaAddress || undefined,
poaEmail: data.poaEmail || undefined,
poaLocation: data.poaLocation || undefined,
},
};
}
export default function ForwarderForm({
documentSettingCode,
documentFiles: controlledFiles,
onDocumentFilesChange,
user,
onSubmit,
isPending,
onBack,
}: {
documentSettingCode: string;
documentFiles?: Record<string, File | File[] | null>;
onDocumentFilesChange?: (
files: Record<string, File | File[] | null>,
) => void;
user: AuthUser;
onSubmit: (data: CreateCompanyPayload) => void;
isPending: boolean;
onBack: () => void;
}) {
const [step, setStep] = useState<ForwarderStep>("company");
const [internalFiles, setInternalFiles] = useState<
Record<string, File | File[] | null>
>({});
const documentFiles = controlledFiles ?? internalFiles;
const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles;
const { data: uploadSetting, isLoading: loadingDocuments } = useQuery(
api.fileUploadSettings.getByCode.queryOptions({
input: { code: documentSettingCode },
refetchOnMount: false,
}),
);
const { const {
register, register,
handleSubmit, handleSubmit,
trigger, trigger,
watch,
formState: { errors }, formState: { errors },
} = useForm<FormData>({ } = useForm<FormData>({
resolver: zodResolver(onboardingSchema), resolver: zodResolver(forwarderSchema),
defaultValues: { defaultValues: {
companyName: "", companyName: "",
companyEmail: "", companyEmail: "",
@@ -123,20 +181,21 @@ export default function CustomerOnboardingPage() {
}, },
}); });
const createCustomerMutation = useMutation({ const formValues = watch();
mutationFn: (payload: CreateCustomerDto) => const hasDocuments = Boolean(uploadSetting?.fields?.length);
api.customers.create.call(payload), const totalSteps = 5;
onSuccess: () => {
if (user)
queryClient.invalidateQueries({
queryKey: api.customers.getByUserId.queryKey({ id: user.id }),
});
},
});
const nextStep = async () => { const nextStep = async () => {
if (step === "poa") { if (step === "poa") {
handleSubmit(onSubmit)(); setStep("documents");
return;
}
if (step === "documents") {
setStep("confirm");
return;
}
if (step === "confirm") {
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
return; return;
} }
const fields = stepFields[step]; const fields = stepFields[step];
@@ -145,69 +204,36 @@ export default function CustomerOnboardingPage() {
setStep(step === "company" ? "personnel" : "poa"); setStep(step === "company" ? "personnel" : "poa");
}; };
const prevStep = () => { const skipDocuments = () => {
if (step === "personnel") setStep("company"); setStep("confirm");
else if (step === "poa") setStep("personnel");
}; };
const onSubmit = async (data: FormData) => { const prevStep = () => {
const nameParts = (user?.name?.en ?? "").split(" "); if (step === "company") {
const payload: CreateCustomerDto = { onBack();
userId: user!.id, } else if (step === "personnel") {
firstName: nameParts[0] || "", setStep("company");
lastName: nameParts.slice(-1)[0] || "", } else if (step === "poa") {
email: user!.email, setStep("personnel");
phone: user!.phoneNumber, } else if (step === "documents") {
companyName: data.companyName, setStep("poa");
companyEmail: data.companyEmail, } else {
companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`, setStep("documents");
companyLocation: data.companyLocation, }
companyAddress: data.companyAddress,
contactPersonName: data.contactPersonName,
contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`,
tinNumber: data.tinNumber,
vatNumber: data.vatNumber,
fanNumber: data.fanNumber,
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,
poaAddress: data.poaAddress || undefined,
poaEmail: data.poaEmail || undefined,
poaLocation: data.poaLocation || undefined,
};
createCustomerMutation.mutate(payload);
}; };
return ( return (
<AuthLayout <>
left={{ <div className="mb-8">
badge: "Complete Your Profile", <button
title: "Set up your company profile", type="button"
description: onClick={prevStep}
"Provide your business details to start using EDR Freight for managing shipments, tracking consignments, and streamlining logistics operations across Ethiopia and Djibouti.", className="mb-4 flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
features: [ >
"Company registration details", <ChevronLeft className="size-4" />
"Contact and management personnel", Change account type
"Power of Attorney (optional)", </button>
],
stats: {
label: "Active Customers",
value: "500+",
footer: "And growing",
progress: "w-[95%]",
},
}}
>
<TransporterOnboarding />
{/* <DjiboutiForwardingAgentForm /> */}
{/* <ImportExportOnBoarding /> */}
{/* <div className="mb-8 lg:col-span-2">
<div className="flex items-center justify-between max-w-lg mx-auto relative px-2"> <div className="flex items-center justify-between max-w-lg mx-auto relative px-2">
<div className="absolute top-1/2 left-0 w-full h-0.5 bg-border -translate-y-1/2 z-0" /> <div className="absolute top-1/2 left-0 w-full h-0.5 bg-border -translate-y-1/2 z-0" />
<StepIcon <StepIcon
@@ -218,22 +244,41 @@ export default function CustomerOnboardingPage() {
<StepIcon <StepIcon
icon={<User className="size-5" />} icon={<User className="size-5" />}
active={step === "personnel"} active={step === "personnel"}
completed={step === "poa"} completed={step === "poa" || step === "documents" || step === "confirm"}
/> />
<StepIcon <StepIcon
icon={<FileText className="size-5" />} icon={<FileText className="size-5" />}
active={step === "poa"} active={step === "poa"}
completed={step === "documents" || step === "confirm"}
/>
<StepIcon
icon={<UploadCloud className="size-5" />}
active={step === "documents"}
completed={step === "confirm"}
/>
<StepIcon
icon={<CheckCircle2 className="size-5" />}
active={step === "confirm"}
completed={false} completed={false}
/> />
</div> </div>
<p className="text-center text-sm text-muted-foreground mt-3"> <p className="text-center text-sm text-muted-foreground mt-3">
{step === "company" && "Step 1 of 3 — Company Information"} {step === "company" &&
{step === "personnel" && "Step 2 of 3 — Personnel Details"} `Step 1 of ${totalSteps} — Company Information`}
{step === "poa" && "Step 3 of 3 — Power of Attorney (Optional)"} {step === "personnel" &&
`Step 2 of ${totalSteps} — Personnel Details`}
{step === "poa" &&
`Step 3 of ${totalSteps} — Power of Attorney (Optional)`}
{step === "documents" &&
`Step 4 of ${totalSteps} — Upload Documents (Optional)`}
{step === "confirm" && `Step 5 of ${totalSteps} — Review & Confirm`}
</p> </p>
</div> */} </div>
{/* <form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4"> <form
onSubmit={(e) => e.preventDefault()}
className="flex flex-col gap-4"
>
<FieldGroup className="gap-4"> <FieldGroup className="gap-4">
{step === "company" && ( {step === "company" && (
<> <>
@@ -332,11 +377,6 @@ export default function CustomerOnboardingPage() {
{step === "personnel" && ( {step === "personnel" && (
<> <>
<p className="text-sm text-muted-foreground">
Personal details are pulled from your account. Contact and
management info is collected below.
</p>
<div> <div>
<h3 className="text-sm font-semibold text-foreground mb-3"> <h3 className="text-sm font-semibold text-foreground mb-3">
Contact Person Contact Person
@@ -418,88 +458,212 @@ export default function CustomerOnboardingPage() {
{step === "poa" && ( {step === "poa" && (
<> <>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Power of Attorney details are optional. Skip if not applicable. Power of Attorney details are optional. Fill them in if you have
them, or skip to continue.
</p> </p>
<Field> <Field data-invalid={Boolean(errors.poaName)}>
<FieldLabel>PoA Name</FieldLabel> <FieldLabel>PoA Name</FieldLabel>
<Input <Input
placeholder="Authorized Representative Name" placeholder="Authorized Representative Name"
aria-invalid={Boolean(errors.poaName)}
{...register("poaName")} {...register("poaName")}
/> />
<FieldError errors={[errors.poaName]} />
</Field> </Field>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<Field> <Field data-invalid={Boolean(errors.poaEmail)}>
<FieldLabel>PoA Email</FieldLabel> <FieldLabel>PoA Email</FieldLabel>
<Input <Input
type="email" type="email"
placeholder="poa@company.com" placeholder="poa@company.com"
aria-invalid={Boolean(errors.poaEmail)}
{...register("poaEmail")} {...register("poaEmail")}
/> />
<FieldError errors={[errors.poaEmail]} />
</Field> </Field>
<PhoneInput <PhoneInput
countryCode={{ ...register("poaPhoneCountryCode") }} countryCode={{ ...register("poaPhoneCountryCode") }}
phone={{ ...register("poaPhone"), placeholder: "912345678" }} phone={{ ...register("poaPhone"), placeholder: "912345678" }}
label="PoA Phone" label="PoA Phone"
countryCodeError={errors.poaPhoneCountryCode}
phoneError={errors.poaPhone}
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<Field> <Field data-invalid={Boolean(errors.poaLocation)}>
<FieldLabel>PoA Location</FieldLabel> <FieldLabel>PoA Location</FieldLabel>
<Input <Input
placeholder="City, Country" placeholder="City, Country"
aria-invalid={Boolean(errors.poaLocation)}
{...register("poaLocation")} {...register("poaLocation")}
/> />
<FieldError errors={[errors.poaLocation]} />
</Field> </Field>
<Field> <Field data-invalid={Boolean(errors.poaAddress)}>
<FieldLabel>PoA Address</FieldLabel> <FieldLabel>PoA Address</FieldLabel>
<Input <Input
placeholder="Full Address" placeholder="Full Address"
aria-invalid={Boolean(errors.poaAddress)}
{...register("poaAddress")} {...register("poaAddress")}
/> />
<FieldError errors={[errors.poaAddress]} />
</Field> </Field>
</div> </div>
</> </>
)} )}
{step === "documents" && (
<>
{loadingDocuments ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : !uploadSetting ? (
<p className="text-sm text-muted-foreground text-center py-4">
No document requirements found for your account type.
</p>
) : (
<div className="flex flex-col gap-6">
<SmartFileInput
file={uploadSetting}
value={documentFiles}
onChange={setDocumentFiles}
/>
</div>
)}
</>
)}
{step === "confirm" && (
<div className="space-y-4 rounded-xl border border-border bg-card p-4">
<div>
<h3 className="text-base font-semibold text-foreground">
Review your registration
</h3>
<p className="text-sm text-muted-foreground mt-1">
Confirm the company details below before saving.
</p>
</div>
<div className="grid gap-3 md:grid-cols-2">
<ReviewRow label="Company name" value={formValues.companyName} />
<ReviewRow label="Company email" value={formValues.companyEmail} />
<ReviewRow label="Company phone" value={formValues.companyPhone} />
<ReviewRow label="Location" value={formValues.companyLocation} />
<ReviewRow label="Address" value={formValues.companyAddress} />
<ReviewRow label="TIN" value={formValues.tinNumber} />
<ReviewRow label="VAT" value={formValues.vatNumber} />
<ReviewRow label="FAN" value={formValues.fanNumber} />
<ReviewRow
label="Contact person"
value={formValues.contactPersonName}
/>
<ReviewRow
label="Contact phone"
value={`${formValues.contactPersonPhoneCountryCode}${formValues.contactPersonPhone}`}
/>
<ReviewRow
label="General manager"
value={formValues.generalManagerName}
/>
<ReviewRow
label="GM email"
value={formValues.generalManagerEmail}
/>
<ReviewRow
label="GM phone"
value={`${formValues.generalManagerPhoneCountryCode}${formValues.generalManagerPhone}`}
/>
<ReviewRow
label="PoA name"
value={formValues.poaName || undefined}
/>
<ReviewRow
label="PoA phone"
value={
formValues.poaPhone && formValues.poaPhoneCountryCode
? `${formValues.poaPhoneCountryCode}${formValues.poaPhone}`
: undefined
}
/>
<ReviewRow
label="PoA email"
value={formValues.poaEmail || undefined}
/>
<ReviewRow
label="PoA location"
value={formValues.poaLocation || undefined}
/>
</div>
</div>
)}
</FieldGroup> </FieldGroup>
<div className="flex items-center justify-between pt-2"> <div className="flex items-center justify-between pt-2">
<Button <Button type="button" variant="outline" onClick={prevStep}>
type="button"
variant="outline"
onClick={prevStep}
disabled={step === "company"}
>
<ArrowLeft /> <ArrowLeft />
Back {step === "company"
? "Change Type"
: step === "confirm"
? "Back to Documents"
: "Back"}
</Button> </Button>
<Button <div className="flex items-center gap-3">
type="button" {step === "documents" && (
onClick={nextStep} <Button
disabled={createCustomerMutation.isPending} type="button"
> variant="outline"
{createCustomerMutation.isPending ? ( onClick={skipDocuments}
<> disabled={isPending}
<Loader2 className="animate-spin" /> >
Submitting... Skip for now
</> </Button>
) : step === "poa" ? (
"Complete Registration"
) : (
<>
Next Step
<ArrowRight />
</>
)} )}
</Button>
<Button
type="button"
onClick={step === "confirm" ? handleSubmit((data) => onSubmit(buildPayload(data, user))) : nextStep}
disabled={isPending || (step === "documents" && !hasDocuments && loadingDocuments)}
>
{isPending ? (
<>
<Loader2 className="animate-spin" />
Submitting...
</>
) : step === "documents" ? (
"Continue"
) : step === "confirm" ? (
"Submit Registration"
) : (
<>
Next Step
<ArrowRight />
</>
)}
</Button>
</div>
</div> </div>
</form> */} </form>
</AuthLayout> </>
);
}
function ReviewRow({ label, value }: { label: string; value?: string | null }) {
return (
<div className="rounded-lg border border-border bg-muted/20 p-3">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{label}
</div>
<div className="mt-1 text-sm font-medium text-foreground">
{value?.trim() ? value : "Not provided"}
</div>
</div>
); );
} }

View File

@@ -1,5 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { import {
ArrowDownToLine, ArrowDownToLine,
ArrowUpFromLine, ArrowUpFromLine,
@@ -10,9 +10,11 @@ import {
} from "lucide-react"; } from "lucide-react";
import useAuth from "@/hooks/useAuth"; import useAuth from "@/hooks/useAuth";
import { api } from "@/services/api"; import { api } from "@/services/api";
import { companiesService } from "@/services/companies.service";
import type { CreateCompanyPayload } from "@/services/companies.service"; import type { CreateCompanyPayload } from "@/services/companies.service";
import AuthLayout from "@/components/auth/AuthLayout"; import AuthLayout from "@/components/auth/AuthLayout";
import CompanyProfileForm from "./CompanyProfileForm"; import CompanyProfileForm from "./CompanyProfileForm";
import ForwarderForm from "./ForwarderForm";
import DjiboutiAgentForm from "./DjiboutiAgentForm"; import DjiboutiAgentForm from "./DjiboutiAgentForm";
import TransporterForm from "./TransporterForm"; import TransporterForm from "./TransporterForm";
import type { OnboardingUserType } from "./types"; import type { OnboardingUserType } from "./types";
@@ -113,17 +115,21 @@ const PREFLIGHT_LEFT = {
}, },
}; };
const DOCUMENT_SETTING_CODE_MAP: Record<OnboardingUserType, string> = {
importer: "company_onboarding_documents_customer",
exporter: "company_onboarding_documents_customer",
"freight-forwarder-et": "company_onboarding_documents_forwarder",
"freight-forwarder-dj": "company_onboarding_documents_forwarder_dj",
transporter: "company_onboarding_documents_transporter",
};
export default function OnboardingPage() { export default function OnboardingPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { user } = useAuth(); const { user } = useAuth();
const [userType, setUserType] = useState<OnboardingUserType | null>(null); const [userType, setUserType] = useState<OnboardingUserType | null>(null);
const [documentFiles, setDocumentFiles] = useState<
useQuery( Record<string, File | File[] | null>
api.fileUploadSettings.getByEntity.queryOptions({ >({});
input: { entity: "customer" },
refetchOnMount: false,
}),
);
const COMPANY_TYPE_MAP: Record<OnboardingUserType, string> = { const COMPANY_TYPE_MAP: Record<OnboardingUserType, string> = {
importer: "customer", importer: "customer",
@@ -136,8 +142,14 @@ export default function OnboardingPage() {
const createCompanyMutation = useMutation({ const createCompanyMutation = useMutation({
mutationFn: (payload: CreateCompanyPayload) => mutationFn: (payload: CreateCompanyPayload) =>
api.companies.create.call(payload), api.companies.create.call(payload),
onSuccess: () => { onSuccess: async (data) => {
queryClient.invalidateQueries({ const hasFiles = Object.values(documentFiles).some(
(f) => f !== null && (Array.isArray(f) ? f.length > 0 : true),
);
if (hasFiles) {
await companiesService.uploadDocuments(data.company.id, documentFiles);
}
await queryClient.invalidateQueries({
queryKey: api.companies.getInfo.queryKey(), queryKey: api.companies.getInfo.queryKey(),
}); });
}, },
@@ -237,6 +249,9 @@ export default function OnboardingPage() {
<AuthLayout left={leftProps}> <AuthLayout left={leftProps}>
{userType === "transporter" ? ( {userType === "transporter" ? (
<TransporterForm <TransporterForm
documentSettingCode={DOCUMENT_SETTING_CODE_MAP[userType]}
documentFiles={documentFiles}
onDocumentFilesChange={setDocumentFiles}
user={user} user={user}
onSubmit={handleSubmit} onSubmit={handleSubmit}
isPending={createCompanyMutation.isPending} isPending={createCompanyMutation.isPending}
@@ -244,6 +259,19 @@ export default function OnboardingPage() {
/> />
) : userType === "freight-forwarder-dj" ? ( ) : userType === "freight-forwarder-dj" ? (
<DjiboutiAgentForm <DjiboutiAgentForm
documentSettingCode={DOCUMENT_SETTING_CODE_MAP[userType]}
documentFiles={documentFiles}
onDocumentFilesChange={setDocumentFiles}
user={user}
onSubmit={handleSubmit}
isPending={createCompanyMutation.isPending}
onBack={handleBack}
/>
) : userType === "freight-forwarder-et" ? (
<ForwarderForm
documentSettingCode={DOCUMENT_SETTING_CODE_MAP[userType]}
documentFiles={documentFiles}
onDocumentFilesChange={setDocumentFiles}
user={user} user={user}
onSubmit={handleSubmit} onSubmit={handleSubmit}
isPending={createCompanyMutation.isPending} isPending={createCompanyMutation.isPending}
@@ -251,7 +279,9 @@ export default function OnboardingPage() {
/> />
) : ( ) : (
<CompanyProfileForm <CompanyProfileForm
userType={userType} documentSettingCode={DOCUMENT_SETTING_CODE_MAP[userType]}
documentFiles={documentFiles}
onDocumentFilesChange={setDocumentFiles}
user={user} user={user}
onSubmit={handleSubmit} onSubmit={handleSubmit}
isPending={createCompanyMutation.isPending} isPending={createCompanyMutation.isPending}

View File

@@ -1,11 +1,16 @@
import { useState } from "react";
import { useForm, Controller } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
import { useQuery } from "@tanstack/react-query";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import { import {
Loader2, ArrowRight,
ArrowLeft,
ChevronLeft, ChevronLeft,
Truck, Truck,
Info, CheckCircle2,
Loader2,
UploadCloud,
} from "lucide-react"; } from "lucide-react";
import type { AuthUser } from "@/types/auth"; import type { AuthUser } from "@/types/auth";
import type { CreateCompanyPayload } from "@/services/companies.service"; import type { CreateCompanyPayload } from "@/services/companies.service";
@@ -20,8 +25,10 @@ import {
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
SmartFileInput,
} from "@edr/ui-common"; } from "@edr/ui-common";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { api } from "@/services/api";
const TRUCK_TYPES = [ const TRUCK_TYPES = [
"Casoni", "Casoni",
@@ -31,6 +38,8 @@ const TRUCK_TYPES = [
"Others", "Others",
] as const; ] as const;
type TransporterStep = "vehicle" | "documents" | "confirm";
const transporterSchema = z const transporterSchema = z
.object({ .object({
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"), tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
@@ -45,7 +54,10 @@ const transporterSchema = z
.regex(/^\d{4}$/, "Enter a valid year (e.g. 2023)"), .regex(/^\d{4}$/, "Enter a valid year (e.g. 2023)"),
}) })
.superRefine((data, ctx) => { .superRefine((data, ctx) => {
if (data.truckType === "Casoni" && (!data.plateNumber2 || data.plateNumber2.trim().length === 0)) { if (
data.truckType === "Casoni" &&
(!data.plateNumber2 || data.plateNumber2.trim().length === 0)
) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
path: ["plateNumber2"], path: ["plateNumber2"],
@@ -77,19 +89,42 @@ function buildPayload(data: FormData, user: AuthUser): CreateCompanyPayload {
} }
export default function TransporterForm({ export default function TransporterForm({
documentSettingCode,
documentFiles: controlledFiles,
onDocumentFilesChange,
user, user,
onSubmit, onSubmit,
isPending, isPending,
onBack, onBack,
}: { }: {
documentSettingCode: string;
documentFiles?: Record<string, File | File[] | null>;
onDocumentFilesChange?: (
files: Record<string, File | File[] | null>,
) => void;
user: AuthUser; user: AuthUser;
onSubmit: (data: CreateCompanyPayload) => void; onSubmit: (data: CreateCompanyPayload) => void;
isPending: boolean; isPending: boolean;
onBack: () => void; onBack: () => void;
}) { }) {
const [step, setStep] = useState<TransporterStep>("vehicle");
const [internalFiles, setInternalFiles] = useState<
Record<string, File | File[] | null>
>({});
const documentFiles = controlledFiles ?? internalFiles;
const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles;
const { data: uploadSetting, isLoading: loadingDocuments } = useQuery(
api.fileUploadSettings.getByCode.queryOptions({
input: { code: documentSettingCode },
refetchOnMount: false,
}),
);
const { const {
register, register,
handleSubmit, handleSubmit,
trigger,
watch, watch,
control, control,
formState: { errors }, formState: { errors },
@@ -108,187 +143,341 @@ export default function TransporterForm({
const truckType = watch("truckType"); const truckType = watch("truckType");
const isCasoni = truckType === "Casoni"; const isCasoni = truckType === "Casoni";
const formValues = watch();
const hasDocuments = Boolean(uploadSetting?.fields?.length);
const totalSteps = 3;
const nextStep = async () => {
if (step === "documents") {
setStep("confirm");
return;
}
if (step === "confirm") {
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
return;
}
const fields: (keyof FormData)[] = [
"tinNumber",
"fanNumber",
"truckType",
"plateNumber",
"vehicleModel",
"yearOfManufacturing",
];
const isValid = await trigger(fields);
if (!isValid) return;
setStep("documents");
};
const skipDocuments = () => {
setStep("confirm");
};
const prevStep = () => {
if (step === "vehicle") {
onBack();
} else if (step === "documents") {
setStep("vehicle");
} else {
setStep("documents");
}
};
return ( return (
<> <>
<div className="mb-8"> <div className="mb-8">
<button <button
type="button" type="button"
onClick={onBack} onClick={prevStep}
className="mb-4 flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors" className="mb-4 flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
> >
<ChevronLeft className="size-4" /> <ChevronLeft className="size-4" />
Change account type Change account type
</button> </button>
<div className="flex items-center justify-center relative px-2"> <div className="flex items-center justify-between max-w-lg mx-auto relative px-2">
<div className="flex h-10 w-10 items-center justify-center rounded-full border-2 border-primary bg-background text-primary shadow-md"> <div className="absolute top-1/2 left-0 w-full h-0.5 bg-border -translate-y-1/2 z-0" />
<Truck className="size-5" /> <StepIcon
</div> icon={<Truck className="size-5" />}
active={step === "vehicle"}
completed={step !== "vehicle"}
/>
<StepIcon
icon={<UploadCloud className="size-5" />}
active={step === "documents"}
completed={step === "confirm"}
/>
<StepIcon
icon={<CheckCircle2 className="size-5" />}
active={step === "confirm"}
completed={false}
/>
</div> </div>
<p className="text-center text-sm text-muted-foreground mt-3"> <p className="text-center text-sm text-muted-foreground mt-3">
Transporter Registration {step === "vehicle" && `Step 1 of ${totalSteps} — Vehicle Information`}
{step === "documents" && `Step 2 of ${totalSteps} — Upload Documents (Optional)`}
{step === "confirm" && `Step 3 of ${totalSteps} — Review & Confirm`}
</p> </p>
</div> </div>
<form <form
onSubmit={handleSubmit((data) => onSubmit(buildPayload(data, user)))} onSubmit={(e) => e.preventDefault()}
className="flex flex-col gap-4" className="flex flex-col gap-4"
> >
{/* Personal Info (read-only) */} {step === "vehicle" && (
<div className="rounded-lg bg-muted/30 p-4 text-sm text-muted-foreground"> <>
<div className="flex items-center gap-2 mb-2"> <div className="grid grid-cols-2 gap-4">
<Info className="size-4" /> <Field data-invalid={Boolean(errors.tinNumber)}>
<span className="font-medium text-foreground">Account Holder</span> <FieldLabel>TIN Number (10 digits)</FieldLabel>
<Input
placeholder="1234567890"
maxLength={10}
aria-invalid={Boolean(errors.tinNumber)}
{...register("tinNumber")}
/>
<FieldError errors={[errors.tinNumber]} />
</Field>
<Field data-invalid={Boolean(errors.fanNumber)}>
<FieldLabel>FAN Number (16 digits)</FieldLabel>
<Input
placeholder="1234567890123456"
maxLength={16}
aria-invalid={Boolean(errors.fanNumber)}
{...register("fanNumber")}
/>
<FieldError errors={[errors.fanNumber]} />
</Field>
</div>
<hr className="border-border" />
<h3 className="text-sm font-semibold text-foreground">
Vehicle / Truck Information
</h3>
<Controller
name="truckType"
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={Boolean(fieldState.error)}>
<FieldLabel>Truck Type</FieldLabel>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger
className={cn(
"w-full",
fieldState.error ? "border-destructive!" : "",
)}
aria-invalid={Boolean(fieldState.error)}
>
<SelectValue placeholder="Select truck type..." />
</SelectTrigger>
<SelectContent>
{TRUCK_TYPES.map((type) => (
<SelectItem key={type} value={type}>
{type}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.plateNumber)}>
<FieldLabel>Plate Number{isCasoni ? " (Front)" : ""}</FieldLabel>
<Input
placeholder={isCasoni ? "AA-12345" : "AA-12345"}
aria-invalid={Boolean(errors.plateNumber)}
{...register("plateNumber")}
/>
<FieldError errors={[errors.plateNumber]} />
</Field>
{isCasoni && (
<Field data-invalid={Boolean(errors.plateNumber2)}>
<FieldLabel>Plate Number (Trailer)</FieldLabel>
<Input
placeholder="AA-67890"
aria-invalid={Boolean(errors.plateNumber2)}
{...register("plateNumber2")}
/>
<FieldError errors={[errors.plateNumber2]} />
</Field>
)}
{!isCasoni && (
<Field data-invalid={Boolean(errors.vehicleModel)}>
<FieldLabel>Vehicle Model</FieldLabel>
<Input
placeholder="Isuzu FVR 2024"
aria-invalid={Boolean(errors.vehicleModel)}
{...register("vehicleModel")}
/>
<FieldError errors={[errors.vehicleModel]} />
</Field>
)}
</div>
<div className="grid grid-cols-2 gap-4">
{isCasoni && (
<Field data-invalid={Boolean(errors.vehicleModel)}>
<FieldLabel>Vehicle Model</FieldLabel>
<Input
placeholder="Isuzu FVR 2024"
aria-invalid={Boolean(errors.vehicleModel)}
{...register("vehicleModel")}
/>
<FieldError errors={[errors.vehicleModel]} />
</Field>
)}
<Field data-invalid={Boolean(errors.yearOfManufacturing)}>
<FieldLabel>Year of Manufacturing</FieldLabel>
<Input
placeholder="2023"
maxLength={4}
aria-invalid={Boolean(errors.yearOfManufacturing)}
{...register("yearOfManufacturing")}
/>
<FieldError errors={[errors.yearOfManufacturing]} />
</Field>
</div>
</>
)}
{step === "documents" && (
<>
{loadingDocuments ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : !uploadSetting ? (
<p className="text-sm text-muted-foreground text-center py-4">
No document requirements found for your account type.
</p>
) : (
<div className="flex flex-col gap-6">
<SmartFileInput
file={uploadSetting}
value={documentFiles}
onChange={setDocumentFiles}
/>
</div>
)}
</>
)}
{step === "confirm" && (
<div className="space-y-4 rounded-xl border border-border bg-card p-4">
<div>
<h3 className="text-base font-semibold text-foreground">
Review your registration
</h3>
<p className="text-sm text-muted-foreground mt-1">
Confirm the details below before saving.
</p>
</div>
<div className="grid gap-3 md:grid-cols-2">
<ReviewRow label="TIN Number" value={formValues.tinNumber} />
<ReviewRow label="FAN Number" value={formValues.fanNumber} />
<ReviewRow label="Truck Type" value={formValues.truckType} />
<ReviewRow label="Plate Number" value={formValues.plateNumber} />
{formValues.plateNumber2 && (
<ReviewRow label="Plate (Trailer)" value={formValues.plateNumber2} />
)}
<ReviewRow label="Vehicle Model" value={formValues.vehicleModel} />
<ReviewRow label="Year" value={formValues.yearOfManufacturing} />
</div>
</div> </div>
<p> )}
{user.name?.en} {user.email} {user.phoneNumber}
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.tinNumber)}>
<FieldLabel>TIN Number (10 digits)</FieldLabel>
<Input
placeholder="1234567890"
maxLength={10}
aria-invalid={Boolean(errors.tinNumber)}
{...register("tinNumber")}
/>
<FieldError errors={[errors.tinNumber]} />
</Field>
<Field data-invalid={Boolean(errors.fanNumber)}>
<FieldLabel>FAN Number (16 digits)</FieldLabel>
<Input
placeholder="1234567890123456"
maxLength={16}
aria-invalid={Boolean(errors.fanNumber)}
{...register("fanNumber")}
/>
<FieldError errors={[errors.fanNumber]} />
</Field>
</div>
<hr className="border-border" />
<h3 className="text-sm font-semibold text-foreground">
Vehicle / Truck Information
</h3>
<Controller
name="truckType"
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={Boolean(fieldState.error)}>
<FieldLabel>Truck Type</FieldLabel>
<Select
value={field.value}
onValueChange={field.onChange}
>
<SelectTrigger
className={cn(
"w-full",
fieldState.error ? "border-destructive!" : "",
)}
aria-invalid={Boolean(fieldState.error)}
>
<SelectValue placeholder="Select truck type..." />
</SelectTrigger>
<SelectContent>
{TRUCK_TYPES.map((type) => (
<SelectItem key={type} value={type}>
{type}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.plateNumber)}>
<FieldLabel>
Plate Number{isCasoni ? " (Front)" : ""}
</FieldLabel>
<Input
placeholder={isCasoni ? "AA-12345" : "AA-12345"}
aria-invalid={Boolean(errors.plateNumber)}
{...register("plateNumber")}
/>
<FieldError errors={[errors.plateNumber]} />
</Field>
{isCasoni && (
<Field data-invalid={Boolean(errors.plateNumber2)}>
<FieldLabel>Plate Number (Trailer)</FieldLabel>
<Input
placeholder="AA-67890"
aria-invalid={Boolean(errors.plateNumber2)}
{...register("plateNumber2")}
/>
<FieldError errors={[errors.plateNumber2]} />
</Field>
)}
{!isCasoni && (
<Field data-invalid={Boolean(errors.vehicleModel)}>
<FieldLabel>Vehicle Model</FieldLabel>
<Input
placeholder="Isuzu FVR 2024"
aria-invalid={Boolean(errors.vehicleModel)}
{...register("vehicleModel")}
/>
<FieldError errors={[errors.vehicleModel]} />
</Field>
)}
</div>
<div className="grid grid-cols-2 gap-4">
{isCasoni && (
<Field data-invalid={Boolean(errors.vehicleModel)}>
<FieldLabel>Vehicle Model</FieldLabel>
<Input
placeholder="Isuzu FVR 2024"
aria-invalid={Boolean(errors.vehicleModel)}
{...register("vehicleModel")}
/>
<FieldError errors={[errors.vehicleModel]} />
</Field>
)}
<Field data-invalid={Boolean(errors.yearOfManufacturing)}>
<FieldLabel>Year of Manufacturing</FieldLabel>
<Input
placeholder="2023"
maxLength={4}
aria-invalid={Boolean(errors.yearOfManufacturing)}
{...register("yearOfManufacturing")}
/>
<FieldError errors={[errors.yearOfManufacturing]} />
</Field>
</div>
<div className="flex items-center justify-between pt-2"> <div className="flex items-center justify-between pt-2">
<Button type="button" variant="outline" onClick={onBack}> <Button type="button" variant="outline" onClick={prevStep}>
<ChevronLeft /> <ArrowLeft />
Change Type {step === "vehicle"
? "Change Type"
: step === "confirm"
? "Back to Documents"
: "Back"}
</Button> </Button>
<Button type="submit" disabled={isPending}> <div className="flex items-center gap-3">
{isPending ? ( {step === "documents" && (
<> <Button
<Loader2 className="animate-spin" /> type="button"
Submitting... variant="outline"
</> onClick={skipDocuments}
) : ( disabled={isPending}
"Complete Registration" >
Skip for now
</Button>
)} )}
</Button>
<Button
type="button"
onClick={step === "confirm" ? handleSubmit((data) => onSubmit(buildPayload(data, user))) : nextStep}
disabled={isPending || (step === "documents" && !hasDocuments && loadingDocuments)}
>
{isPending ? (
<>
<Loader2 className="animate-spin" />
Submitting...
</>
) : step === "documents" ? (
"Continue"
) : step === "confirm" ? (
"Submit Registration"
) : (
<>
Next Step
<ArrowRight />
</>
)}
</Button>
</div>
</div> </div>
</form> </form>
</> </>
); );
} }
function ReviewRow({ label, value }: { label: string; value?: string | null }) {
return (
<div className="rounded-lg border border-border bg-muted/20 p-3">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{label}
</div>
<div className="mt-1 text-sm font-medium text-foreground">
{value?.trim() ? value : "Not provided"}
</div>
</div>
);
}
function StepIcon({
icon,
active,
completed,
}: {
icon: React.ReactNode;
active: boolean;
completed: boolean;
}) {
return (
<div
className={`relative z-10 flex h-10 w-10 items-center justify-center rounded-full border-2 transition-all ${
completed
? "bg-primary border-primary text-primary-foreground"
: active
? "bg-background border-primary text-primary shadow-md"
: "bg-background border-border text-muted-foreground"
}`}
>
{completed ? <CheckCircle2 className="size-5" /> : icon}
</div>
);
}

View File

@@ -14,6 +14,7 @@ export interface Customer {
country: string; country: string;
address: string; address: string;
notes: string; notes: string;
documentsComplete: boolean;
} }
const seedCustomers: Customer[] = [ const seedCustomers: Customer[] = [
@@ -30,6 +31,7 @@ const seedCustomers: Customer[] = [
country: "Ethiopia", country: "Ethiopia",
address: "Bole Road, Sub-City 03, Building 17", address: "Bole Road, Sub-City 03, Building 17",
notes: "Top-tier importer. Prefers weekly invoicing.", notes: "Top-tier importer. Prefers weekly invoicing.",
documentsComplete: false,
}, },
{ {
id: 2, id: 2,
@@ -44,6 +46,7 @@ const seedCustomers: Customer[] = [
country: "Ethiopia", country: "Ethiopia",
address: "Industrial Park, Zone B, Warehouse 4", address: "Industrial Park, Zone B, Warehouse 4",
notes: "Awaiting compliance documents.", notes: "Awaiting compliance documents.",
documentsComplete: false,
}, },
{ {
id: 3, id: 3,
@@ -58,6 +61,7 @@ const seedCustomers: Customer[] = [
country: "Djibouti", country: "Djibouti",
address: "Port Quarter, Avenue 26, Block 9", address: "Port Quarter, Avenue 26, Block 9",
notes: "Account paused since last quarter.", notes: "Account paused since last quarter.",
documentsComplete: true,
}, },
]; ];
@@ -99,6 +103,7 @@ const generated: Customer[] = extras.map((entry, i) => {
country: entry.country, country: entry.country,
address: `Block ${i + 1}, Street ${10 + i}, Quarter ${(i % 5) + 1}`, address: `Block ${i + 1}, Street ${10 + i}, Quarter ${(i % 5) + 1}`,
notes: `Mock customer #${id}.`, notes: `Mock customer #${id}.`,
documentsComplete: i % 3 === 0,
}; };
}); });

View File

@@ -33,6 +33,7 @@ import type {
CompanyInfoResponse, CompanyInfoResponse,
CreateCompanyPayload, CreateCompanyPayload,
} from "./companies.service"; } from "./companies.service";
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
import type { import type {
AuthUser, AuthUser,
GenerateVerificationCodePayload, GenerateVerificationCodePayload,
@@ -89,40 +90,6 @@ export const api = {
logout: endpoint<void, void>("auth", "logout", authService.logout), logout: endpoint<void, void>("auth", "logout", authService.logout),
}, },
customers: {
list: endpoint<void, Customer[]>(
"customers",
"list",
customersService.list,
),
get: endpoint<{ id: string }, Customer>("customers", "get", ({ id }) =>
customersService.getById(id),
),
create: endpoint<CreateCustomerDto, Customer>(
"customers",
"create",
customersService.create,
),
update: endpoint<{ id: string; dto: UpdateCustomerDto }, Customer>(
"customers",
"update",
({ id, dto }) => customersService.update(id, dto),
),
remove: endpoint<{ id: string }, void>("customers", "remove", ({ id }) =>
customersService.remove(id),
),
getByUserId: endpoint<{ id: string }, Customer | null>(
"customers",
"getByUserId",
({ id }) => customersService.getByUserId(id),
),
},
companies: { companies: {
getInfo: endpoint<void, CompanyInfoResponse | null>( getInfo: endpoint<void, CompanyInfoResponse | null>(
"companies", "companies",
@@ -135,6 +102,18 @@ export const api = {
"create", "create",
companiesService.create, companiesService.create,
), ),
getProfile: endpoint<void, ProfileResponse>(
"companies",
"getProfile",
companiesService.getProfile,
),
updateProfile: endpoint<UpdateProfilePayload, ProfileResponse>(
"companies",
"updateProfile",
companiesService.updateProfile,
),
}, },
bookings: { bookings: {

View File

@@ -2,6 +2,7 @@ import { client } from "@/utils/api";
import { unwrap } from "@/utils/endpoint"; import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS"; import { URL_CONSTANTS } from "@/constants/URLS";
import type { ApiResponse } from "@/types/apiResponse"; import type { ApiResponse } from "@/types/apiResponse";
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
import { isAxiosError } from "axios"; import { isAxiosError } from "axios";
export interface ExternalProfileResponse { export interface ExternalProfileResponse {
@@ -80,4 +81,37 @@ export const companiesService = {
); );
return unwrap(response.data); return unwrap(response.data);
}, },
getProfile: async (): Promise<ProfileResponse> => {
const response = await client.get<ApiResponse<ProfileResponse>>(
URL_CONSTANTS.COMPANIES_API.PROFILE,
);
return unwrap(response.data);
},
updateProfile: async (payload: UpdateProfilePayload): Promise<ProfileResponse> => {
const response = await client.patch<ApiResponse<ProfileResponse>>(
URL_CONSTANTS.COMPANIES_API.PROFILE,
payload,
);
return unwrap(response.data);
},
uploadDocuments: async (
companyId: string,
files: Record<string, File | File[] | null>,
): Promise<void> => {
const formData = new FormData();
for (const [fieldName, fileOrFiles] of Object.entries(files)) {
if (!fileOrFiles) continue;
if (Array.isArray(fileOrFiles)) {
for (const f of fileOrFiles) {
formData.append(fieldName, f);
}
} else {
formData.append(fieldName, fileOrFiles);
}
}
await client.post(URL_CONSTANTS.COMPANIES_API.DOCUMENTS(companyId), formData);
},
}; };

View File

@@ -1,57 +0,0 @@
import { client } from "@/utils/api";
import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS";
import type { ApiResponse } from "@/types/apiResponse";
import type {
CreateCustomerDto,
Customer,
UpdateCustomerDto,
} from "@/types/customers";
import { isAxiosError } from "axios";
const BASE = URL_CONSTANTS.CUSTOMERS_API.BASE;
export const customersService = {
list: async (): Promise<Customer[]> => {
const response = await client.get<ApiResponse<Customer[]>>(BASE);
return unwrap(response.data);
},
getById: async (id: string): Promise<Customer> => {
const response = await client.get<ApiResponse<Customer>>(
URL_CONSTANTS.CUSTOMERS_API.BY_ID(id),
);
return unwrap(response.data);
},
getByUserId: async (userId: string): Promise<Customer | null> => {
try {
const response = await client.get<ApiResponse<Customer>>(
URL_CONSTANTS.CUSTOMERS_API.BY_USER_ID(userId),
);
return unwrap(response.data);
} catch (e) {
if (isAxiosError(e) && e.response?.status === 404) {
return null;
}
throw e;
}
},
create: async (payload: CreateCustomerDto): Promise<Customer> => {
const response = await client.post<ApiResponse<Customer>>(BASE, payload);
return unwrap(response.data);
},
update: async (id: string, payload: UpdateCustomerDto): Promise<Customer> => {
const response = await client.patch<ApiResponse<Customer>>(
URL_CONSTANTS.CUSTOMERS_API.BY_ID(id),
payload,
);
return unwrap(response.data);
},
remove: async (id: string): Promise<void> => {
await client.delete(URL_CONSTANTS.CUSTOMERS_API.BY_ID(id));
},
};

View File

@@ -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;
}