mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
220 lines
7.1 KiB
TypeScript
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>
|
|
);
|
|
}
|