diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 8e74c7528..ac2868ec3 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -23,7 +23,11 @@ import { CreateCompanyDto } from "./dto/create-company.dto"; import { UpdateCompanyDto } from "./dto/update-company.dto"; import { CreateExternalProfileDto } from "./dto/create-external-profile.dto"; import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto"; -import { ResponseCompanyDto } from "./dto/response-company.dto"; +import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto"; +import { + ResponseCompanyDto, + ResponseCompanyProfileDto, +} from "./dto/response-company.dto"; import { ResponseExternalProfileDto } from "./dto/response-external-profile.dto"; import { CompanyInfoResponseDto } from "./dto/company-info-response.dto"; import { UpdateProfileDto } from "./dto/update-profile.dto"; @@ -85,6 +89,22 @@ export class CompaniesController { return this.companiesService.updateProfile(user.id, dto); } + @Post("company-profiles") + @ApiOperation({ + summary: + "Add operational profile(s) (importer/exporter/forwarder) to the current user's company", + }) + async addCompanyProfiles( + @CurrentUser() user: CurrentIamUser, + @Body() dto: AddCompanyProfilesDto, + ): Promise { + const profiles = await this.companiesService.addCompanyProfilesForUser( + user.id, + dto.types, + ); + return profiles.map((p) => new ResponseCompanyProfileDto(p)); + } + // Used by portal @Post("create") @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index ee0262413..dc68f1785 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -434,4 +434,47 @@ export class CompaniesService { return profiles; } + + /** + * Add operational profile(s) to the current user's company (portal settings). + * Add-only and idempotent: each requested type must be allowed for the + * company's type, profiles that already exist are skipped (not re-created or + * rejected), and the full updated list is returned. + */ + async addCompanyProfilesForUser( + userId: string, + types: ProfileType[], + ): Promise { + const profile = await this.profilesRepo.findByUserId(userId); + if (!profile) + throw new NotFoundException(`Profile for user ${userId} not found`); + + const companyId = profile.company?.id ?? profile.companyId; + const company = await this.findCompanyById(companyId); + const allowedTypes = this.getProfileTypeForCompanyType(company.type); + + for (const type of types) { + if (!allowedTypes.includes(type)) { + throw new BadRequestException( + `Profile type "${type}" is not allowed for company type "${company.type}"`, + ); + } + + const existing = await this.companyProfilesRepo.findByType( + companyId, + type, + ); + if (existing) continue; + + const reference = await this.companyProfilesRepo.generateReference(type); + await this.companyProfilesRepo.create({ + companyId, + type, + reference, + status: ProfileStatus.Active, + }); + } + + return this.companyProfilesRepo.findByCompanyId(companyId); + } } diff --git a/apps/edr-freight-api/src/modules/companies/dto/add-company-profiles.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/add-company-profiles.dto.ts new file mode 100644 index 000000000..838c42111 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/add-company-profiles.dto.ts @@ -0,0 +1,9 @@ +import { IsArray, IsEnum, ArrayMinSize } from "class-validator"; +import { ProfileType } from "../entities/company-profile.entity"; + +export class AddCompanyProfilesDto { + @IsArray() + @ArrayMinSize(1) + @IsEnum(ProfileType, { each: true }) + types!: ProfileType[]; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts index ee8ede34f..d6744e75f 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts @@ -1,9 +1,11 @@ import { Company } from '../entities/company.entity'; import { ExternalProfile } from '../entities/external-profile.entity'; +import { ResponseCompanyProfileDto } from './response-company.dto'; export class ProfileResponseDto { companyId: string; companyName: string; + companyType: string; companyEmail: string | null; companyPhone: string | null; companyLocation: string; @@ -12,6 +14,8 @@ export class ProfileResponseDto { vatNumber: string | null; fanNumber: string | null; + companyProfiles: ResponseCompanyProfileDto[]; + contactPersonName: string | null; contactPersonPhone: string | null; generalManagerName: string | null; @@ -29,6 +33,10 @@ export class ProfileResponseDto { constructor(profile: ExternalProfile, company: Company) { this.companyId = company.id; this.companyName = company.name; + this.companyType = company.type; + this.companyProfiles = + company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)) ?? + []; this.companyEmail = company.email ?? null; this.companyPhone = company.phone ?? null; this.companyLocation = company.country; diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index a2a72655e..7c6683049 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -83,6 +83,7 @@ export const URL_CONSTANTS = { GET_INFO: "/api/companies/getInfo", CREATE: "/api/companies/create", PROFILE: "/api/companies/profile", + COMPANY_PROFILES: "/api/companies/company-profiles", DASHBOARD: "/api/companies/dashboard", DOCUMENTS: (id: string) => `/api/companies/${id}/documents`, }, diff --git a/apps/edr-freight-web/portal/src/pages/settings/CompanyRolesCard.tsx b/apps/edr-freight-web/portal/src/pages/settings/CompanyRolesCard.tsx new file mode 100644 index 000000000..8c448bfbd --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/settings/CompanyRolesCard.tsx @@ -0,0 +1,220 @@ +import { useMemo, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { + ArrowDownToLine, + ArrowUpFromLine, + Building2, + Check, + CheckCircle2, + Save, + XCircle, +} from "lucide-react"; +import { + Box, + Button, + Card, + Group, + SimpleGrid, + Text, + ThemeIcon, + Title, + UnstyledButton, +} from "@mantine/core"; +import { api } from "@/services/api"; +import type { ProfileResponse } from "@/types/profile"; + +interface RoleOption { + type: string; + label: string; + description: string; + icon: React.ReactNode; +} + +const CUSTOMER_ROLES: RoleOption[] = [ + { + type: "importer", + label: "Importer", + description: "Import goods into Ethiopia via the railway corridor.", + icon: , + }, + { + type: "exporter", + label: "Exporter", + description: "Export goods from Ethiopia via rail.", + icon: , + }, +]; + +const FORWARDER_ROLES: RoleOption[] = [ + { + type: "freight_forwarder", + label: "Freight Forwarder", + description: "Handle cargo on behalf of importers and exporters.", + icon: , + }, +]; + +// dj_freight_forwarder and transporter are intentionally not exposed yet. +function rolesForCompanyType(companyType: string): RoleOption[] { + if (companyType === "customer") return CUSTOMER_ROLES; + if (companyType === "freight_forwarder") return FORWARDER_ROLES; + return []; +} + +interface CompanyRolesCardProps { + profile: ProfileResponse; +} + +export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) { + const queryClient = useQueryClient(); + + const options = useMemo( + () => rolesForCompanyType(profile.companyType), + [profile.companyType], + ); + + // Roles already persisted (active + locked), keyed by type -> reference. + const activeByType = useMemo(() => { + const map = new Map(); + for (const p of profile.companyProfiles) map.set(p.type, p.reference); + return map; + }, [profile.companyProfiles]); + + const [selected, setSelected] = useState>(new Set()); + + const toggle = (type: string) => { + if (activeByType.has(type)) return; // add-only: active roles are locked + setSelected((prev) => { + const next = new Set(prev); + if (next.has(type)) next.delete(type); + else next.add(type); + return next; + }); + }; + + const mutation = useMutation({ + mutationFn: (types: string[]) => + api.companies.addCompanyProfiles.call({ types }), + onSuccess: () => { + setSelected(new Set()); + queryClient.invalidateQueries({ + queryKey: api.companies.getProfile.queryKey(), + }); + queryClient.invalidateQueries({ + queryKey: api.companies.getInfo.queryKey(), + }); + }, + }); + + const handleSave = () => { + if (selected.size === 0) return; + mutation.mutate(Array.from(selected)); + }; + + return ( + + + + Business Profile + + + {profile.companyType === "customer" + ? "Select the role(s) your company operates as — importer, exporter, or both." + : "Your company's operational role."} + + + {options.length === 0 ? ( + + Role management for this company type is coming soon. + + ) : ( + + {options.map((opt) => { + const isActive = activeByType.has(opt.type); + const isSelected = selected.has(opt.type); + const highlighted = isActive || isSelected; + return ( + toggle(opt.type)} + className={`group block rounded-lg border! p-5! text-left transition-all duration-200 ${ + highlighted + ? "border-[var(--mantine-color-edr-green-5)]! bg-edr-soft!" + : "border-edr-border! bg-edr-card! hover:-translate-y-0.5 hover:border-[var(--mantine-color-edr-green-5)]! hover:bg-edr-soft" + } ${isActive ? "cursor-default" : ""}`} + > + + + {opt.icon} + + + + {opt.label} + + + {opt.description} + + {isActive && ( + + Active · {activeByType.get(opt.type)} + + )} + + {highlighted && ( + + )} + + + ); + })} + + )} + + {options.length > 0 && ( + + + {mutation.isSuccess && ( + + + + Profile updated + + + )} + {mutation.isError && ( + + + + Failed to update profile + + + )} + + + + )} + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx index f2ad48f2c..80e8d6ab7 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx @@ -18,6 +18,7 @@ import { api } from "@/services/api"; import PhoneInput from "@/components/auth/PhoneInput"; import type { ProfileResponse } from "@/types/profile"; import type { CreateCompanyPayload } from "@/services/companies.service"; +import CompanyRolesCard from "./CompanyRolesCard"; export const COMPANY_PROFILE_SCHEMA = z.object({ companyName: z.string().min(1, "Company name is required"), @@ -121,11 +122,13 @@ export default function TabCompanyProfile({ const onSubmit = (data: CompanyProfileFormData) => mutation.mutate(data); return ( - - - - Company Profile - + + {!isCreate && profile && } + + + + Company Profile + {isCreate ? "Enter your company registration details to get started" @@ -251,6 +254,7 @@ export default function TabCompanyProfile({ - + + ); } diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index ea6616799..80ebd4da0 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -36,6 +36,7 @@ import { } from "@/types/dropdownSettings"; import type { CompanyInfoResponse, + CompanyProfileResponse, CreateCompanyPayload, DashboardSummary, } from "./companies.service"; @@ -126,6 +127,12 @@ export const api = { "getDashboard", companiesService.getDashboard, ), + + addCompanyProfiles: endpoint<{ types: string[] }, CompanyProfileResponse[]>( + "companies", + "addCompanyProfiles", + companiesService.addCompanyProfiles, + ), }, bookings: { diff --git a/apps/edr-freight-web/portal/src/services/companies.service.ts b/apps/edr-freight-web/portal/src/services/companies.service.ts index eef7f31fe..149705ec1 100644 --- a/apps/edr-freight-web/portal/src/services/companies.service.ts +++ b/apps/edr-freight-web/portal/src/services/companies.service.ts @@ -142,6 +142,16 @@ export const companiesService = { return unwrap(response.data); }, + addCompanyProfiles: async (payload: { + types: string[]; + }): Promise => { + const response = await client.post>( + URL_CONSTANTS.COMPANIES_API.COMPANY_PROFILES, + payload, + ); + return unwrap(response.data); + }, + uploadDocuments: async ( companyId: string, files: Record, diff --git a/apps/edr-freight-web/portal/src/types/profile.ts b/apps/edr-freight-web/portal/src/types/profile.ts index cab34ee23..ce8661828 100644 --- a/apps/edr-freight-web/portal/src/types/profile.ts +++ b/apps/edr-freight-web/portal/src/types/profile.ts @@ -1,6 +1,10 @@ +import type { CompanyProfileResponse } from "@/services/companies.service"; + export interface ProfileResponse { companyId: string; companyName: string; + companyType: string; + companyProfiles: CompanyProfileResponse[]; companyEmail: string | null; companyPhone: string | null; companyLocation: string; diff --git a/apps/edr-landing/next-env.d.ts b/apps/edr-landing/next-env.d.ts new file mode 100644 index 000000000..c4b7818fb --- /dev/null +++ b/apps/edr-landing/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +import "./.next/dev/types/routes.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.