Merge branch 'freight_feature/priority' of github.com:Tria-plc/edr-platform into freight_feature/priority

This commit is contained in:
Marshal
2026-06-16 13:27:29 +00:00
13 changed files with 862 additions and 580 deletions

View File

@@ -13,7 +13,7 @@ import {
BookingReviewNotesCard, BookingReviewNotesCard,
BookingRouteCard, BookingRouteCard,
detailStyles, detailStyles,
type BookingDetailView type BookingDetailView,
} from "@/components/bookings/detail"; } from "@/components/bookings/detail";
import Breadcrumbs from "@/components/ui/Breadcrumbs"; import Breadcrumbs from "@/components/ui/Breadcrumbs";
@@ -101,7 +101,9 @@ const BookingDetailPage = () => {
}; };
const approvalSteps = booking.approvalSteps ?? []; const approvalSteps = booking.approvalSteps ?? [];
const approvedCount = approvalSteps.filter((s) => s.status === "APPROVED").length; const approvedCount = approvalSteps.filter(
(s) => s.status === "APPROVED",
).length;
const totalSteps = approvalSteps.length; const totalSteps = approvalSteps.length;
return ( return (
@@ -116,7 +118,7 @@ const BookingDetailPage = () => {
{ label: booking.reference }, { label: booking.reference },
]} ]}
/> />
{/* {/*
<BookingDetailHeader <BookingDetailHeader
booking={booking} booking={booking}
approvedCount={approvedCount} approvedCount={approvedCount}
@@ -125,13 +127,18 @@ const BookingDetailPage = () => {
<BookingLifecycleStepper status={booking.status} /> <BookingLifecycleStepper status={booking.status} />
<Grid gutter="lg"> <Grid>
{/* LEFT — primary content */} {/* LEFT — primary content */}
<Grid.Col span={{ base: 12, lg: 8 }}> <Grid.Col span={{ base: 12, lg: 8 }}>
<Stack gap="lg"> <Stack gap="lg">
<BookingRouteCard booking={booking} /> <BookingRouteCard booking={booking} />
<BookingContainersCard containers={booking.bookingContainers ?? []} /> <BookingContainersCard
<BookingApprovalCard steps={approvalSteps} approvedCount={approvedCount} /> containers={booking.bookingContainers ?? []}
/>
<BookingApprovalCard
steps={approvalSteps}
approvedCount={approvedCount}
/>
<BookingReviewNotesCard notes={booking.reviewNotes ?? []} /> <BookingReviewNotesCard notes={booking.reviewNotes ?? []} />
</Stack> </Stack>
</Grid.Col> </Grid.Col>
@@ -139,9 +146,12 @@ const BookingDetailPage = () => {
{/* RIGHT — summary sidebar */} {/* RIGHT — summary sidebar */}
<Grid.Col span={{ base: 12, lg: 4 }}> <Grid.Col span={{ base: 12, lg: 4 }}>
<Stack gap="lg"> <Stack gap="lg">
{booking.status === "SELECTED_FOR_BATCH" && booking.paymentDeadline && ( {booking.status === "SELECTED_FOR_BATCH" &&
<BookingPaymentCountdownCard paymentDeadline={booking.paymentDeadline} /> booking.paymentDeadline && (
)} <BookingPaymentCountdownCard
paymentDeadline={booking.paymentDeadline}
/>
)}
<BookingPaymentCard <BookingPaymentCard
totalAmount={booking.totalAmount} totalAmount={booking.totalAmount}
currency={booking.paymentCurrency} currency={booking.paymentCurrency}

View File

@@ -75,7 +75,7 @@ function RequireAuth() {
* Only redirects on a confirmed "no company" response — never on a * Only redirects on a confirmed "no company" response — never on a
* transient query error. * transient query error.
*/ */
function RequireCompany({path}: {path: string}) { function RequireCompany() {
const { customerQuery } = useAuth(); const { customerQuery } = useAuth();
if (customerQuery.isPending) return <FullScreenSpinner />; if (customerQuery.isPending) return <FullScreenSpinner />;

View File

@@ -1,13 +1,6 @@
import { customers, type Customer } from "@/pages/customers/customers.mock"; import { customers, type Customer } from "@/pages/customers/customers.mock";
import { bookings, type Booking } from "@/pages/bookings/bookings.mock"; import { bookings, type Booking } from "@/pages/bookings/bookings.mock";
import { import { shipments, type Shipment } from "@/pages/tracking/shipments.mock";
consignments,
type Consignment,
} from "@/pages/consignments/consignments.mock";
import {
shipments,
type Shipment,
} from "@/pages/tracking/shipments.mock";
import { invoices, type Invoice } from "@/pages/billing/invoices.mock"; import { invoices, type Invoice } from "@/pages/billing/invoices.mock";
/** /**
@@ -28,12 +21,6 @@ export function getMyBookings(): Booking[] {
return bookings.filter((b) => b.customerId === me.id); return bookings.filter((b) => b.customerId === me.id);
} }
export function getMyConsignments(): Consignment[] {
const me = getCurrentCustomer();
const myBookingIds = new Set(getMyBookings().map((b) => b.id));
return consignments.filter((c) => myBookingIds.has(c.bookingId));
}
export function getMyShipments(): Shipment[] { export function getMyShipments(): Shipment[] {
const myBookingIds = new Set(getMyBookings().map((b) => b.id)); const myBookingIds = new Set(getMyBookings().map((b) => b.id));
return shipments.filter((s) => myBookingIds.has(s.bookingId)); return shipments.filter((s) => myBookingIds.has(s.bookingId));

View File

@@ -438,7 +438,7 @@ export default function MyPortalPage() {
</Link> </Link>
</Group> </Group>
{!customer&& ( {!customer && (
<Box className="rounded-2xl border border-edr-border bg-gradient-to-r from-edr-blue/5 to-edr-green/5 px-7 py-6"> <Box className="rounded-2xl border border-edr-border bg-gradient-to-r from-edr-blue/5 to-edr-green/5 px-7 py-6">
<Group justify="space-between" align="center" wrap="nowrap"> <Group justify="space-between" align="center" wrap="nowrap">
<Box className="flex-1"> <Box className="flex-1">
@@ -446,9 +446,10 @@ export default function MyPortalPage() {
Setup your Company Profile Setup your Company Profile
</Text> </Text>
<Text fz={13} c="edr-muted" mb={12}> <Text fz={13} c="edr-muted" mb={12}>
Complete your company information to unlock all features and start booking shipments. Complete your company information to unlock all features and
start booking shipments.
</Text> </Text>
<Link to="/onboarding" className="no-underline"> <Link to="/settings" className="no-underline">
<Group gap={8} align="center" className="w-fit"> <Group gap={8} align="center" className="w-fit">
<Text fz={13} fw={600} c="edr-green.7"> <Text fz={13} fw={600} c="edr-green.7">
Complete Setup Complete Setup
@@ -464,7 +465,6 @@ export default function MyPortalPage() {
</Box> </Box>
)} )}
{/* ── Stats Strip ───────────────────────────────────────────────────── */} {/* ── Stats Strip ───────────────────────────────────────────────────── */}
<Box className="rounded-2xl border border-edr-border bg-gradient-to-br from-white to-edr-soft px-7 py-5"> <Box className="rounded-2xl border border-edr-border bg-gradient-to-br from-white to-edr-soft px-7 py-5">
<SimpleGrid cols={{ base: 2, lg: 4 }} spacing={0} verticalSpacing={20}> <SimpleGrid cols={{ base: 2, lg: 4 }} spacing={0} verticalSpacing={20}>

View File

@@ -1,111 +1,364 @@
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
Container,
Group,
Stack,
Title,
Text,
Tabs,
Card,
TextInput,
Button,
Badge,
Alert,
Center,
Loader,
Grid,
} from "@mantine/core";
import {
AlertCircle,
Building2, Building2,
User,
Briefcase, Briefcase,
UserCheck, CheckCircle2,
FileCheck, FileCheck,
Save,
User,
UserCheck,
XCircle,
} from "lucide-react"; } from "lucide-react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { api } from "@/services/api"; import { api } from "@/services/api";
import { Badge } from "@edr/ui-common"; import type { CreateCompanyPayload } from "@/services/companies.service";
import { cn } from "@/lib/utils"; import PhoneInput from "@/components/auth/PhoneInput";
import TabCompanyProfile from "./settings/TabCompanyProfile"; import TabCompanyProfile from "./settings/TabCompanyProfile";
import TabContactPerson from "./settings/TabContactPerson"; import TabContactPerson from "./settings/TabContactPerson";
import TabGeneralManager from "./settings/TabGeneralManager"; import TabGeneralManager from "./settings/TabGeneralManager";
import TabPowerOfAttorney from "./settings/TabPowerOfAttorney"; import TabPowerOfAttorney from "./settings/TabPowerOfAttorney";
import TabDocuments from "./settings/TabDocuments"; import TabDocuments from "./settings/TabDocuments";
type SettingsTab = type SettingsTab = "company" | "contact" | "gm" | "poa" | "documents";
| "company"
| "contact"
| "gm"
| "poa"
| "documents";
const TABS: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [ const TABS: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [
{ id: "company", label: "Company Profile", icon: <Building2 className="size-4" /> }, { id: "company", label: "Company Profile", icon: <Building2 size={16} /> },
{ id: "contact", label: "Contact Person", icon: <User className="size-4" /> }, { id: "contact", label: "Contact Person", icon: <User size={16} /> },
{ id: "gm", label: "General Manager", icon: <Briefcase className="size-4" /> }, { id: "gm", label: "General Manager", icon: <Briefcase size={16} /> },
{ id: "poa", label: "Power of Attorney", icon: <UserCheck className="size-4" /> }, { id: "poa", label: "Power of Attorney", icon: <UserCheck size={16} /> },
{ id: "documents", label: "Documents", icon: <FileCheck className="size-4" /> }, { id: "documents", label: "Documents", icon: <FileCheck size={16} /> },
]; ];
export default function SettingsPage() { export default function SettingsPage() {
const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const tab = (searchParams.get("tab") as SettingsTab) || "company"; const tab = (searchParams.get("tab") as SettingsTab) || "company";
const setTab = (t: SettingsTab) => { const setTab = (t: SettingsTab) => {
setSearchParams((prev) => { setSearchParams(
const next = new URLSearchParams(prev); (prev) => {
next.set("tab", t); const next = new URLSearchParams(prev);
return next; next.set("tab", t);
}, { replace: true }); return next;
},
{ replace: true },
);
}; };
const profileQuery = useQuery( const profileQuery = useQuery(api.companies.getProfile.queryOptions());
api.companies.getProfile.queryOptions(),
);
const profile = profileQuery.data; const profile = profileQuery.data;
const createCompanyMutation = useMutation({
mutationFn: (payload: CreateCompanyPayload) =>
api.companies.create.call(payload),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(),
});
},
});
const onboardingSchema = z.object({
companyName: z.string().min(1, "Company name is required"),
companyEmail: z.string().email("Invalid email address"),
companyPhone: z.string().min(1, "Company phone is required"),
companyPhoneCountryCode: z.string().min(1, "Country code is required"),
companyLocation: z.string().min(1, "Location is required"),
companyAddress: z.string().min(1, "Address is required"),
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
fanNumber: z.string().length(16, "FAN must be exactly 16 digits"),
});
type OnboardingFormData = z.infer<typeof onboardingSchema>;
const {
register,
handleSubmit,
formState: { errors },
} = useForm<OnboardingFormData>({
resolver: zodResolver(onboardingSchema),
defaultValues: {
companyPhoneCountryCode: "+251",
},
});
const onSubmitOnboarding = (data: OnboardingFormData) => {
const payload: CreateCompanyPayload = {
companyType: "customer",
companyName: data.companyName,
companyEmail: data.companyEmail,
companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`,
companyLocation: data.companyLocation,
companyAddress: data.companyAddress,
tin: data.tinNumber,
fanNumber: data.fanNumber,
};
createCompanyMutation.mutate(payload);
};
if (profileQuery.isPending) { if (profileQuery.isPending) {
return ( return (
<div className="flex h-full items-center justify-center"> <Center h="100%">
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-primary" /> <Loader color="edr-green" size="lg" />
</div> </Center>
);
}
if (!profile) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground">No company profile found.</p>
</div>
); );
} }
return ( return (
<div className="px-4 py-8"> <Container size="xl" py="xl">
<div className="mb-8 flex items-center justify-between"> <Group justify="space-between" mb="xl">
<div> <div>
<h1 className="text-2xl font-black tracking-tight text-foreground"> <Title order={1} size="h2">
Account Settings Account Settings
</h1> </Title>
<p className="mt-1 text-sm text-muted-foreground"> <Text c="edr-muted" size="sm" mt={4}>
Manage your company profile, personnel, and documents Manage your company profile, personnel, and documents
</p> </Text>
</div> </div>
<Badge variant="secondary" className="font-bold uppercase tracking-wider"> {profile && <Badge color="edr-green">Verified</Badge>}
Verified </Group>
</Badge>
</div>
{/* Tab Bar */} <Tabs
<div className="mb-6 flex flex-wrap gap-1 border-b border-border"> value={tab}
{TABS.map((t) => ( onChange={(value) => {
<button if (!value) return;
key={t.id} if (!profile && value !== "company") return;
type="button" setTab(value as SettingsTab);
onClick={() => setTab(t.id)} }}
className={cn( >
"flex items-center gap-2 border-b-2 px-4 py-3 text-sm font-semibold transition-colors", <Tabs.List mb="md">
tab === t.id {TABS.map((t) => (
? "border-primary text-primary" <Tabs.Tab
: "border-transparent text-muted-foreground hover:text-foreground", key={t.id}
)} value={t.id}
> leftSection={t.icon}
{t.icon} disabled={!profile && t.id !== "company"}
{t.label} >
</button> {t.label}
))} </Tabs.Tab>
</div> ))}
</Tabs.List>
{tab === "company" && <TabCompanyProfile profile={profile} />} <Tabs.Panel value="company">
{tab === "contact" && <TabContactPerson profile={profile} />} {!profile ? (
{tab === "gm" && <TabGeneralManager profile={profile} />} <Card padding="lg">
{tab === "poa" && <TabPowerOfAttorney profile={profile} />} <Stack gap="md">
{tab === "documents" && <TabDocuments profile={profile} />} <Group gap="sm">
</div> <Building2 size={20} />
<Title order={3}>Company Profile</Title>
</Group>
<Text c="edr-muted" size="sm">
Enter your company registration details to get started
</Text>
</Stack>
<form onSubmit={handleSubmit(onSubmitOnboarding)}>
<Stack gap="md" mt="lg">
<TextInput
label="Company Name"
placeholder="Global Logistics Ltd"
error={errors.companyName?.message}
{...register("companyName")}
/>
<Grid>
<Grid.Col span={6}>
<TextInput
label="Company Email"
type="email"
placeholder="ops@company.com"
error={errors.companyEmail?.message}
{...register("companyEmail")}
/>
</Grid.Col>
<Grid.Col span={6}>
<PhoneInput
countryCode={{ ...register("companyPhoneCountryCode") }}
phone={{
...register("companyPhone"),
placeholder: "912345678",
}}
countryCodeError={errors.companyPhoneCountryCode}
phoneError={errors.companyPhone}
label="Company Phone"
/>
</Grid.Col>
</Grid>
<Grid>
<Grid.Col span={6}>
<TextInput
label="Location"
placeholder="Addis Ababa, Ethiopia"
error={errors.companyLocation?.message}
{...register("companyLocation")}
/>
</Grid.Col>
<Grid.Col span={6}>
<TextInput
label="Address"
placeholder="Bole Subcity, Woreda 03"
error={errors.companyAddress?.message}
{...register("companyAddress")}
/>
</Grid.Col>
</Grid>
<Grid>
<Grid.Col span={6}>
<TextInput
label="TIN Number (10 digits)"
placeholder="1234567890"
maxLength={10}
error={errors.tinNumber?.message}
{...register("tinNumber")}
/>
</Grid.Col>
<Grid.Col span={6}>
<TextInput
label="FAN Number (16 digits)"
placeholder="1234567890123456"
maxLength={16}
error={errors.fanNumber?.message}
{...register("fanNumber")}
/>
</Grid.Col>
</Grid>
</Stack>
<Group
justify="space-between"
mt="xl"
pt="md"
style={{
borderTop: "1px solid var(--mantine-color-edr-border-0)",
}}
>
<Group gap="xs">
{createCompanyMutation.isSuccess && (
<Group gap={6} c="green">
<CheckCircle2 size={16} />
<Text size="sm" fw={500}>
Profile created successfully
</Text>
</Group>
)}
{createCompanyMutation.isError && (
<Group gap={6} c="red">
<XCircle size={16} />
<Text size="sm" fw={500}>
Failed to create profile
</Text>
</Group>
)}
</Group>
<Button
type="submit"
leftSection={<Save size={16} />}
loading={createCompanyMutation.isPending}
>
Create Profile
</Button>
</Group>
</form>
</Card>
) : (
<TabCompanyProfile profile={profile} />
)}
</Tabs.Panel>
<Tabs.Panel value="contact">
{profile ? (
<TabContactPerson profile={profile} />
) : (
<Card padding="xl">
<Center>
<Alert
icon={<AlertCircle size={24} />}
color="gray"
variant="light"
>
Please complete the company profile first.
</Alert>
</Center>
</Card>
)}
</Tabs.Panel>
<Tabs.Panel value="gm">
{profile ? (
<TabGeneralManager profile={profile} />
) : (
<Card padding="xl">
<Center>
<Alert
icon={<AlertCircle size={24} />}
color="gray"
variant="light"
>
Please complete the company profile first.
</Alert>
</Center>
</Card>
)}
</Tabs.Panel>
<Tabs.Panel value="poa">
{profile ? (
<TabPowerOfAttorney profile={profile} />
) : (
<Card padding="xl">
<Center>
<Alert
icon={<AlertCircle size={24} />}
color="gray"
variant="light"
>
Please complete the company profile first.
</Alert>
</Center>
</Card>
)}
</Tabs.Panel>
<Tabs.Panel value="documents">
{profile ? (
<TabDocuments profile={profile} />
) : (
<Card padding="xl">
<Center>
<Alert
icon={<AlertCircle size={24} />}
color="gray"
variant="light"
>
Please complete the company profile first.
</Alert>
</Center>
</Card>
)}
</Tabs.Panel>
</Tabs>
</Container>
); );
} }
//

View File

@@ -63,11 +63,7 @@ export default function NewBookingPage() {
You need to complete your company onboarding before you can create You need to complete your company onboarding before you can create
bookings. Please follow the onboarding process to get started. bookings. Please follow the onboarding process to get started.
</Text> </Text>
<Button <Button color="orange" onClick={() => navigate("/settings")} mt="md">
color="orange"
onClick={() => navigate("/onboarding")}
mt="md"
>
Go to Onboarding Go to Onboarding
</Button> </Button>
</Alert> </Alert>
@@ -110,18 +106,15 @@ export default function NewBookingPage() {
const originYard = form.watch("originYard"); const originYard = form.watch("originYard");
const destinationYard = form.watch("destinationYard"); const destinationYard = form.watch("destinationYard");
const direction = useMemo( const direction = useMemo(() => {
() =>{ const origin = referenceData?.yard.find((y) => y.id === originYard);
const origin = referenceData?.yard.find((y) => y.id === originYard); const destination = referenceData?.yard.find(
const destination = referenceData?.yard.find((y) => y.id === destinationYard); (y) => y.id === destinationYard,
);
const route = getRouteDirection( const route = getRouteDirection(origin, destination);
origin,destination return route;
) }, [originYard, destinationYard]);
return route
},
[originYard, destinationYard],
);
async function handleContinue() { async function handleContinue() {
const valid = await form.trigger(stepFields[step], { shouldFocus: true }); const valid = await form.trigger(stepFields[step], { shouldFocus: true });
@@ -171,8 +164,7 @@ export default function NewBookingPage() {
.flatMap((g) => g.children ?? []) .flatMap((g) => g.children ?? [])
.find((c) => c.id === childId); .find((c) => c.id === childId);
const cargoTypeId = const cargoTypeId = data.cargoType === "bulk" ? childId : undefined;
data.cargoType === "bulk" ? childId : undefined;
const cargoFreeText = bulkChild?.show_free_text_box const cargoFreeText = bulkChild?.show_free_text_box
? data.cargoFreeText ? data.cargoFreeText
@@ -304,7 +296,9 @@ export default function NewBookingPage() {
</Alert> </Alert>
)} )}
{step === 1 && <Step1ContractType form={form} referenceData={referenceData} />} {step === 1 && (
<Step1ContractType form={form} referenceData={referenceData} />
)}
{step === 2 && ( {step === 2 && (
<Step2ServiceType referenceData={referenceData} form={form} /> <Step2ServiceType referenceData={referenceData} form={form} />
)} )}

View File

@@ -4,10 +4,7 @@ import { useQuery } from "@tanstack/react-query";
import { FileText, RefreshCw } from "lucide-react"; import { FileText, RefreshCw } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Controller, type UseFormReturn } from "react-hook-form"; import { Controller, type UseFormReturn } from "react-hook-form";
import { import { BookingFormInputValues, type BookingFormValues } from "./schema";
BookingFormInputValues,
type BookingFormValues,
} from "./schema";
import { import {
AlertBox, AlertBox,
AsyncComboboxField, AsyncComboboxField,
@@ -16,7 +13,11 @@ import {
StepHeader, StepHeader,
} from "./shared"; } from "./shared";
type BookingForm = UseFormReturn<BookingFormInputValues, any, BookingFormValues>; type BookingForm = UseFormReturn<
BookingFormInputValues,
any,
BookingFormValues
>;
interface PreviousContractOption { interface PreviousContractOption {
value: string; value: string;
@@ -35,21 +36,24 @@ export function Step1ContractType({
const previousContractRef = form.watch("previousContractRef"); const previousContractRef = form.watch("previousContractRef");
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const { data: bookings, isLoading, error } = useQuery( const {
api.bookings.list.queryOptions({ data: bookings,
input: { isLoading,
error,
} = useQuery(
api.bookings.list.queryOptions({
input: {
page: 1, page: 1,
pageSize: 100, pageSize: 100,
sortBy: "createdAt", sortBy: "createdAt",
sortOrder: "DESC", sortOrder: "DESC",
} },
}) }),
); );
const contractOptions = useMemo<PreviousContractOption[]>(() => { const contractOptions = useMemo<PreviousContractOption[]>(() => {
console.log("Bookings data:", bookings); console.log("Bookings data:", bookings);
if(!bookings) return [] if (!bookings) return [];
return bookings?.items return bookings?.items
.map((booking) => { .map((booking) => {
@@ -62,35 +66,120 @@ api.bookings.list.queryOptions({
}; };
}) })
.filter((opt) => .filter((opt) =>
opt.label.toLowerCase().includes(searchQuery.toLowerCase()) opt.label.toLowerCase().includes(searchQuery.toLowerCase()),
); );
}, [bookings, searchQuery]); }, [bookings, searchQuery]);
const handleSelectContract = async (contractId: string) => { const handleSelectContract = (contractId: string) => {
const selected = contractOptions.find((opt) => opt.value === contractId); const selected = contractOptions.find((opt) => opt.value === contractId);
if (!selected) return; if (!selected) return;
form.setValue("previousContractRef", contractId); form.setValue("previousContractRef", contractId);
// Auto-fill from previous contract
const booking = selected.booking; const booking = selected.booking;
if (booking) { if (!booking) return;
form.setValue("serviceTypeId", booking.serviceTypeId);
form.setValue("cargoType", booking.freightType === "CONTAINER" ? "container" : "bulk");
form.setValue("equipmentReturn", booking.equipmentReturn === "WITH_RETURN" ? "with_return" : "without_return");
if (booking.isHazardous) form.setValue("isHazardous", booking.isHazardous);
// Look up shipping line name from reference data // ── Service type ────────────────────────────────────────────────────
if (booking.shippingLineId && referenceData?.shipping_line) { const service = booking.serviceType
const shippingLine = referenceData.shipping_line.find( ? referenceData?.service.find((s) => s.code === booking.serviceType)
(sl) => sl.id === booking.shippingLineId, : undefined;
); const serviceId = service?.id || booking.serviceTypeId;
if (shippingLine) { if (serviceId) form.setValue("serviceTypeId", serviceId);
form.setValue("shippingLine", shippingLine.name);
} // ── First / last mile ───────────────────────────────────────────────
form.setValue("firstMile.enabled", booking.firstMileEnabled);
if (booking.firstMilePickupAddress) {
form.setValue("firstMile.pickUpAddress", booking.firstMilePickupAddress);
}
form.setValue("lastMile.enabled", booking.lastMileEnabled);
if (booking.lastMileDeliveryAddress) {
form.setValue(
"lastMile.deliveryAddress",
booking.lastMileDeliveryAddress,
);
}
// ── Equipment return ────────────────────────────────────────────────
form.setValue(
"equipmentReturn",
booking.equipmentReturn === "WITH_RETURN"
? "with_return"
: "without_return",
);
// ── Customs ─────────────────────────────────────────────────────────
if (service) {
form.setValue("customsClearingEnabled", service.includesCustoms);
}
// ── Route ───────────────────────────────────────────────────────────
if (booking.originYard?.id) {
form.setValue("originYard", booking.originYard.id);
}
if (booking.destinationYard?.id) {
form.setValue("destinationYard", booking.destinationYard.id);
}
// ── Shipping line ───────────────────────────────────────────────────
if (booking.shippingLineId && referenceData?.shipping_line) {
const shippingLine = referenceData.shipping_line.find(
(sl) => sl.id === booking.shippingLineId,
);
if (shippingLine) {
form.setValue("shippingLine", shippingLine.name);
} }
} }
}
// ── Cargo type ──────────────────────────────────────────────────────
form.setValue(
"cargoType",
booking.freightType === "CONTAINER" ? "container" : "bulk",
);
// ── Cargo weight (bulk) ─────────────────────────────────────────────
if (booking.cargoTotalWeightVgm > 0) {
form.setValue("cargoWeight", String(booking.cargoTotalWeightVgm));
}
// ── Hazardous / refrigerated ────────────────────────────────────────
form.setValue("isHazardous", booking.isHazardous);
form.setValue("isRefrigerated", booking.isRefrigerated);
// ── Containers ──────────────────────────────────────────────────────
if (
booking.freightType === "CONTAINER" &&
booking.containers &&
booking.containers.length > 0
) {
const mappedContainers = booking.containers.map((c) => {
let containerTypeName = "";
for (const group of referenceData?.containers ?? []) {
const ct = group.types.find(
(t) => t.code === c.type || t.name === c.type,
);
if (ct) {
containerTypeName = ct.name;
break;
}
}
return {
type: (c.type === "40ft" ? "40ft" : "20ft") as "20ft" | "40ft",
containerType: containerTypeName,
qty: String(c.qty),
vgm: String(c.vgm),
};
});
form.setValue("containers", mappedContainers);
}
// ── Consolidation ───────────────────────────────────────────────────
form.setValue("consolidationEnabled", booking.allowConsolidation);
// ── Scheduled date ──────────────────────────────────────────────────
if (booking.scheduledDate) {
form.setValue("scheduledDate", booking.scheduledDate);
}
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">

View File

@@ -3,23 +3,19 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import { Building2, CheckCircle2, Loader2, Save, XCircle } from "lucide-react"; import { Building2, CheckCircle2, Save, XCircle } from "lucide-react";
import { api } from "@/services/api";
import PhoneInput from "@/components/auth/PhoneInput";
import { import {
Card, Card,
CardHeader, Group,
CardTitle, Stack,
CardDescription, Title,
CardContent, Text,
CardFooter, TextInput,
Button, Button,
Input, Grid,
Field, } from "@mantine/core";
FieldLabel, import { api } from "@/services/api";
FieldError, import PhoneInput from "@/components/auth/PhoneInput";
FieldGroup,
} from "@edr/ui-common";
import type { ProfileResponse } from "@/types/profile"; import type { ProfileResponse } from "@/types/profile";
const schema = z.object({ const schema = z.object({
@@ -88,114 +84,107 @@ export default function TabCompanyProfile({ profile }: { profile: ProfileRespons
const onSubmit = (data: FormData) => mutation.mutate(data); const onSubmit = (data: FormData) => mutation.mutate(data);
return ( return (
<Card> <Card padding="lg">
<CardHeader> <Group gap="sm" mb="xs">
<CardTitle className="flex items-center gap-2"> <Building2 size={20} />
<Building2 className="size-5 text-primary" /> <Title order={3}>Company Profile</Title>
Company Profile </Group>
</CardTitle> <Text c="edr-muted" size="sm" mb="lg">
<CardDescription>Edit your company registration details</CardDescription> Edit your company registration details
</CardHeader> </Text>
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<CardContent> <Stack gap="md">
<FieldGroup className="gap-4"> <TextInput
<Field data-invalid={Boolean(errors.companyName)}> label="Company Name"
<FieldLabel>Company Name</FieldLabel> placeholder="Global Logistics Ltd"
<Input error={errors.companyName?.message}
placeholder="Global Logistics Ltd" {...register("companyName")}
aria-invalid={Boolean(errors.companyName)} />
{...register("companyName")}
<Grid>
<Grid.Col span={6}>
<TextInput
label="Company Email"
type="email"
placeholder="ops@company.com"
error={errors.companyEmail?.message}
{...register("companyEmail")}
/> />
<FieldError errors={[errors.companyName]} /> </Grid.Col>
</Field> <Grid.Col span={6}>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.companyEmail)}>
<FieldLabel>Company Email</FieldLabel>
<Input
type="email"
placeholder="ops@company.com"
aria-invalid={Boolean(errors.companyEmail)}
{...register("companyEmail")}
/>
<FieldError errors={[errors.companyEmail]} />
</Field>
<PhoneInput <PhoneInput
countryCode={{ ...register("companyPhoneCountryCode") }} countryCode={{ ...register("companyPhoneCountryCode") }}
phone={{ phone={{ ...register("companyPhone"), placeholder: "912345678" }}
...register("companyPhone"),
placeholder: "912345678",
}}
countryCodeError={errors.companyPhoneCountryCode} countryCodeError={errors.companyPhoneCountryCode}
phoneError={errors.companyPhone} phoneError={errors.companyPhone}
label="Company Phone" label="Company Phone"
/> />
</div> </Grid.Col>
</Grid>
<div className="grid grid-cols-2 gap-4"> <Grid>
<Field data-invalid={Boolean(errors.companyLocation)}> <Grid.Col span={6}>
<FieldLabel>Location</FieldLabel> <TextInput
<Input label="Location"
placeholder="Addis Ababa, Ethiopia" placeholder="Addis Ababa, Ethiopia"
aria-invalid={Boolean(errors.companyLocation)} error={errors.companyLocation?.message}
{...register("companyLocation")} {...register("companyLocation")}
/> />
<FieldError errors={[errors.companyLocation]} /> </Grid.Col>
</Field> <Grid.Col span={6}>
<TextInput
label="Address"
placeholder="Bole Subcity, Woreda 03"
error={errors.companyAddress?.message}
{...register("companyAddress")}
/>
</Grid.Col>
</Grid>
<Field data-invalid={Boolean(errors.companyAddress)}> <Grid>
<FieldLabel>Address</FieldLabel> <Grid.Col span={6}>
<Input <TextInput
placeholder="Bole Subcity, Woreda 03" label="TIN Number (10 digits)"
aria-invalid={Boolean(errors.companyAddress)} placeholder="1234567890"
{...register("companyAddress")} maxLength={10}
/> error={errors.tinNumber?.message}
<FieldError errors={[errors.companyAddress]} /> {...register("tinNumber")}
</Field> />
</div> </Grid.Col>
<Grid.Col span={6}>
<TextInput
label="FAN Number (16 digits)"
placeholder="1234567890123456"
maxLength={16}
error={errors.fanNumber?.message}
{...register("fanNumber")}
/>
</Grid.Col>
</Grid>
</Stack>
<div className="grid grid-cols-2 gap-4"> <Group
<Field data-invalid={Boolean(errors.tinNumber)}> justify="space-between"
<FieldLabel>TIN Number (10 digits)</FieldLabel> mt="xl"
<Input pt="md"
placeholder="1234567890" style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }}
maxLength={10} >
aria-invalid={Boolean(errors.tinNumber)} <Group gap="xs">
{...register("tinNumber")}
/>
<FieldError errors={[errors.tinNumber]} />
</Field>
<Field data-invalid={Boolean(errors.fanNumber)}>
<FieldLabel>FAN Number (16 digits)</FieldLabel>
<Input
placeholder="1234567890123456"
maxLength={16}
aria-invalid={Boolean(errors.fanNumber)}
{...register("fanNumber")}
/>
<FieldError errors={[errors.fanNumber]} />
</Field>
</div>
</FieldGroup>
</CardContent>
<CardFooter className="flex items-center justify-between gap-4 border-t border-border px-6 py-4">
<div className="flex items-center gap-2">
{mutation.isSuccess && ( {mutation.isSuccess && (
<span className="flex items-center gap-1.5 text-sm font-medium text-emerald-600"> <Group gap={6} c="green">
<CheckCircle2 className="size-4" /> <CheckCircle2 size={16} />
Saved successfully <Text size="sm" fw={500}>Saved successfully</Text>
</span> </Group>
)} )}
{mutation.isError && ( {mutation.isError && (
<span className="flex items-center gap-1.5 text-sm font-medium text-red-600"> <Group gap={6} c="red">
<XCircle className="size-4" /> <XCircle size={16} />
Save failed <Text size="sm" fw={500}>Save failed</Text>
</span> </Group>
)} )}
</div> </Group>
<div className="flex items-center gap-3"> <Group gap="md">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@@ -204,15 +193,15 @@ export default function TabCompanyProfile({ profile }: { profile: ProfileRespons
> >
Reset Reset
</Button> </Button>
<Button type="submit" disabled={mutation.isPending}> <Button
{mutation.isPending ? ( type="submit"
<><Loader2 className="size-4 animate-spin" /> Saving...</> leftSection={<Save size={16} />}
) : ( loading={mutation.isPending}
<><Save className="size-4" /> Save Changes</> >
)} Save Changes
</Button> </Button>
</div> </Group>
</CardFooter> </Group>
</form> </form>
</Card> </Card>
); );

View File

@@ -3,23 +3,18 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import { CheckCircle2, Loader2, Save, User, XCircle } from "lucide-react"; import { CheckCircle2, Save, User, XCircle } from "lucide-react";
import { api } from "@/services/api";
import PhoneInput from "@/components/auth/PhoneInput";
import { import {
Card, Card,
CardHeader, Group,
CardTitle, Stack,
CardDescription, Title,
CardContent, Text,
CardFooter, TextInput,
Button, Button,
Input, } from "@mantine/core";
Field, import { api } from "@/services/api";
FieldLabel, import PhoneInput from "@/components/auth/PhoneInput";
FieldError,
FieldGroup,
} from "@edr/ui-common";
import type { ProfileResponse } from "@/types/profile"; import type { ProfileResponse } from "@/types/profile";
const schema = z.object({ const schema = z.object({
@@ -73,55 +68,54 @@ export default function TabContactPerson({ profile }: { profile: ProfileResponse
const onSubmit = (data: FormData) => mutation.mutate(data); const onSubmit = (data: FormData) => mutation.mutate(data);
return ( return (
<Card> <Card padding="lg">
<CardHeader> <Group gap="sm" mb="xs">
<CardTitle className="flex items-center gap-2"> <User size={20} />
<User className="size-5 text-primary" /> <Title order={3}>Contact Person</Title>
Contact Person </Group>
</CardTitle> <Text c="edr-muted" size="sm" mb="lg">
<CardDescription>Manage the primary contact person for your account</CardDescription> Manage the primary contact person for your account
</CardHeader> </Text>
<form onSubmit={handleSubmit(onSubmit)}>
<CardContent>
<FieldGroup className="gap-4">
<Field data-invalid={Boolean(errors.contactPersonName)}>
<FieldLabel>Full Name</FieldLabel>
<Input
placeholder="Jane Smith"
aria-invalid={Boolean(errors.contactPersonName)}
{...register("contactPersonName")}
/>
<FieldError errors={[errors.contactPersonName]} />
</Field>
<PhoneInput <form onSubmit={handleSubmit(onSubmit)}>
countryCode={{ ...register("contactPersonPhoneCountryCode") }} <Stack gap="md">
phone={{ <TextInput
...register("contactPersonPhone"), label="Full Name"
placeholder: "912345678", placeholder="Jane Smith"
}} error={errors.contactPersonName?.message}
countryCodeError={errors.contactPersonPhoneCountryCode} {...register("contactPersonName")}
phoneError={errors.contactPersonPhone} />
label="Phone Number"
/> <PhoneInput
</FieldGroup> countryCode={{ ...register("contactPersonPhoneCountryCode") }}
</CardContent> phone={{ ...register("contactPersonPhone"), placeholder: "912345678" }}
<CardFooter className="flex items-center justify-between gap-4 border-t border-border px-6 py-4"> countryCodeError={errors.contactPersonPhoneCountryCode}
<div className="flex items-center gap-2"> phoneError={errors.contactPersonPhone}
label="Phone Number"
/>
</Stack>
<Group
justify="space-between"
mt="xl"
pt="md"
style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }}
>
<Group gap="xs">
{mutation.isSuccess && ( {mutation.isSuccess && (
<span className="flex items-center gap-1.5 text-sm font-medium text-emerald-600"> <Group gap={6} c="green">
<CheckCircle2 className="size-4" /> <CheckCircle2 size={16} />
Saved successfully <Text size="sm" fw={500}>Saved successfully</Text>
</span> </Group>
)} )}
{mutation.isError && ( {mutation.isError && (
<span className="flex items-center gap-1.5 text-sm font-medium text-red-600"> <Group gap={6} c="red">
<XCircle className="size-4" /> <XCircle size={16} />
Save failed <Text size="sm" fw={500}>Save failed</Text>
</span> </Group>
)} )}
</div> </Group>
<div className="flex items-center gap-3"> <Group gap="md">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@@ -130,15 +124,15 @@ export default function TabContactPerson({ profile }: { profile: ProfileResponse
> >
Reset Reset
</Button> </Button>
<Button type="submit" disabled={mutation.isPending}> <Button
{mutation.isPending ? ( type="submit"
<><Loader2 className="size-4 animate-spin" /> Saving...</> leftSection={<Save size={16} />}
) : ( loading={mutation.isPending}
<><Save className="size-4" /> Save Changes</> >
)} Save Changes
</Button> </Button>
</div> </Group>
</CardFooter> </Group>
</form> </form>
</Card> </Card>
); );

View File

@@ -7,17 +7,17 @@ import {
UploadCloud, UploadCloud,
XCircle, XCircle,
} from "lucide-react"; } from "lucide-react";
import { api } from "@/services/api";
import { companiesService } from "@/services/companies.service";
import { import {
Card, Card,
CardHeader, Group,
CardTitle, Title,
CardDescription, Text,
CardContent,
Button, Button,
SmartFileInput, Center,
} from "@edr/ui-common"; } from "@mantine/core";
import { api } from "@/services/api";
import { companiesService } from "@/services/companies.service";
import { SmartFileInput } from "@edr/ui-common";
import type { ProfileResponse } from "@/types/profile"; import type { ProfileResponse } from "@/types/profile";
export default function TabDocuments({ profile }: { profile: ProfileResponse }) { export default function TabDocuments({ profile }: { profile: ProfileResponse }) {
@@ -39,69 +39,62 @@ export default function TabDocuments({ profile }: { profile: ProfileResponse })
}); });
return ( return (
<Card> <Card padding="lg">
<CardHeader> <Group gap="sm" mb="xs">
<CardTitle className="flex items-center gap-2"> <FileCheck size={20} />
<FileCheck className="size-5 text-primary" /> <Title order={3}>Documents</Title>
Documents </Group>
</CardTitle> <Text c="edr-muted" size="sm" mb="lg">
<CardDescription> Upload and manage required business documents
Upload and manage required business documents </Text>
</CardDescription>
</CardHeader>
<CardContent>
{docSettingQuery.isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : !docSettingQuery.data ? (
<p className="text-sm text-muted-foreground text-center py-4">
No document requirements configured for your account.
</p>
) : (
<SmartFileInput
file={docSettingQuery.data}
value={documentFiles}
onChange={setDocumentFiles}
/>
)}
{docSettingQuery.data && ( {docSettingQuery.isLoading ? (
<div className="flex items-center justify-between pt-4"> <Center py="xl">
<div className="flex items-center gap-2"> <Loader2 size={24} className="animate-spin" />
{docUploadMutation.isSuccess && ( </Center>
<span className="flex items-center gap-1.5 text-sm font-medium text-emerald-600"> ) : !docSettingQuery.data ? (
<CheckCircle2 className="size-4" /> <Text c="edr-muted" size="sm" ta="center" py="md">
Documents uploaded successfully No document requirements configured for your account.
</span> </Text>
)} ) : (
{docUploadMutation.isError && ( <SmartFileInput
<span className="flex items-center gap-1.5 text-sm font-medium text-red-600"> file={docSettingQuery.data}
<XCircle className="size-4" /> value={documentFiles}
Upload failed onChange={setDocumentFiles}
</span> />
)} )}
</div>
<Button {docSettingQuery.data && (
type="button" <Group
onClick={() => docUploadMutation.mutate(documentFiles)} justify="space-between"
disabled={docUploadMutation.isPending} mt="lg"
> pt="md"
{docUploadMutation.isPending ? ( style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }}
<> >
<Loader2 className="size-4 animate-spin" /> <Group gap="xs">
Uploading... {docUploadMutation.isSuccess && (
</> <Group gap={6} c="green">
) : ( <CheckCircle2 size={16} />
<> <Text size="sm" fw={500}>Documents uploaded successfully</Text>
<UploadCloud className="size-4" /> </Group>
Upload Documents )}
</> {docUploadMutation.isError && (
)} <Group gap={6} c="red">
</Button> <XCircle size={16} />
</div> <Text size="sm" fw={500}>Upload failed</Text>
)} </Group>
</CardContent> )}
</Group>
<Button
type="button"
leftSection={<UploadCloud size={16} />}
loading={docUploadMutation.isPending}
onClick={() => docUploadMutation.mutate(documentFiles)}
>
Upload Documents
</Button>
</Group>
)}
</Card> </Card>
); );
} }

View File

@@ -3,23 +3,19 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import { Briefcase, CheckCircle2, Loader2, Save, XCircle } from "lucide-react"; import { Briefcase, CheckCircle2, Save, XCircle } from "lucide-react";
import { api } from "@/services/api";
import PhoneInput from "@/components/auth/PhoneInput";
import { import {
Card, Card,
CardHeader, Group,
CardTitle, Stack,
CardDescription, Title,
CardContent, Text,
CardFooter, TextInput,
Button, Button,
Input, Grid,
Field, } from "@mantine/core";
FieldLabel, import { api } from "@/services/api";
FieldError, import PhoneInput from "@/components/auth/PhoneInput";
FieldGroup,
} from "@edr/ui-common";
import type { ProfileResponse } from "@/types/profile"; import type { ProfileResponse } from "@/types/profile";
const schema = z.object({ const schema = z.object({
@@ -76,68 +72,67 @@ export default function TabGeneralManager({ profile }: { profile: ProfileRespons
const onSubmit = (data: FormData) => mutation.mutate(data); const onSubmit = (data: FormData) => mutation.mutate(data);
return ( return (
<Card> <Card padding="lg">
<CardHeader> <Group gap="sm" mb="xs">
<CardTitle className="flex items-center gap-2"> <Briefcase size={20} />
<Briefcase className="size-5 text-primary" /> <Title order={3}>General Manager</Title>
General Manager </Group>
</CardTitle> <Text c="edr-muted" size="sm" mb="lg">
<CardDescription>Manage the general manager information</CardDescription> Manage the general manager information
</CardHeader> </Text>
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<CardContent> <Stack gap="md">
<FieldGroup className="gap-4"> <TextInput
<Field data-invalid={Boolean(errors.generalManagerName)}> label="Full Name"
<FieldLabel>Full Name</FieldLabel> placeholder="Abebe Bikila"
<Input error={errors.generalManagerName?.message}
placeholder="Abebe Bikila" {...register("generalManagerName")}
aria-invalid={Boolean(errors.generalManagerName)} />
{...register("generalManagerName")}
<Grid>
<Grid.Col span={6}>
<TextInput
label="Email Address"
type="email"
placeholder="gm@company.com"
error={errors.generalManagerEmail?.message}
{...register("generalManagerEmail")}
/> />
<FieldError errors={[errors.generalManagerName]} /> </Grid.Col>
</Field> <Grid.Col span={6}>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.generalManagerEmail)}>
<FieldLabel>Email Address</FieldLabel>
<Input
type="email"
placeholder="gm@company.com"
aria-invalid={Boolean(errors.generalManagerEmail)}
{...register("generalManagerEmail")}
/>
<FieldError errors={[errors.generalManagerEmail]} />
</Field>
<PhoneInput <PhoneInput
countryCode={{ ...register("generalManagerPhoneCountryCode") }} countryCode={{ ...register("generalManagerPhoneCountryCode") }}
phone={{ phone={{ ...register("generalManagerPhone"), placeholder: "912345678" }}
...register("generalManagerPhone"),
placeholder: "912345678",
}}
countryCodeError={errors.generalManagerPhoneCountryCode} countryCodeError={errors.generalManagerPhoneCountryCode}
phoneError={errors.generalManagerPhone} phoneError={errors.generalManagerPhone}
label="Phone Number" label="Phone Number"
/> />
</div> </Grid.Col>
</FieldGroup> </Grid>
</CardContent> </Stack>
<CardFooter className="flex items-center justify-between gap-4 border-t border-border px-6 py-4">
<div className="flex items-center gap-2"> <Group
justify="space-between"
mt="xl"
pt="md"
style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }}
>
<Group gap="xs">
{mutation.isSuccess && ( {mutation.isSuccess && (
<span className="flex items-center gap-1.5 text-sm font-medium text-emerald-600"> <Group gap={6} c="green">
<CheckCircle2 className="size-4" /> <CheckCircle2 size={16} />
Saved successfully <Text size="sm" fw={500}>Saved successfully</Text>
</span> </Group>
)} )}
{mutation.isError && ( {mutation.isError && (
<span className="flex items-center gap-1.5 text-sm font-medium text-red-600"> <Group gap={6} c="red">
<XCircle className="size-4" /> <XCircle size={16} />
Save failed <Text size="sm" fw={500}>Save failed</Text>
</span> </Group>
)} )}
</div> </Group>
<div className="flex items-center gap-3"> <Group gap="md">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@@ -146,15 +141,15 @@ export default function TabGeneralManager({ profile }: { profile: ProfileRespons
> >
Reset Reset
</Button> </Button>
<Button type="submit" disabled={mutation.isPending}> <Button
{mutation.isPending ? ( type="submit"
<><Loader2 className="size-4 animate-spin" /> Saving...</> leftSection={<Save size={16} />}
) : ( loading={mutation.isPending}
<><Save className="size-4" /> Save Changes</> >
)} Save Changes
</Button> </Button>
</div> </Group>
</CardFooter> </Group>
</form> </form>
</Card> </Card>
); );

View File

@@ -4,22 +4,18 @@ import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import { CheckCircle2, Loader2, Save, UserCheck, XCircle } from "lucide-react"; import { CheckCircle2, Loader2, Save, UserCheck, XCircle } from "lucide-react";
import { api } from "@/services/api";
import PhoneInput from "@/components/auth/PhoneInput";
import { import {
Card, Card,
CardHeader, Group,
CardTitle, Stack,
CardDescription, Title,
CardContent, Text,
CardFooter, TextInput,
Button, Button,
Input, Grid,
Field, } from "@mantine/core";
FieldLabel, import { api } from "@/services/api";
FieldError, import PhoneInput from "@/components/auth/PhoneInput";
FieldGroup,
} from "@edr/ui-common";
import type { ProfileResponse } from "@/types/profile"; import type { ProfileResponse } from "@/types/profile";
const schema = z.object({ const schema = z.object({
@@ -66,7 +62,6 @@ export default function TabPowerOfAttorney({
formState: { errors, isDirty }, formState: { errors, isDirty },
} = useForm<FormData>({ } = useForm<FormData>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
values: defaultValues, values: defaultValues,
}); });
@@ -92,47 +87,41 @@ export default function TabPowerOfAttorney({
const onSubmit = (data: FormData) => mutation.mutate(data); const onSubmit = (data: FormData) => mutation.mutate(data);
return ( return (
<Card> <Card padding="lg">
<CardHeader> <Group gap="sm" mb="xs">
<CardTitle className="flex items-center gap-2"> <UserCheck size={20} />
<UserCheck className="size-5 text-accent" /> <Title order={3}>Power of Attorney</Title>
Power of Attorney </Group>
</CardTitle> <Text c="edr-muted" size="sm" mb="lg">
<CardDescription> Power of Attorney details are optional. Fill them in if you have an
Power of Attorney details are optional. Fill them in if you have an authorized representative, or leave blank.
authorized representative, or leave blank. </Text>
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<CardContent> <Stack gap="md">
<FieldGroup className="gap-4"> <Text c="edr-muted" size="sm">
<p className="text-sm text-muted-foreground"> Power of Attorney details are optional. Fill them in if you have
Power of Attorney details are optional. Fill them in if you have an authorized representative, or leave blank.
an authorized representative, or leave blank. </Text>
</p>
<Field data-invalid={Boolean(errors.poaName)}> <TextInput
<FieldLabel>PoA Full Name</FieldLabel> label="PoA Full Name"
<Input placeholder="Authorized Representative Name"
placeholder="Authorized Representative Name" error={errors.poaName?.message}
aria-invalid={Boolean(errors.poaName)} {...register("poaName")}
{...register("poaName")} />
<Grid>
<Grid.Col span={6}>
<TextInput
label="PoA Email"
type="email"
placeholder="poa@company.com"
error={errors.poaEmail?.message}
{...register("poaEmail")}
/> />
<FieldError errors={[errors.poaName]} /> </Grid.Col>
</Field> <Grid.Col span={6}>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.poaEmail)}>
<FieldLabel>PoA Email</FieldLabel>
<Input
type="email"
placeholder="poa@company.com"
aria-invalid={Boolean(errors.poaEmail)}
{...register("poaEmail")}
/>
<FieldError errors={[errors.poaEmail]} />
</Field>
<PhoneInput <PhoneInput
countryCode={{ ...register("poaPhoneCountryCode") }} countryCode={{ ...register("poaPhoneCountryCode") }}
phone={{ ...register("poaPhone"), placeholder: "912345678" }} phone={{ ...register("poaPhone"), placeholder: "912345678" }}
@@ -140,47 +129,50 @@ export default function TabPowerOfAttorney({
countryCodeError={errors.poaPhoneCountryCode} countryCodeError={errors.poaPhoneCountryCode}
phoneError={errors.poaPhone} phoneError={errors.poaPhone}
/> />
</div> </Grid.Col>
</Grid>
<div className="grid grid-cols-2 gap-4"> <Grid>
<Field data-invalid={Boolean(errors.poaLocation)}> <Grid.Col span={6}>
<FieldLabel>PoA Location</FieldLabel> <TextInput
<Input label="PoA Location"
placeholder="City, Country" placeholder="City, Country"
aria-invalid={Boolean(errors.poaLocation)} error={errors.poaLocation?.message}
{...register("poaLocation")} {...register("poaLocation")}
/> />
<FieldError errors={[errors.poaLocation]} /> </Grid.Col>
</Field> <Grid.Col span={6}>
<TextInput
label="PoA Address"
placeholder="Full Address"
error={errors.poaAddress?.message}
{...register("poaAddress")}
/>
</Grid.Col>
</Grid>
</Stack>
<Field data-invalid={Boolean(errors.poaAddress)}> <Group
<FieldLabel>PoA Address</FieldLabel> justify="space-between"
<Input mt="xl"
placeholder="Full Address" pt="md"
aria-invalid={Boolean(errors.poaAddress)} style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }}
{...register("poaAddress")} >
/> <Group gap="xs">
<FieldError errors={[errors.poaAddress]} />
</Field>
</div>
</FieldGroup>
</CardContent>
<CardFooter className="flex items-center justify-between gap-4 border-t border-border px-6 py-4">
<div className="flex items-center gap-2">
{mutation.isSuccess && ( {mutation.isSuccess && (
<span className="flex items-center gap-1.5 text-sm font-medium text-emerald-600"> <Group gap={6} c="green">
<CheckCircle2 className="size-4" /> <CheckCircle2 size={16} />
Saved successfully <Text size="sm" fw={500}>Saved successfully</Text>
</span> </Group>
)} )}
{mutation.isError && ( {mutation.isError && (
<span className="flex items-center gap-1.5 text-sm font-medium text-red-600"> <Group gap={6} c="red">
<XCircle className="size-4" /> <XCircle size={16} />
Save failed <Text size="sm" fw={500}>Save failed</Text>
</span> </Group>
)} )}
</div> </Group>
<div className="flex items-center gap-3"> <Group gap="md">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@@ -189,19 +181,15 @@ export default function TabPowerOfAttorney({
> >
Reset Reset
</Button> </Button>
<Button type="submit" disabled={mutation.isPending}> <Button
{mutation.isPending ? ( type="submit"
<> leftSection={<Save size={16} />}
<Loader2 className="size-4 animate-spin" /> Saving... loading={mutation.isPending}
</> >
) : ( Save Changes
<>
<Save className="size-4" /> Save Changes
</>
)}
</Button> </Button>
</div> </Group>
</CardFooter> </Group>
</form> </form>
</Card> </Card>
); );

View File

@@ -1,10 +0,0 @@
export type UserTypeRequest {
email: string;
username: string;
phoneNumber: string;
userType: string;
name: {
am?: string;
en: string;
};
}