feat(wip): up the settings onboaridng flow

This commit is contained in:
Nathnael
2026-06-18 14:45:14 +00:00
parent e9149242b2
commit c6179c046c
11 changed files with 339 additions and 7 deletions

View File

@@ -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<ResponseCompanyProfileDto[]> {
const profiles = await this.companiesService.addCompanyProfilesForUser(
user.id,
dto.types,
);
return profiles.map((p) => new ResponseCompanyProfileDto(p));
}
// Used by portal
@Post("create")
@ApiOperation({

View File

@@ -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<CompanyProfile[]> {
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);
}
}

View File

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

View File

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

View File

@@ -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`,
},

View File

@@ -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: <ArrowDownToLine size={22} />,
},
{
type: "exporter",
label: "Exporter",
description: "Export goods from Ethiopia via rail.",
icon: <ArrowUpFromLine size={22} />,
},
];
const FORWARDER_ROLES: RoleOption[] = [
{
type: "freight_forwarder",
label: "Freight Forwarder",
description: "Handle cargo on behalf of importers and exporters.",
icon: <Building2 size={22} />,
},
];
// 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<string, string>();
for (const p of profile.companyProfiles) map.set(p.type, p.reference);
return map;
}, [profile.companyProfiles]);
const [selected, setSelected] = useState<Set<string>>(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 (
<Card padding="lg">
<Group gap="sm" mb="xs">
<Building2 size={20} />
<Title order={3}>Business Profile</Title>
</Group>
<Text c="edr-muted" size="sm" mb="lg">
{profile.companyType === "customer"
? "Select the role(s) your company operates as — importer, exporter, or both."
: "Your company's operational role."}
</Text>
{options.length === 0 ? (
<Text size="sm" c="edr-muted">
Role management for this company type is coming soon.
</Text>
) : (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{options.map((opt) => {
const isActive = activeByType.has(opt.type);
const isSelected = selected.has(opt.type);
const highlighted = isActive || isSelected;
return (
<UnstyledButton
key={opt.type}
onClick={() => 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" : ""}`}
>
<Group gap="md" wrap="nowrap" align="start">
<ThemeIcon
size={56}
radius="lg"
variant={highlighted ? "filled" : "light"}
color="edr-green"
className="shrink-0"
>
{opt.icon}
</ThemeIcon>
<Box className="min-w-0 flex-1">
<Text fw={700} c="edr-text" fz={15}>
{opt.label}
</Text>
<Text size="xs" c="edr-muted" mt={4} lh={1.5}>
{opt.description}
</Text>
{isActive && (
<Text size="xs" c="edr-green" mt={6} fw={600}>
Active · {activeByType.get(opt.type)}
</Text>
)}
</Box>
{highlighted && (
<Check
size={18}
className="shrink-0 text-[var(--mantine-color-edr-green-6)]"
/>
)}
</Group>
</UnstyledButton>
);
})}
</SimpleGrid>
)}
{options.length > 0 && (
<Group
justify="space-between"
mt="xl"
pt="md"
style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }}
>
<Group gap="xs">
{mutation.isSuccess && (
<Group gap={6} c="green">
<CheckCircle2 size={16} />
<Text size="sm" fw={500}>
Profile updated
</Text>
</Group>
)}
{mutation.isError && (
<Group gap={6} c="red">
<XCircle size={16} />
<Text size="sm" fw={500}>
Failed to update profile
</Text>
</Group>
)}
</Group>
<Button
type="button"
leftSection={<Save size={16} />}
loading={mutation.isPending}
disabled={selected.size === 0}
onClick={handleSave}
>
{selected.size > 1 ? "Add Roles" : "Add Role"}
</Button>
</Group>
)}
</Card>
);
}

View File

@@ -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 (
<Card padding="lg">
<Group gap="sm" mb="xs">
<Building2 size={20} />
<Title order={3}>Company Profile</Title>
</Group>
<Stack gap="lg">
{!isCreate && profile && <CompanyRolesCard profile={profile} />}
<Card padding="lg">
<Group gap="sm" mb="xs">
<Building2 size={20} />
<Title order={3}>Company Profile</Title>
</Group>
<Text c="edr-muted" size="sm" mb="lg">
{isCreate
? "Enter your company registration details to get started"
@@ -251,6 +254,7 @@ export default function TabCompanyProfile({
</Group>
</Group>
</form>
</Card>
</Card>
</Stack>
);
}

View File

@@ -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: {

View File

@@ -142,6 +142,16 @@ export const companiesService = {
return unwrap(response.data);
},
addCompanyProfiles: async (payload: {
types: string[];
}): Promise<CompanyProfileResponse[]> => {
const response = await client.post<ApiResponse<CompanyProfileResponse[]>>(
URL_CONSTANTS.COMPANIES_API.COMPANY_PROFILES,
payload,
);
return unwrap(response.data);
},
uploadDocuments: async (
companyId: string,
files: Record<string, File | File[] | null>,

View File

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

6
apps/edr-landing/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
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.