Files
edr-platform/apps/edr-freight-web/portal/src/pages/settings/TabOwner.tsx

220 lines
7.1 KiB
TypeScript

import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { AlertTriangle, Briefcase, Save } from "lucide-react";
import {
Alert,
Button,
Card,
Grid,
Group,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { api } from "@/services/api";
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
import SourcedField from "@/pages/accounts/companyProfileForm/SourcedField";
import type { ProfileResponse } from "@/types/profile";
// Optional, not unrequired: whatever a Fayda verification supplied is owned by
// the API and never typed here, so a blanket `min(1)` would fail a form that is
// correct. Presence is gated below, where the identity state says which fields
// are actually on screen; zod only polices format.
const schema = z.object({
ownerName: z.string().optional(),
ownerEmail: z
.string()
.optional()
.refine(
(v) => !v || z.string().email().safeParse(v).success,
"Invalid owner email",
),
ownerPhone: z
.string()
.optional()
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
});
type FormData = z.infer<typeof schema>;
interface TabOwnerProps {
profile: ProfileResponse;
mode?: "edit" | "onboarding";
onContinue?: () => void;
}
/**
* The company's owner — meaning whoever the eTrade licence names as the
* business's manager. Not necessarily the legal owner, but the person the
* record has to match: the backoffice's check is that comparison.
*
* Their identity is Fayda-verified only when the company has NO Power of
* Attorney; when it names a representative it is the representative who
* verifies, and the owner's details are simply recorded (from eTrade, or typed
* here). Either way all three are required.
*/
export default function TabOwner({
profile,
mode = "edit",
onContinue,
}: TabOwnerProps) {
const queryClient = useQueryClient();
const identity = profile.identity;
const owner = identity?.owner;
// Only the person the declaration points at carries the verification, so the
// panel is offered here only when that person is the owner.
const ownerIsSubject = identity?.subject === "owner";
// A Fayda verification owns what its claims filled — the API refuses to
// overwrite those, so they show read-only. Anything it left blank stays
// editable here, whatever value is currently stored.
const ownerVerified = owner?.verified ?? false;
const ownerLocked = {
name: ownerVerified && Boolean(owner?.name?.trim()),
email: ownerVerified && Boolean(owner?.email?.trim()),
phone: ownerVerified && Boolean(owner?.phone?.trim()),
};
const {
register,
control,
handleSubmit,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
ownerName: profile.ownerName ?? "",
ownerEmail: profile.ownerEmail ?? "",
ownerPhone: profile.ownerPhone ?? "",
},
});
const mutation = useMutation({
mutationFn: (data: FormData) =>
api.companies.updateProfile.call({
// `|| undefined`, never "": the DTO's `@IsOptional()` only skips null
// and undefined, so an empty string is validated and 400s with
// "ownerEmail must be an email". A verified owner legitimately leaves
// the fields Fayda did supply blank here.
ownerName: data.ownerName || undefined,
ownerEmail: data.ownerEmail || undefined,
ownerPhone: data.ownerPhone || undefined,
}),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(),
});
if (mode === "onboarding") onContinue?.();
},
});
// What the verification did NOT supply. Fayda's email and phone claims are
// optional, so a verified owner can still be missing details the API demands
// — the API leaves exactly those keys typeable, and so does this form.
const gaps = {
name: !owner?.name?.trim(),
email: !owner?.email?.trim(),
phone: !owner?.phone?.trim(),
};
const savable = gaps.name || gaps.email || gaps.phone;
return (
<Card padding="lg">
<Group gap="sm" mb="xs">
<Briefcase size={20} />
<Title order={3}>Company Owner</Title>
</Group>
<Text c="edr-muted" size="sm" mb="lg">
The person registered on your eTrade licence.
</Text>
<form onSubmit={handleSubmit((data) => mutation.mutate(data))}>
<Stack gap="md">
{identity?.ownerMatchesEtrade === false && (
<Alert
color="amber"
variant="light"
icon={<AlertTriangle size={18} />}
title="This doesn't match your eTrade licence"
>
Your licence lists <strong>{identity.etradeManagerName}</strong>.
Our team checks this before approving changes.
</Alert>
)}
{ownerIsSubject && owner && (
<FaydaVerifyPanel
subject="owner"
title="Owner identity"
state={owner}
required={!identity?.passportAccepted}
pendingReview={profile.reviewStatus === "pending"}
/>
)}
<SourcedField
label="Name"
value={owner?.name}
source={ownerLocked.name ? "Fayda" : null}
>
<TextInput
label="Name"
placeholder="Abebe Bikila"
error={errors.ownerName?.message}
{...register("ownerName")}
/>
</SourcedField>
<Grid>
<Grid.Col span={{ base: 12, sm: 6 }}>
<SourcedField
label="Email"
value={owner?.email}
source={ownerLocked.email ? "Fayda" : null}
>
<TextInput
label="Email"
type="email"
placeholder="owner@company.com"
error={errors.ownerEmail?.message}
{...register("ownerEmail")}
/>
</SourcedField>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6 }}>
<SourcedField
label="Phone"
value={owner?.phone}
source={ownerLocked.phone ? "Fayda" : null}
>
<ControlledPhoneField
control={control}
name="ownerPhone"
label="Phone"
/>
</SourcedField>
</Grid.Col>
</Grid>
{savable && (
<Group justify="flex-end">
<Button
type="submit"
color="edr-green"
loading={mutation.isPending}
leftSection={<Save size={16} />}
>
{mode === "onboarding" ? "Save & continue" : "Save changes"}
</Button>
</Group>
)}
</Stack>
</form>
</Card>
);
}