mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 03:05:42 +00:00
feat(api): Introduce dedicated company management API and service
This commit is contained in:
@@ -79,6 +79,11 @@ export const URL_CONSTANTS = {
|
|||||||
BY_USER_ID: (id: string) => `/api/customers/user/${id}`,
|
BY_USER_ID: (id: string) => `/api/customers/user/${id}`,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
COMPANIES_API: {
|
||||||
|
GET_INFO: "/api/companies/getInfo",
|
||||||
|
CREATE: "/api/companies/create",
|
||||||
|
},
|
||||||
|
|
||||||
BOOKINGS: {
|
BOOKINGS: {
|
||||||
BASE: "/bookings",
|
BASE: "/bookings",
|
||||||
BY_ID: (id: string | number) => `/bookings/${id}`,
|
BY_ID: (id: string | number) => `/bookings/${id}`,
|
||||||
|
|||||||
@@ -35,9 +35,8 @@ const useAuth = () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const customerQuery = useQuery(
|
const companyQuery = useQuery(
|
||||||
api.customers.getByUserId.queryOptions({
|
api.companies.getInfo.queryOptions({
|
||||||
input: { id: authQuery.data?.id ?? "" },
|
|
||||||
enabled: !!authQuery.data?.id,
|
enabled: !!authQuery.data?.id,
|
||||||
retry: false,
|
retry: false,
|
||||||
staleTime: 10 * 60 * 1000,
|
staleTime: 10 * 60 * 1000,
|
||||||
@@ -48,12 +47,12 @@ const useAuth = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log({
|
console.log({
|
||||||
user: authQuery.data,
|
user: authQuery.data,
|
||||||
customer: customerQuery.data,
|
company: companyQuery.data,
|
||||||
isCustomer: !!customerQuery.data,
|
isCompany: !!companyQuery.data,
|
||||||
isUserPending: authQuery.isPending,
|
isUserPending: authQuery.isPending,
|
||||||
isCustomerPending: customerQuery.isPending,
|
isCompanyPending: companyQuery.isPending,
|
||||||
});
|
});
|
||||||
}, [authQuery, customerQuery]);
|
}, [authQuery, companyQuery]);
|
||||||
|
|
||||||
const hasToken = !!getCookie("auth-token");
|
const hasToken = !!getCookie("auth-token");
|
||||||
const isPending = authQuery.isPending && hasToken;
|
const isPending = authQuery.isPending && hasToken;
|
||||||
@@ -180,7 +179,8 @@ const useAuth = () => {
|
|||||||
return {
|
return {
|
||||||
isPending,
|
isPending,
|
||||||
user: authQuery.data ?? null,
|
user: authQuery.data ?? null,
|
||||||
customer: customerQuery.data ?? null,
|
company: companyQuery.data ?? null,
|
||||||
|
customer: companyQuery.data ?? null,
|
||||||
login,
|
login,
|
||||||
signup,
|
signup,
|
||||||
setPassword,
|
setPassword,
|
||||||
@@ -189,7 +189,8 @@ const useAuth = () => {
|
|||||||
generateVerificationCode,
|
generateVerificationCode,
|
||||||
logout,
|
logout,
|
||||||
authQuery,
|
authQuery,
|
||||||
customerQuery,
|
companyQuery,
|
||||||
|
customerQuery: companyQuery,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -11,10 +12,12 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Loader2,
|
Loader2,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
|
UploadCloud,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { OnboardingUserType } from "./types";
|
import type { OnboardingUserType } from "./types";
|
||||||
import type { AuthUser } from "@/types/auth";
|
import type { AuthUser } from "@/types/auth";
|
||||||
import type { CreateCustomerDto } from "@/types/customers";
|
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,
|
||||||
@@ -23,9 +26,11 @@ import {
|
|||||||
FieldLabel,
|
FieldLabel,
|
||||||
FieldError,
|
FieldError,
|
||||||
FieldGroup,
|
FieldGroup,
|
||||||
|
SmartFileInput,
|
||||||
} from "@edr/ui-common";
|
} from "@edr/ui-common";
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
|
||||||
type CompanyStep = "company" | "personnel" | "poa";
|
type CompanyStep = "company" | "personnel" | "poa" | "documents";
|
||||||
|
|
||||||
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"),
|
||||||
@@ -79,55 +84,34 @@ const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
|
|||||||
"generalManagerPhoneCountryCode",
|
"generalManagerPhoneCountryCode",
|
||||||
],
|
],
|
||||||
poa: [],
|
poa: [],
|
||||||
|
documents: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const POA_FIELDS: (keyof FormData)[] = [
|
function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
|
||||||
"poaName",
|
|
||||||
"poaPhone",
|
|
||||||
"poaPhoneCountryCode",
|
|
||||||
"poaAddress",
|
|
||||||
"poaEmail",
|
|
||||||
"poaLocation",
|
|
||||||
];
|
|
||||||
|
|
||||||
const POA_LABELS: Record<string, string> = {
|
|
||||||
poaName: "PoA name",
|
|
||||||
poaPhone: "PoA phone",
|
|
||||||
poaPhoneCountryCode: "PoA country code",
|
|
||||||
poaAddress: "PoA address",
|
|
||||||
poaEmail: "PoA email",
|
|
||||||
poaLocation: "PoA location",
|
|
||||||
};
|
|
||||||
|
|
||||||
function buildPayload(data: FormData, user: AuthUser): CreateCustomerDto {
|
|
||||||
const nameParts = (user.name?.en ?? "").split(" ");
|
|
||||||
return {
|
return {
|
||||||
userId: user.id,
|
|
||||||
firstName: nameParts[0] || "",
|
|
||||||
lastName: nameParts.slice(-1)[0] || "",
|
|
||||||
email: user.email,
|
|
||||||
phone: user.phoneNumber,
|
|
||||||
companyName: data.companyName,
|
companyName: data.companyName,
|
||||||
companyEmail: data.companyEmail,
|
companyEmail: data.companyEmail,
|
||||||
companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`,
|
companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`,
|
||||||
companyLocation: data.companyLocation,
|
companyLocation: data.companyLocation,
|
||||||
companyAddress: data.companyAddress,
|
companyAddress: data.companyAddress,
|
||||||
contactPersonName: data.contactPersonName,
|
tin: data.tinNumber,
|
||||||
contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`,
|
|
||||||
tinNumber: data.tinNumber,
|
|
||||||
vatNumber: data.vatNumber,
|
vatNumber: data.vatNumber,
|
||||||
fanNumber: data.fanNumber,
|
fanNumber: data.fanNumber,
|
||||||
generalManagerName: data.generalManagerName,
|
attributes: {
|
||||||
generalManagerEmail: data.generalManagerEmail,
|
contactPersonName: data.contactPersonName,
|
||||||
generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`,
|
contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`,
|
||||||
poaName: data.poaName || undefined,
|
generalManagerName: data.generalManagerName,
|
||||||
poaPhone:
|
generalManagerEmail: data.generalManagerEmail,
|
||||||
data.poaPhone && data.poaPhoneCountryCode
|
generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`,
|
||||||
? `${data.poaPhoneCountryCode}${data.poaPhone}`
|
poaName: data.poaName || undefined,
|
||||||
: undefined,
|
poaPhone:
|
||||||
poaAddress: data.poaAddress || undefined,
|
data.poaPhone && data.poaPhoneCountryCode
|
||||||
poaEmail: data.poaEmail || undefined,
|
? `${data.poaPhoneCountryCode}${data.poaPhone}`
|
||||||
poaLocation: data.poaLocation || undefined,
|
: undefined,
|
||||||
|
poaAddress: data.poaAddress || undefined,
|
||||||
|
poaEmail: data.poaEmail || undefined,
|
||||||
|
poaLocation: data.poaLocation || undefined,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,21 +124,26 @@ export default function CompanyProfileForm({
|
|||||||
}: {
|
}: {
|
||||||
userType: OnboardingUserType;
|
userType: OnboardingUserType;
|
||||||
user: AuthUser;
|
user: AuthUser;
|
||||||
onSubmit: (data: CreateCustomerDto) => void;
|
onSubmit: (data: CreateCompanyPayload) => void;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
}) {
|
}) {
|
||||||
const requirePoA = userType === "freight-forwarder-et";
|
|
||||||
|
|
||||||
const [step, setStep] = useState<CompanyStep>("company");
|
const [step, setStep] = useState<CompanyStep>("company");
|
||||||
|
const [documentFiles, setDocumentFiles] = useState<
|
||||||
|
Record<string, File | File[] | null>
|
||||||
|
>({});
|
||||||
|
|
||||||
|
const { data: uploadSettings = [], isLoading: loadingDocuments } = useQuery(
|
||||||
|
api.fileUploadSettings.getByEntity.queryOptions({
|
||||||
|
input: { entity: "customer" },
|
||||||
|
refetchOnMount: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
trigger,
|
trigger,
|
||||||
setError,
|
|
||||||
clearErrors,
|
|
||||||
getValues,
|
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<FormData>({
|
} = useForm<FormData>({
|
||||||
resolver: zodResolver(onboardingSchema),
|
resolver: zodResolver(onboardingSchema),
|
||||||
@@ -184,26 +173,18 @@ export default function CompanyProfileForm({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const hasDocuments = uploadSettings.length > 0;
|
||||||
|
|
||||||
const nextStep = async () => {
|
const nextStep = async () => {
|
||||||
if (step === "poa") {
|
if (step === "poa") {
|
||||||
if (requirePoA) {
|
if (hasDocuments) {
|
||||||
clearErrors(POA_FIELDS);
|
setStep("documents");
|
||||||
const values = getValues();
|
} else {
|
||||||
let hasError = false;
|
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
|
||||||
for (const field of POA_FIELDS) {
|
|
||||||
const val = values[field];
|
|
||||||
if (!val || val.toString().trim().length === 0) {
|
|
||||||
setError(field, {
|
|
||||||
message: `${
|
|
||||||
POA_LABELS[field].charAt(0).toUpperCase() +
|
|
||||||
POA_LABELS[field].slice(1)
|
|
||||||
} is required for Freight Forwarders`,
|
|
||||||
});
|
|
||||||
hasError = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (hasError) return;
|
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (step === "documents") {
|
||||||
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
|
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -220,6 +201,8 @@ export default function CompanyProfileForm({
|
|||||||
setStep("company");
|
setStep("company");
|
||||||
} else if (step === "poa") {
|
} else if (step === "poa") {
|
||||||
setStep("personnel");
|
setStep("personnel");
|
||||||
|
} else {
|
||||||
|
setStep("poa");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -250,14 +233,21 @@ export default function CompanyProfileForm({
|
|||||||
<StepIcon
|
<StepIcon
|
||||||
icon={<FileText className="size-5" />}
|
icon={<FileText className="size-5" />}
|
||||||
active={step === "poa"}
|
active={step === "poa"}
|
||||||
completed={false}
|
completed={hasDocuments ? step === "documents" : step === "personnel"}
|
||||||
/>
|
/>
|
||||||
|
{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 3 — Company Information"}
|
{step === "company" && `Step 1 of ${hasDocuments ? 4 : 3} — Company Information`}
|
||||||
{step === "personnel" && "Step 2 of 3 — Personnel Details"}
|
{step === "personnel" && `Step 2 of ${hasDocuments ? 4 : 3} — Personnel Details`}
|
||||||
{step === "poa" &&
|
{step === "poa" && `Step 3 of ${hasDocuments ? 4 : 3} — Power of Attorney (Optional)`}
|
||||||
`Step 3 of 3 — Power of Attorney ${requirePoA ? "(Required)" : "(Optional)"}`}
|
{step === "documents" && "Step 4 of 4 — Upload Documents (Optional)"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -449,16 +439,12 @@ export default function CompanyProfileForm({
|
|||||||
{step === "poa" && (
|
{step === "poa" && (
|
||||||
<>
|
<>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{requirePoA
|
Power of Attorney details are optional. Fill them in if you have
|
||||||
? "Power of Attorney details are required for Freight Forwarder registration."
|
them, or skip to continue.
|
||||||
: "Power of Attorney details are optional. Skip if not applicable."}
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Field data-invalid={Boolean(errors.poaName)}>
|
<Field data-invalid={Boolean(errors.poaName)}>
|
||||||
<FieldLabel>
|
<FieldLabel>PoA Name</FieldLabel>
|
||||||
PoA Name
|
|
||||||
{requirePoA && <span className="text-destructive ml-1">*</span>}
|
|
||||||
</FieldLabel>
|
|
||||||
<Input
|
<Input
|
||||||
placeholder="Authorized Representative Name"
|
placeholder="Authorized Representative Name"
|
||||||
aria-invalid={Boolean(errors.poaName)}
|
aria-invalid={Boolean(errors.poaName)}
|
||||||
@@ -469,10 +455,7 @@ export default function CompanyProfileForm({
|
|||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Field data-invalid={Boolean(errors.poaEmail)}>
|
<Field data-invalid={Boolean(errors.poaEmail)}>
|
||||||
<FieldLabel>
|
<FieldLabel>PoA Email</FieldLabel>
|
||||||
PoA Email
|
|
||||||
{requirePoA && <span className="text-destructive ml-1">*</span>}
|
|
||||||
</FieldLabel>
|
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="poa@company.com"
|
placeholder="poa@company.com"
|
||||||
@@ -485,7 +468,7 @@ export default function CompanyProfileForm({
|
|||||||
<PhoneInput
|
<PhoneInput
|
||||||
countryCode={{ ...register("poaPhoneCountryCode") }}
|
countryCode={{ ...register("poaPhoneCountryCode") }}
|
||||||
phone={{ ...register("poaPhone"), placeholder: "912345678" }}
|
phone={{ ...register("poaPhone"), placeholder: "912345678" }}
|
||||||
label={`PoA Phone${requirePoA ? " *" : ""}`}
|
label="PoA Phone"
|
||||||
countryCodeError={errors.poaPhoneCountryCode}
|
countryCodeError={errors.poaPhoneCountryCode}
|
||||||
phoneError={errors.poaPhone}
|
phoneError={errors.poaPhone}
|
||||||
/>
|
/>
|
||||||
@@ -493,10 +476,7 @@ export default function CompanyProfileForm({
|
|||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Field data-invalid={Boolean(errors.poaLocation)}>
|
<Field data-invalid={Boolean(errors.poaLocation)}>
|
||||||
<FieldLabel>
|
<FieldLabel>PoA Location</FieldLabel>
|
||||||
PoA Location
|
|
||||||
{requirePoA && <span className="text-destructive ml-1">*</span>}
|
|
||||||
</FieldLabel>
|
|
||||||
<Input
|
<Input
|
||||||
placeholder="City, Country"
|
placeholder="City, Country"
|
||||||
aria-invalid={Boolean(errors.poaLocation)}
|
aria-invalid={Boolean(errors.poaLocation)}
|
||||||
@@ -506,10 +486,7 @@ export default function CompanyProfileForm({
|
|||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field data-invalid={Boolean(errors.poaAddress)}>
|
<Field data-invalid={Boolean(errors.poaAddress)}>
|
||||||
<FieldLabel>
|
<FieldLabel>PoA Address</FieldLabel>
|
||||||
PoA Address
|
|
||||||
{requirePoA && <span className="text-destructive ml-1">*</span>}
|
|
||||||
</FieldLabel>
|
|
||||||
<Input
|
<Input
|
||||||
placeholder="Full Address"
|
placeholder="Full Address"
|
||||||
aria-invalid={Boolean(errors.poaAddress)}
|
aria-invalid={Boolean(errors.poaAddress)}
|
||||||
@@ -520,6 +497,36 @@ export default function CompanyProfileForm({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{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 ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : uploadSettings.length === 0 ? (
|
||||||
|
<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">
|
||||||
|
{uploadSettings.map((setting) => (
|
||||||
|
<SmartFileInput
|
||||||
|
key={setting.id}
|
||||||
|
file={setting}
|
||||||
|
value={documentFiles}
|
||||||
|
onChange={setDocumentFiles}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
|
|
||||||
<div className="flex items-center justify-between pt-2">
|
<div className="flex items-center justify-between pt-2">
|
||||||
@@ -534,7 +541,7 @@ export default function CompanyProfileForm({
|
|||||||
<Loader2 className="animate-spin" />
|
<Loader2 className="animate-spin" />
|
||||||
Submitting...
|
Submitting...
|
||||||
</>
|
</>
|
||||||
) : step === "poa" ? (
|
) : step === "documents" ? (
|
||||||
"Complete Registration"
|
"Complete Registration"
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -560,13 +567,12 @@ function StepIcon({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`relative z-10 flex h-10 w-10 items-center justify-center rounded-full border-2 transition-all ${
|
className={`relative z-10 flex h-10 w-10 items-center justify-center rounded-full border-2 transition-all ${completed
|
||||||
completed
|
|
||||||
? "bg-primary border-primary text-primary-foreground"
|
? "bg-primary border-primary text-primary-foreground"
|
||||||
: active
|
: active
|
||||||
? "bg-background border-primary text-primary shadow-md"
|
? "bg-background border-primary text-primary shadow-md"
|
||||||
: "bg-background border-border text-muted-foreground"
|
: "bg-background border-border text-muted-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{completed ? <CheckCircle2 className="size-5" /> : icon}
|
{completed ? <CheckCircle2 className="size-5" /> : icon}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { AuthUser } from "@/types/auth";
|
import type { AuthUser } from "@/types/auth";
|
||||||
import type { CreateCustomerDto } from "@/types/customers";
|
import type { CreateCompanyPayload } from "@/services/companies.service";
|
||||||
import PhoneInput from "@/components/auth/PhoneInput";
|
import PhoneInput from "@/components/auth/PhoneInput";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -45,27 +45,21 @@ const stepLabels: Record<DjiboutiStep, string> = {
|
|||||||
representative: "Step 2 of 2 — Representative Details",
|
representative: "Step 2 of 2 — Representative Details",
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildPayload(data: FormData, user: AuthUser): CreateCustomerDto {
|
function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
|
||||||
const nameParts = (user.name?.en ?? "").split(" ");
|
|
||||||
return {
|
return {
|
||||||
userId: user.id,
|
|
||||||
firstName: nameParts[0] || "",
|
|
||||||
lastName: nameParts.slice(-1)[0] || "",
|
|
||||||
email: user.email,
|
|
||||||
phone: user.phoneNumber,
|
|
||||||
companyName: data.companyName,
|
companyName: data.companyName,
|
||||||
companyEmail: data.companyEmail,
|
companyEmail: data.companyEmail,
|
||||||
companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`,
|
companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`,
|
||||||
companyLocation: data.companyLocation,
|
companyLocation: data.companyLocation,
|
||||||
companyAddress: data.companyAddress,
|
companyAddress: data.companyAddress,
|
||||||
contactPersonName: data.repName,
|
tin: "",
|
||||||
contactPersonPhone: `${data.repPhoneCountryCode}${data.repPhone}`,
|
|
||||||
tinNumber: "",
|
|
||||||
vatNumber: "",
|
vatNumber: "",
|
||||||
fanNumber: "",
|
fanNumber: "",
|
||||||
generalManagerName: "",
|
attributes: {
|
||||||
generalManagerEmail: "",
|
repName: data.repName,
|
||||||
generalManagerPhone: "",
|
repEmail: data.repEmail,
|
||||||
|
repPhone: `${data.repPhoneCountryCode}${data.repPhone}`,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +70,7 @@ export default function DjiboutiAgentForm({
|
|||||||
onBack,
|
onBack,
|
||||||
}: {
|
}: {
|
||||||
user: AuthUser;
|
user: AuthUser;
|
||||||
onSubmit: (data: CreateCustomerDto) => void;
|
onSubmit: (data: CreateCompanyPayload) => void;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
ArrowDownToLine,
|
ArrowDownToLine,
|
||||||
ArrowUpFromLine,
|
ArrowUpFromLine,
|
||||||
@@ -10,7 +10,7 @@ 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 type { CreateCustomerDto } from "@/types/customers";
|
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 DjiboutiAgentForm from "./DjiboutiAgentForm";
|
import DjiboutiAgentForm from "./DjiboutiAgentForm";
|
||||||
@@ -23,40 +23,37 @@ const USER_TYPE_CARDS: {
|
|||||||
description: string;
|
description: string;
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
}[] = [
|
}[] = [
|
||||||
{
|
{
|
||||||
id: "importer",
|
id: "importer",
|
||||||
label: "Importer",
|
label: "Importer",
|
||||||
description: "Import goods into Ethiopia via the railway corridor.",
|
description: "Import goods into Ethiopia via the railway corridor.",
|
||||||
icon: <ArrowDownToLine className="size-6" />,
|
icon: <ArrowDownToLine className="size-6" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "exporter",
|
id: "exporter",
|
||||||
label: "Exporter",
|
label: "Exporter",
|
||||||
description: "Export goods from Ethiopia via rail.",
|
description: "Export goods from Ethiopia via rail.",
|
||||||
icon: <ArrowUpFromLine className="size-6" />,
|
icon: <ArrowUpFromLine className="size-6" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "freight-forwarder-et",
|
id: "freight-forwarder-et",
|
||||||
label: "Freight Forwarder (Ethiopia)",
|
label: "Freight Forwarder (Ethiopia)",
|
||||||
description:
|
description: "Ethiopian freight forwarding company handling client cargo.",
|
||||||
"Ethiopian freight forwarding company handling client cargo.",
|
icon: <Building2 className="size-6" />,
|
||||||
icon: <Building2 className="size-6" />,
|
},
|
||||||
},
|
{
|
||||||
{
|
id: "freight-forwarder-dj",
|
||||||
id: "freight-forwarder-dj",
|
label: "FF Agent (Djibouti)",
|
||||||
label: "FF Agent (Djibouti)",
|
description: "Djibouti-based agent coordinating cross-border logistics.",
|
||||||
description:
|
icon: <Ship className="size-6" />,
|
||||||
"Djibouti-based agent coordinating cross-border logistics.",
|
},
|
||||||
icon: <Ship className="size-6" />,
|
{
|
||||||
},
|
id: "transporter",
|
||||||
{
|
label: "Transporter",
|
||||||
id: "transporter",
|
description: "Trucking company providing first/last-mile services.",
|
||||||
label: "Transporter",
|
icon: <Truck className="size-6" />,
|
||||||
description:
|
},
|
||||||
"Trucking company providing first/last-mile services.",
|
];
|
||||||
icon: <Truck className="size-6" />,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const USER_TYPE_LEFT_MAP: Record<
|
const USER_TYPE_LEFT_MAP: Record<
|
||||||
OnboardingUserType,
|
OnboardingUserType,
|
||||||
@@ -121,21 +118,39 @@ export default function OnboardingPage() {
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [userType, setUserType] = useState<OnboardingUserType | null>(null);
|
const [userType, setUserType] = useState<OnboardingUserType | null>(null);
|
||||||
|
|
||||||
const createCustomerMutation = useMutation({
|
useQuery(
|
||||||
mutationFn: (payload: CreateCustomerDto) =>
|
api.fileUploadSettings.getByEntity.queryOptions({
|
||||||
api.customers.create.call(payload),
|
input: { entity: "customer" },
|
||||||
|
refetchOnMount: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const COMPANY_TYPE_MAP: Record<OnboardingUserType, string> = {
|
||||||
|
importer: "customer",
|
||||||
|
exporter: "customer",
|
||||||
|
"freight-forwarder-et": "forwarder",
|
||||||
|
"freight-forwarder-dj": "forwarder",
|
||||||
|
transporter: "transporter",
|
||||||
|
};
|
||||||
|
|
||||||
|
const createCompanyMutation = useMutation({
|
||||||
|
mutationFn: (payload: CreateCompanyPayload) =>
|
||||||
|
api.companies.create.call(payload),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
if (user)
|
queryClient.invalidateQueries({
|
||||||
queryClient.invalidateQueries({
|
queryKey: api.companies.getInfo.queryKey(),
|
||||||
queryKey: api.customers.getByUserId.queryKey({ id: user.id }),
|
});
|
||||||
});
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
const handleSubmit = (payload: CreateCustomerDto) => {
|
const handleSubmit = (payload: CreateCompanyPayload) => {
|
||||||
createCustomerMutation.mutate(payload);
|
const enriched: CreateCompanyPayload = {
|
||||||
|
...payload,
|
||||||
|
companyType: COMPANY_TYPE_MAP[userType!],
|
||||||
|
};
|
||||||
|
createCompanyMutation.mutate(enriched);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectType = (type: OnboardingUserType) => {
|
const handleSelectType = (type: OnboardingUserType) => {
|
||||||
@@ -172,9 +187,7 @@ export default function OnboardingPage() {
|
|||||||
{card.icon}
|
{card.icon}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-foreground">
|
<p className="font-semibold text-foreground">{card.label}</p>
|
||||||
{card.label}
|
|
||||||
</p>
|
|
||||||
<p className="mt-0.5 text-xs text-muted-foreground leading-relaxed">
|
<p className="mt-0.5 text-xs text-muted-foreground leading-relaxed">
|
||||||
{card.description}
|
{card.description}
|
||||||
</p>
|
</p>
|
||||||
@@ -197,21 +210,21 @@ export default function OnboardingPage() {
|
|||||||
features:
|
features:
|
||||||
userType === "transporter"
|
userType === "transporter"
|
||||||
? [
|
? [
|
||||||
"Vehicle & fleet registration",
|
"Vehicle & fleet registration",
|
||||||
"TIN & FAN verification",
|
"TIN & FAN verification",
|
||||||
"First-mile / Last-mile eligibility",
|
"First-mile / Last-mile eligibility",
|
||||||
]
|
]
|
||||||
: userType === "freight-forwarder-dj"
|
: userType === "freight-forwarder-dj"
|
||||||
? [
|
? [
|
||||||
"Company details",
|
"Company details",
|
||||||
"Representative information",
|
"Representative information",
|
||||||
"Cross-border operations",
|
"Cross-border operations",
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
"Company registration details",
|
"Company registration details",
|
||||||
"Contact and management personnel",
|
"Contact and management personnel",
|
||||||
"Power of Attorney (optional)",
|
"Power of Attorney (optional)",
|
||||||
],
|
],
|
||||||
stats: {
|
stats: {
|
||||||
label: "Active Customers",
|
label: "Active Customers",
|
||||||
value: "500+",
|
value: "500+",
|
||||||
@@ -226,14 +239,14 @@ export default function OnboardingPage() {
|
|||||||
<TransporterForm
|
<TransporterForm
|
||||||
user={user}
|
user={user}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
isPending={createCustomerMutation.isPending}
|
isPending={createCompanyMutation.isPending}
|
||||||
onBack={handleBack}
|
onBack={handleBack}
|
||||||
/>
|
/>
|
||||||
) : userType === "freight-forwarder-dj" ? (
|
) : userType === "freight-forwarder-dj" ? (
|
||||||
<DjiboutiAgentForm
|
<DjiboutiAgentForm
|
||||||
user={user}
|
user={user}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
isPending={createCustomerMutation.isPending}
|
isPending={createCompanyMutation.isPending}
|
||||||
onBack={handleBack}
|
onBack={handleBack}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -241,7 +254,7 @@ export default function OnboardingPage() {
|
|||||||
userType={userType}
|
userType={userType}
|
||||||
user={user}
|
user={user}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
isPending={createCustomerMutation.isPending}
|
isPending={createCompanyMutation.isPending}
|
||||||
onBack={handleBack}
|
onBack={handleBack}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
Info,
|
Info,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { AuthUser } from "@/types/auth";
|
import type { AuthUser } from "@/types/auth";
|
||||||
import type { CreateCustomerDto } from "@/types/customers";
|
import type { CreateCompanyPayload } from "@/services/companies.service";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Input,
|
Input,
|
||||||
@@ -56,34 +56,23 @@ const transporterSchema = z
|
|||||||
|
|
||||||
type FormData = z.infer<typeof transporterSchema>;
|
type FormData = z.infer<typeof transporterSchema>;
|
||||||
|
|
||||||
function buildPayload(data: FormData, user: AuthUser): CreateCustomerDto {
|
function buildPayload(data: FormData, user: AuthUser): CreateCompanyPayload {
|
||||||
const nameParts = (user.name?.en ?? "").split(" ");
|
|
||||||
return {
|
return {
|
||||||
userId: user.id,
|
companyName: user.name?.en ?? "",
|
||||||
firstName: nameParts[0] || "",
|
companyEmail: user.email,
|
||||||
lastName: nameParts.slice(-1)[0] || "",
|
companyPhone: user.phoneNumber,
|
||||||
email: user.email,
|
|
||||||
phone: user.phoneNumber,
|
|
||||||
companyName: "",
|
|
||||||
companyEmail: "",
|
|
||||||
companyPhone: "",
|
|
||||||
companyLocation: "",
|
companyLocation: "",
|
||||||
companyAddress: "",
|
companyAddress: "",
|
||||||
contactPersonName: "",
|
tin: data.tinNumber,
|
||||||
contactPersonPhone: "",
|
|
||||||
tinNumber: data.tinNumber,
|
|
||||||
vatNumber: "",
|
vatNumber: "",
|
||||||
fanNumber: data.fanNumber,
|
fanNumber: data.fanNumber,
|
||||||
generalManagerName: "",
|
attributes: {
|
||||||
generalManagerEmail: "",
|
|
||||||
generalManagerPhone: "",
|
|
||||||
notes: JSON.stringify({
|
|
||||||
truckType: data.truckType,
|
truckType: data.truckType,
|
||||||
plateNumber: data.plateNumber,
|
plateNumber: data.plateNumber,
|
||||||
plateNumber2: data.plateNumber2 || null,
|
plateNumber2: data.plateNumber2 || null,
|
||||||
vehicleModel: data.vehicleModel,
|
vehicleModel: data.vehicleModel,
|
||||||
yearOfManufacturing: data.yearOfManufacturing,
|
yearOfManufacturing: data.yearOfManufacturing,
|
||||||
}),
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +83,7 @@ export default function TransporterForm({
|
|||||||
onBack,
|
onBack,
|
||||||
}: {
|
}: {
|
||||||
user: AuthUser;
|
user: AuthUser;
|
||||||
onSubmit: (data: CreateCustomerDto) => void;
|
onSubmit: (data: CreateCompanyPayload) => void;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { fileUploadSettingsService } from "./fileUploadSettings.service";
|
|||||||
import { dropdownSettingsService } from "./dropdownSettings.service";
|
import { dropdownSettingsService } from "./dropdownSettings.service";
|
||||||
import { authService } from "./auth.service";
|
import { authService } from "./auth.service";
|
||||||
import { customersService } from "./customers.service";
|
import { customersService } from "./customers.service";
|
||||||
|
import { companiesService } from "./companies.service";
|
||||||
import {
|
import {
|
||||||
CreateDropdownOptionDto,
|
CreateDropdownOptionDto,
|
||||||
CreateDropdownSettingDto,
|
CreateDropdownSettingDto,
|
||||||
@@ -28,6 +29,10 @@ import {
|
|||||||
Customer,
|
Customer,
|
||||||
UpdateCustomerDto,
|
UpdateCustomerDto,
|
||||||
} from "@/types/customers";
|
} from "@/types/customers";
|
||||||
|
import type {
|
||||||
|
CompanyInfoResponse,
|
||||||
|
CreateCompanyPayload,
|
||||||
|
} from "./companies.service";
|
||||||
import type {
|
import type {
|
||||||
AuthUser,
|
AuthUser,
|
||||||
GenerateVerificationCodePayload,
|
GenerateVerificationCodePayload,
|
||||||
@@ -118,6 +123,20 @@ export const api = {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
companies: {
|
||||||
|
getInfo: endpoint<void, CompanyInfoResponse | null>(
|
||||||
|
"companies",
|
||||||
|
"getInfo",
|
||||||
|
companiesService.getInfo,
|
||||||
|
),
|
||||||
|
|
||||||
|
create: endpoint<CreateCompanyPayload, CompanyInfoResponse>(
|
||||||
|
"companies",
|
||||||
|
"create",
|
||||||
|
companiesService.create,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
bookings: {
|
bookings: {
|
||||||
list: endpoint<void, PaginatedResponse<Freight.IBooking>>(
|
list: endpoint<void, PaginatedResponse<Freight.IBooking>>(
|
||||||
"bookings",
|
"bookings",
|
||||||
@@ -190,6 +209,12 @@ export const api = {
|
|||||||
({ code }) => fileUploadSettingsService.getByCode(code),
|
({ code }) => fileUploadSettingsService.getByCode(code),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
getByEntity: endpoint<{ entity: string }, FileUploadSetting[]>(
|
||||||
|
"file-upload-settings",
|
||||||
|
"getByEntity",
|
||||||
|
({ entity }) => fileUploadSettingsService.getByEntity(entity),
|
||||||
|
),
|
||||||
|
|
||||||
create: endpoint<CreateFileUploadSettingDto, FileUploadSetting>(
|
create: endpoint<CreateFileUploadSettingDto, FileUploadSetting>(
|
||||||
"file-upload-settings",
|
"file-upload-settings",
|
||||||
"create",
|
"create",
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { client } from "@/utils/api";
|
||||||
|
import { unwrap } from "@/utils/endpoint";
|
||||||
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||||
|
import type { ApiResponse } from "@/types/apiResponse";
|
||||||
|
import { isAxiosError } from "axios";
|
||||||
|
|
||||||
|
export interface ExternalProfileResponse {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
companyId: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
phone: string | null;
|
||||||
|
nationalId: string | null;
|
||||||
|
jobTitle: string | null;
|
||||||
|
isPrimaryContact: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompanyResponse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
status: string;
|
||||||
|
tin: string;
|
||||||
|
vatNumber: string | null;
|
||||||
|
businessLicense: string | null;
|
||||||
|
fanNumber: string | null;
|
||||||
|
country: string;
|
||||||
|
address: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
email: string | null;
|
||||||
|
website: string | null;
|
||||||
|
attributes: Record<string, any> | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompanyInfoResponse {
|
||||||
|
profile: ExternalProfileResponse;
|
||||||
|
company: CompanyResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCompanyPayload {
|
||||||
|
companyType?: string;
|
||||||
|
companyName: string;
|
||||||
|
companyEmail?: string;
|
||||||
|
companyPhone?: string;
|
||||||
|
companyLocation?: string;
|
||||||
|
companyAddress?: string;
|
||||||
|
tin?: string;
|
||||||
|
vatNumber?: string;
|
||||||
|
fanNumber?: string;
|
||||||
|
jobTitle?: string;
|
||||||
|
isPrimaryContact?: boolean;
|
||||||
|
attributes?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const companiesService = {
|
||||||
|
getInfo: async (): Promise<CompanyInfoResponse | null> => {
|
||||||
|
try {
|
||||||
|
const response = await client.get<ApiResponse<CompanyInfoResponse>>(
|
||||||
|
URL_CONSTANTS.COMPANIES_API.GET_INFO,
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
} catch (e) {
|
||||||
|
if (isAxiosError(e) && e.response?.status === 404) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (payload: CreateCompanyPayload): Promise<CompanyInfoResponse> => {
|
||||||
|
const response = await client.post<ApiResponse<CompanyInfoResponse>>(
|
||||||
|
URL_CONSTANTS.COMPANIES_API.CREATE,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -43,6 +43,14 @@ export const fileUploadSettingsService = {
|
|||||||
return unwrap(response.data);
|
return unwrap(response.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// GET /file-upload-settings/by-entity/:entity
|
||||||
|
getByEntity: async (entity: string): Promise<FileUploadSetting[]> => {
|
||||||
|
const response = await client.get<ApiResponse<FileUploadSetting[]>>(
|
||||||
|
`${BASE}/by-entity/${encodeURIComponent(entity)}`,
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
// GET /file-upload-settings/by-code/:code
|
// GET /file-upload-settings/by-code/:code
|
||||||
getByCode: async (code: string): Promise<FileUploadSetting> => {
|
getByCode: async (code: string): Promise<FileUploadSetting> => {
|
||||||
const response = await client.get<ApiResponse<FileUploadSetting>>(
|
const response = await client.get<ApiResponse<FileUploadSetting>>(
|
||||||
|
|||||||
Reference in New Issue
Block a user