mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +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 { UpdateCompanyDto } from "./dto/update-company.dto";
|
||||||
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
|
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
|
||||||
import { CreateCompanyWithProfileDto } from "./dto/create-company-with-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 { ResponseExternalProfileDto } from "./dto/response-external-profile.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 { UpdateProfileDto } from "./dto/update-profile.dto";
|
||||||
@@ -85,6 +89,22 @@ export class CompaniesController {
|
|||||||
return this.companiesService.updateProfile(user.id, dto);
|
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
|
// Used by portal
|
||||||
@Post("create")
|
@Post("create")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
|
|||||||
@@ -434,4 +434,47 @@ export class CompaniesService {
|
|||||||
|
|
||||||
return profiles;
|
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 { Company } from '../entities/company.entity';
|
||||||
import { ExternalProfile } from '../entities/external-profile.entity';
|
import { ExternalProfile } from '../entities/external-profile.entity';
|
||||||
|
import { ResponseCompanyProfileDto } from './response-company.dto';
|
||||||
|
|
||||||
export class ProfileResponseDto {
|
export class ProfileResponseDto {
|
||||||
companyId: string;
|
companyId: string;
|
||||||
companyName: string;
|
companyName: string;
|
||||||
|
companyType: string;
|
||||||
companyEmail: string | null;
|
companyEmail: string | null;
|
||||||
companyPhone: string | null;
|
companyPhone: string | null;
|
||||||
companyLocation: string;
|
companyLocation: string;
|
||||||
@@ -12,6 +14,8 @@ export class ProfileResponseDto {
|
|||||||
vatNumber: string | null;
|
vatNumber: string | null;
|
||||||
fanNumber: string | null;
|
fanNumber: string | null;
|
||||||
|
|
||||||
|
companyProfiles: ResponseCompanyProfileDto[];
|
||||||
|
|
||||||
contactPersonName: string | null;
|
contactPersonName: string | null;
|
||||||
contactPersonPhone: string | null;
|
contactPersonPhone: string | null;
|
||||||
generalManagerName: string | null;
|
generalManagerName: string | null;
|
||||||
@@ -29,6 +33,10 @@ export class ProfileResponseDto {
|
|||||||
constructor(profile: ExternalProfile, company: Company) {
|
constructor(profile: ExternalProfile, company: Company) {
|
||||||
this.companyId = company.id;
|
this.companyId = company.id;
|
||||||
this.companyName = company.name;
|
this.companyName = company.name;
|
||||||
|
this.companyType = company.type;
|
||||||
|
this.companyProfiles =
|
||||||
|
company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)) ??
|
||||||
|
[];
|
||||||
this.companyEmail = company.email ?? null;
|
this.companyEmail = company.email ?? null;
|
||||||
this.companyPhone = company.phone ?? null;
|
this.companyPhone = company.phone ?? null;
|
||||||
this.companyLocation = company.country;
|
this.companyLocation = company.country;
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ export const URL_CONSTANTS = {
|
|||||||
GET_INFO: "/api/companies/getInfo",
|
GET_INFO: "/api/companies/getInfo",
|
||||||
CREATE: "/api/companies/create",
|
CREATE: "/api/companies/create",
|
||||||
PROFILE: "/api/companies/profile",
|
PROFILE: "/api/companies/profile",
|
||||||
|
COMPANY_PROFILES: "/api/companies/company-profiles",
|
||||||
DASHBOARD: "/api/companies/dashboard",
|
DASHBOARD: "/api/companies/dashboard",
|
||||||
DOCUMENTS: (id: string) => `/api/companies/${id}/documents`,
|
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 PhoneInput from "@/components/auth/PhoneInput";
|
||||||
import type { ProfileResponse } from "@/types/profile";
|
import type { ProfileResponse } from "@/types/profile";
|
||||||
import type { CreateCompanyPayload } from "@/services/companies.service";
|
import type { CreateCompanyPayload } from "@/services/companies.service";
|
||||||
|
import CompanyRolesCard from "./CompanyRolesCard";
|
||||||
|
|
||||||
export const COMPANY_PROFILE_SCHEMA = z.object({
|
export const COMPANY_PROFILE_SCHEMA = z.object({
|
||||||
companyName: z.string().min(1, "Company name is required"),
|
companyName: z.string().min(1, "Company name is required"),
|
||||||
@@ -121,11 +122,13 @@ export default function TabCompanyProfile({
|
|||||||
const onSubmit = (data: CompanyProfileFormData) => mutation.mutate(data);
|
const onSubmit = (data: CompanyProfileFormData) => mutation.mutate(data);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card padding="lg">
|
<Stack gap="lg">
|
||||||
<Group gap="sm" mb="xs">
|
{!isCreate && profile && <CompanyRolesCard profile={profile} />}
|
||||||
<Building2 size={20} />
|
<Card padding="lg">
|
||||||
<Title order={3}>Company Profile</Title>
|
<Group gap="sm" mb="xs">
|
||||||
</Group>
|
<Building2 size={20} />
|
||||||
|
<Title order={3}>Company Profile</Title>
|
||||||
|
</Group>
|
||||||
<Text c="edr-muted" size="sm" mb="lg">
|
<Text c="edr-muted" size="sm" mb="lg">
|
||||||
{isCreate
|
{isCreate
|
||||||
? "Enter your company registration details to get started"
|
? "Enter your company registration details to get started"
|
||||||
@@ -251,6 +254,7 @@ export default function TabCompanyProfile({
|
|||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</Card>
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
} from "@/types/dropdownSettings";
|
} from "@/types/dropdownSettings";
|
||||||
import type {
|
import type {
|
||||||
CompanyInfoResponse,
|
CompanyInfoResponse,
|
||||||
|
CompanyProfileResponse,
|
||||||
CreateCompanyPayload,
|
CreateCompanyPayload,
|
||||||
DashboardSummary,
|
DashboardSummary,
|
||||||
} from "./companies.service";
|
} from "./companies.service";
|
||||||
@@ -126,6 +127,12 @@ export const api = {
|
|||||||
"getDashboard",
|
"getDashboard",
|
||||||
companiesService.getDashboard,
|
companiesService.getDashboard,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
addCompanyProfiles: endpoint<{ types: string[] }, CompanyProfileResponse[]>(
|
||||||
|
"companies",
|
||||||
|
"addCompanyProfiles",
|
||||||
|
companiesService.addCompanyProfiles,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
bookings: {
|
bookings: {
|
||||||
|
|||||||
@@ -142,6 +142,16 @@ export const companiesService = {
|
|||||||
return unwrap(response.data);
|
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 (
|
uploadDocuments: async (
|
||||||
companyId: string,
|
companyId: string,
|
||||||
files: Record<string, File | File[] | null>,
|
files: Record<string, File | File[] | null>,
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
|
import type { CompanyProfileResponse } from "@/services/companies.service";
|
||||||
|
|
||||||
export interface ProfileResponse {
|
export interface ProfileResponse {
|
||||||
companyId: string;
|
companyId: string;
|
||||||
companyName: string;
|
companyName: string;
|
||||||
|
companyType: string;
|
||||||
|
companyProfiles: CompanyProfileResponse[];
|
||||||
companyEmail: string | null;
|
companyEmail: string | null;
|
||||||
companyPhone: string | null;
|
companyPhone: string | null;
|
||||||
companyLocation: string;
|
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