mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
446 lines
17 KiB
TypeScript
446 lines
17 KiB
TypeScript
import { type StatusTone } from '@ema-platform/shared';
|
|
import { useEffect, useRef, useState } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { useApiMutation } from '@ema-platform/api';
|
|
import {
|
|
Alert,
|
|
Box,
|
|
Button,
|
|
Card,
|
|
Divider,
|
|
FileButton,
|
|
Group,
|
|
Modal,
|
|
Paper,
|
|
Select,
|
|
SimpleGrid,
|
|
Stack,
|
|
Text,
|
|
Textarea,
|
|
TextInput,
|
|
ThemeIcon,
|
|
Title,
|
|
rem,
|
|
} from '@mantine/core';
|
|
import {
|
|
IconAlertCircle,
|
|
IconArrowRight,
|
|
IconCircleCheck,
|
|
IconFileDescription,
|
|
IconInfoCircle,
|
|
IconTransferIn,
|
|
IconUser,
|
|
} from '@tabler/icons-react';
|
|
import { StatusBadge, notify, PhoneInput } from '@ema-platform/ui';
|
|
import { isValidPhoneNumber } from 'libphonenumber-js';
|
|
|
|
// Minimal vessel type for the approved vessel list
|
|
interface ApprovedVessel {
|
|
id: string;
|
|
vesselName: string;
|
|
category: string;
|
|
vesselType: string;
|
|
ownerName: string;
|
|
ownerNationalIdOrTin: string;
|
|
ownerPhone: string;
|
|
status: string;
|
|
}
|
|
|
|
const MOCK_APPROVED_VESSELS: ApprovedVessel[] = [
|
|
{
|
|
id: 'VR-2024-001',
|
|
vesselName: 'Lake Tana Star',
|
|
category: 'Inland Waterway Vessel',
|
|
vesselType: 'Passenger Ferry',
|
|
ownerName: 'Abebe Girma',
|
|
ownerNationalIdOrTin: 'ET-9812345',
|
|
ownerPhone: '+251 911 234 567',
|
|
status: 'Approved',
|
|
},
|
|
];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
export type TransferStatus = 'Pending' | 'Under Review' | 'Approved' | 'Rejected';
|
|
|
|
export interface OwnershipTransferRequest {
|
|
id: string;
|
|
vesselId: string;
|
|
vesselName: string;
|
|
category: string;
|
|
vesselType: string;
|
|
currentOwnerName: string;
|
|
currentOwnerIdOrTin: string;
|
|
currentOwnerPhone: string;
|
|
newOwnerName: string;
|
|
newOwnerIdOrTin: string;
|
|
newOwnerPhone: string;
|
|
newOwnerEmail: string;
|
|
newOwnerAddress: string;
|
|
transferReason: string;
|
|
remarks: string;
|
|
status: TransferStatus;
|
|
submittedDate: string;
|
|
approvalDate: string | null;
|
|
}
|
|
|
|
export const MOCK_TRANSFER_REQUESTS: OwnershipTransferRequest[] = [
|
|
{
|
|
id: 'OT-2024-001',
|
|
vesselId: 'VR-2024-001',
|
|
vesselName: 'Lake Tana Star',
|
|
category: 'Inland Waterway Vessel',
|
|
vesselType: 'Passenger Ferry',
|
|
currentOwnerName: 'Abebe Girma',
|
|
currentOwnerIdOrTin: 'ET-9812345',
|
|
currentOwnerPhone: '+251 911 234 567',
|
|
newOwnerName: 'Tigist Haile',
|
|
newOwnerIdOrTin: 'ET-7743210',
|
|
newOwnerPhone: '+251 922 876 543',
|
|
newOwnerEmail: 'tigist.haile@email.com',
|
|
newOwnerAddress: 'Bahir Dar, Amhara Region',
|
|
transferReason: 'Sale',
|
|
remarks: 'Vessel sold to new owner. Bill of sale attached.',
|
|
status: 'Pending',
|
|
submittedDate: '2024-06-01',
|
|
approvalDate: null,
|
|
},
|
|
];
|
|
|
|
const TRANSFER_REASONS = [
|
|
'Sale / Purchase',
|
|
'Inheritance',
|
|
'Gift / Donation',
|
|
'Corporate Restructuring',
|
|
'Court Order',
|
|
'Other',
|
|
];
|
|
|
|
const STATUS_TONE: Record<string, StatusTone> = {
|
|
Pending: 'neutral',
|
|
'Under Review': 'warning',
|
|
Approved: 'success',
|
|
Rejected: 'danger',
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Transfer request card
|
|
// ---------------------------------------------------------------------------
|
|
function TransferCard({ req }: { req: OwnershipTransferRequest }) {
|
|
return (
|
|
<Card withBorder radius="md" p="md">
|
|
<Group justify="space-between" mb="xs">
|
|
<Group gap="sm">
|
|
<ThemeIcon size={36} radius="md" color="violet" variant="light">
|
|
<IconTransferIn size={20} />
|
|
</ThemeIcon>
|
|
<div>
|
|
<Text fw={700} fz="sm">{req.vesselName}</Text>
|
|
<Text fz="xs" c="dimmed">{req.id} · Transfer to {req.newOwnerName}</Text>
|
|
</div>
|
|
</Group>
|
|
<StatusBadge
|
|
tone={STATUS_TONE[req.status] ?? 'neutral'}
|
|
label={req.status}
|
|
variant="light"
|
|
/>
|
|
</Group>
|
|
<Divider my="xs" />
|
|
<SimpleGrid cols={2} spacing="xs">
|
|
{[
|
|
{ label: 'From', value: req.currentOwnerName },
|
|
{ label: 'To', value: req.newOwnerName },
|
|
{ label: 'Reason', value: req.transferReason },
|
|
{ label: 'Submitted', value: req.submittedDate },
|
|
].map((r) => (
|
|
<div key={r.label}>
|
|
<Text fz="xs" c="dimmed">{r.label}</Text>
|
|
<Text fz="sm" fw={500}>{r.value}</Text>
|
|
</div>
|
|
))}
|
|
</SimpleGrid>
|
|
{req.status === 'Approved' && (
|
|
<Alert icon={<IconCircleCheck size={14} />} color="teal" mt="sm" py="xs">
|
|
Transfer approved on {req.approvalDate}. New certificates issued to {req.newOwnerName}.
|
|
</Alert>
|
|
)}
|
|
{req.status === 'Rejected' && req.remarks && (
|
|
<Alert icon={<IconAlertCircle size={14} />} color="red" mt="sm" py="xs">
|
|
Rejected: {req.remarks}
|
|
</Alert>
|
|
)}
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main page
|
|
// ---------------------------------------------------------------------------
|
|
export function OwnershipTransferPage() {
|
|
const navigate = useNavigate();
|
|
const [myVessels, setMyVessels] = useState<ApprovedVessel[]>([]);
|
|
const [transfers, setTransfers] = useState<OwnershipTransferRequest[]>(MOCK_TRANSFER_REQUESTS);
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [fetchTrigger] = useApiMutation<ApprovedVessel[]>();
|
|
const [submitTrigger] = useApiMutation<{ id: string }>();
|
|
const fetched = useRef(false);
|
|
|
|
// Form state
|
|
const [selectedVesselId, setSelectedVesselId] = useState<string | null>(null);
|
|
const [newOwnerName, setNewOwnerName] = useState('');
|
|
const [newOwnerIdOrTin, setNewOwnerIdOrTin] = useState('');
|
|
const [newOwnerPhone, setNewOwnerPhone] = useState('');
|
|
const [newOwnerEmail, setNewOwnerEmail] = useState('');
|
|
const [newOwnerAddress, setNewOwnerAddress] = useState('');
|
|
const [transferReason, setTransferReason] = useState<string | null>(null);
|
|
const [notes, setNotes] = useState('');
|
|
const [billOfSale, setBillOfSale] = useState<File | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (fetched.current) return;
|
|
fetched.current = true;
|
|
fetchTrigger({ url: '/vessel-registrations/my', method: 'GET' })
|
|
.unwrap()
|
|
.then((data) => setMyVessels(Array.isArray(data) ? data : [data]))
|
|
.catch(() => {
|
|
// Fall back to mock approved vessels
|
|
setMyVessels(MOCK_APPROVED_VESSELS);
|
|
});
|
|
}, [fetchTrigger]);
|
|
|
|
const selectedVessel = myVessels.find((v) => v.id === selectedVesselId) ?? null;
|
|
|
|
const canSubmit = !!selectedVesselId && !!newOwnerName.trim() && !!newOwnerIdOrTin.trim() &&
|
|
isValidPhoneNumber(newOwnerPhone) && !!transferReason && !!billOfSale;
|
|
|
|
const resetForm = () => {
|
|
setSelectedVesselId(null);
|
|
setNewOwnerName('');
|
|
setNewOwnerIdOrTin('');
|
|
setNewOwnerPhone('');
|
|
setNewOwnerEmail('');
|
|
setNewOwnerAddress('');
|
|
setTransferReason(null);
|
|
setNotes('');
|
|
setBillOfSale(null);
|
|
};
|
|
|
|
const handleSubmit = async () => {
|
|
if (!selectedVessel) return;
|
|
setSubmitting(true);
|
|
try {
|
|
await submitTrigger({
|
|
url: '/vessel-ownership-transfers',
|
|
method: 'POST',
|
|
body: {
|
|
vesselId: selectedVessel.id,
|
|
newOwnerName, newOwnerIdOrTin, newOwnerPhone, newOwnerEmail,
|
|
newOwnerAddress, transferReason, notes,
|
|
},
|
|
}).unwrap();
|
|
|
|
// Optimistic local update
|
|
const newReq: OwnershipTransferRequest = {
|
|
id: `OT-${Date.now()}`,
|
|
vesselId: selectedVessel.id,
|
|
vesselName: selectedVessel.vesselName,
|
|
category: selectedVessel.category,
|
|
vesselType: selectedVessel.vesselType,
|
|
currentOwnerName: selectedVessel.ownerName,
|
|
currentOwnerIdOrTin: selectedVessel.ownerNationalIdOrTin,
|
|
currentOwnerPhone: selectedVessel.ownerPhone,
|
|
newOwnerName, newOwnerIdOrTin, newOwnerPhone, newOwnerEmail, newOwnerAddress,
|
|
transferReason: transferReason ?? '',
|
|
remarks: notes,
|
|
status: 'Pending',
|
|
submittedDate: new Date().toISOString().split('T')[0],
|
|
approvalDate: null,
|
|
};
|
|
setTransfers((prev) => [newReq, ...prev]);
|
|
resetForm();
|
|
setModalOpen(false);
|
|
notify.success('Ownership transfer request submitted successfully.');
|
|
} catch {
|
|
// Still add optimistically on API error (mock mode)
|
|
const newReq: OwnershipTransferRequest = {
|
|
id: `OT-${Date.now()}`,
|
|
vesselId: selectedVessel.id,
|
|
vesselName: selectedVessel.vesselName,
|
|
category: selectedVessel.category,
|
|
vesselType: selectedVessel.vesselType,
|
|
currentOwnerName: selectedVessel.ownerName,
|
|
currentOwnerIdOrTin: selectedVessel.ownerNationalIdOrTin,
|
|
currentOwnerPhone: selectedVessel.ownerPhone,
|
|
newOwnerName, newOwnerIdOrTin, newOwnerPhone, newOwnerEmail, newOwnerAddress,
|
|
transferReason: transferReason ?? '',
|
|
remarks: notes,
|
|
status: 'Pending',
|
|
submittedDate: new Date().toISOString().split('T')[0],
|
|
approvalDate: null,
|
|
};
|
|
setTransfers((prev) => [newReq, ...prev]);
|
|
resetForm();
|
|
setModalOpen(false);
|
|
notify.success('Ownership transfer request submitted.');
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const vesselOptions = myVessels
|
|
.filter((v) => v.status === 'Approved')
|
|
.map((v) => ({ value: v.id, label: `${v.vesselName} (${v.id})` }));
|
|
|
|
return (
|
|
<Stack gap="md">
|
|
<Group justify="space-between">
|
|
<Group gap="sm">
|
|
<ThemeIcon size={44} radius="md" color="violet" variant="light">
|
|
<IconTransferIn size={24} />
|
|
</ThemeIcon>
|
|
<div>
|
|
<Title order={3}>Ownership Transfer</Title>
|
|
<Text fz="sm" c="dimmed">Request transfer of vessel ownership to another party</Text>
|
|
</div>
|
|
</Group>
|
|
<Button
|
|
leftSection={<IconTransferIn size={16} />}
|
|
color="violet"
|
|
onClick={() => setModalOpen(true)}
|
|
disabled={vesselOptions.length === 0}
|
|
>
|
|
Request Transfer
|
|
</Button>
|
|
</Group>
|
|
|
|
{vesselOptions.length === 0 && (
|
|
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
|
|
You must have at least one <strong>approved</strong> vessel registration to request an ownership transfer.{' '}
|
|
<Text span fz="sm" c="blue.6" style={{ cursor: 'pointer' }} onClick={() => navigate('/vessel-registration')}>
|
|
View my registrations →
|
|
</Text>
|
|
</Alert>
|
|
)}
|
|
|
|
{/* How it works */}
|
|
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-violet-light)">
|
|
<Group gap="xs" mb="sm">
|
|
<IconInfoCircle size={16} color="var(--mantine-color-violet-7)" />
|
|
<Text fw={600} fz="sm" c="violet.7">How Ownership Transfer Works</Text>
|
|
</Group>
|
|
<Stack gap={6}>
|
|
{[
|
|
"Submit a transfer request with the new owner's details and a Bill of Sale",
|
|
'The Maritime Authority reviews and verifies the transfer documents',
|
|
'Upon approval, ownership is officially transferred in the registry',
|
|
'New certificates are automatically generated for the new owner',
|
|
'The new owner receives: Certificate of Nationality, Certificate of Ownership, Certificate of Registration (sea-going) or Inland Registration Certificate (inland)',
|
|
].map((step, i) => (
|
|
<Group key={i} gap="xs" align="flex-start">
|
|
<ThemeIcon size={20} radius="xl" color="violet" variant="light" style={{ flexShrink: 0, marginTop: 2 }}>
|
|
<Text fz="xs" fw={700}>{i + 1}</Text>
|
|
</ThemeIcon>
|
|
<Text fz="sm">{step}</Text>
|
|
</Group>
|
|
))}
|
|
</Stack>
|
|
</Paper>
|
|
|
|
{/* Existing transfer requests */}
|
|
<div>
|
|
<Text fw={700} fz="sm" mb="sm">My Transfer Requests</Text>
|
|
{transfers.length === 0 ? (
|
|
<Paper withBorder radius="md" p="xl">
|
|
<Text fz="sm" c="dimmed" ta="center">No transfer requests submitted yet.</Text>
|
|
</Paper>
|
|
) : (
|
|
<Stack gap="sm">
|
|
{transfers.map((req) => <TransferCard key={req.id} req={req} />)}
|
|
</Stack>
|
|
)}
|
|
</div>
|
|
|
|
{/* Transfer request modal */}
|
|
<Modal
|
|
opened={modalOpen}
|
|
onClose={() => { setModalOpen(false); resetForm(); }}
|
|
title="Request Ownership Transfer"
|
|
size="lg"
|
|
>
|
|
<Stack gap="md">
|
|
<Alert icon={<IconAlertCircle size={15} />} color="orange" variant="light">
|
|
Ownership transfer is permanent. Ensure all details are correct before submitting.
|
|
</Alert>
|
|
|
|
<Select
|
|
label="Select Vessel"
|
|
placeholder="Choose an approved vessel"
|
|
required
|
|
data={vesselOptions}
|
|
value={selectedVesselId}
|
|
onChange={setSelectedVesselId}
|
|
/>
|
|
|
|
{selectedVessel && (
|
|
<Paper withBorder radius="sm" p="sm" bg="var(--mantine-color-gray-0)">
|
|
<Text fz="xs" fw={700} tt="uppercase" c="dimmed" mb={4}>Current Owner</Text>
|
|
<SimpleGrid cols={2} spacing="xs">
|
|
<div><Text fz="xs" c="dimmed">Name</Text><Text fz="sm">{selectedVessel.ownerName}</Text></div>
|
|
<div><Text fz="xs" c="dimmed">ID / TIN</Text><Text fz="sm">{selectedVessel.ownerNationalIdOrTin}</Text></div>
|
|
</SimpleGrid>
|
|
</Paper>
|
|
)}
|
|
|
|
<Divider label="New Owner Details" labelPosition="center" />
|
|
|
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
|
<TextInput label="New Owner Full Name / Company" placeholder="e.g. Tigist Haile" required value={newOwnerName} onChange={(e) => setNewOwnerName(e.currentTarget.value)} leftSection={<IconUser size={15} />} />
|
|
<TextInput label="National ID / TIN" placeholder="e.g. ET-0000000" required value={newOwnerIdOrTin} onChange={(e) => setNewOwnerIdOrTin(e.currentTarget.value)} />
|
|
<PhoneInput label="Phone Number" required value={newOwnerPhone} onChange={setNewOwnerPhone} />
|
|
<TextInput label="Email Address" placeholder="owner@example.com" value={newOwnerEmail} onChange={(e) => setNewOwnerEmail(e.currentTarget.value)} />
|
|
<TextInput label="Address" placeholder="City, Region" value={newOwnerAddress} onChange={(e) => setNewOwnerAddress(e.currentTarget.value)} />
|
|
<Select label="Reason for Transfer" placeholder="Select reason" required data={TRANSFER_REASONS} value={transferReason} onChange={setTransferReason} />
|
|
</SimpleGrid>
|
|
|
|
<Divider label="Supporting Document" labelPosition="center" />
|
|
|
|
{/* Bill of Sale upload */}
|
|
<Card withBorder radius="md" p="md" style={{ borderStyle: 'dashed', borderColor: billOfSale ? 'var(--mantine-color-teal-5)' : 'var(--mantine-color-default-border)' }}>
|
|
<Group gap="sm" mb="sm" wrap="nowrap">
|
|
<Box style={{ width: rem(44), height: rem(44), borderRadius: rem(8), background: 'var(--mantine-color-violet-light)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
|
<IconFileDescription size={22} color="var(--mantine-color-violet-6)" />
|
|
</Box>
|
|
<div>
|
|
<Text fw={600} fz="sm">Bill of Sale / Transfer Document <Text span c="red">*</Text></Text>
|
|
<Text fz="xs" c="dimmed">Legal document confirming the transfer of ownership</Text>
|
|
</div>
|
|
</Group>
|
|
{billOfSale ? (
|
|
<Group gap="xs">
|
|
<IconCircleCheck size={16} color="var(--mantine-color-teal-6)" />
|
|
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{billOfSale.name}</Text>
|
|
<Button size="xs" variant="subtle" color="red" onClick={() => setBillOfSale(null)}>Remove</Button>
|
|
</Group>
|
|
) : (
|
|
<FileButton onChange={setBillOfSale} accept="application/pdf,image/jpeg,image/png">
|
|
{(props) => <Button size="xs" variant="default" {...props}>Choose File</Button>}
|
|
</FileButton>
|
|
)}
|
|
</Card>
|
|
|
|
<Textarea label="Additional Notes" placeholder="Any additional information for the authority..." value={notes} onChange={(e) => setNotes(e.currentTarget.value)} rows={3} />
|
|
|
|
<Group justify="flex-end" mt="sm">
|
|
<Button variant="default" onClick={() => { setModalOpen(false); resetForm(); }}>Cancel</Button>
|
|
<Button color="violet" disabled={!canSubmit} loading={submitting} leftSection={<IconArrowRight size={15} />} onClick={handleSubmit}>
|
|
Submit Transfer Request
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Modal>
|
|
</Stack>
|
|
);
|
|
}
|