mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 12:41:04 +00:00
feat(wip): up the settings onboaridng flow
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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`,
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
6
apps/edr-landing/next-env.d.ts
vendored
Normal 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.
|
||||
Reference in New Issue
Block a user