feat(ui): company detail page with header, tabs, members, owned yachts
This commit is contained in:
@@ -0,0 +1,16 @@
|
|||||||
|
import { CompanyDetail } from '@/components/companies/company-detail';
|
||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
|
||||||
|
interface CompanyDetailPageProps {
|
||||||
|
params: Promise<{ companyId: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CompanyDetailPage({ params }: CompanyDetailPageProps) {
|
||||||
|
const { companyId } = await params;
|
||||||
|
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
const currentUserId = session?.user?.id;
|
||||||
|
|
||||||
|
return <CompanyDetail companyId={companyId} currentUserId={currentUserId} />;
|
||||||
|
}
|
||||||
153
src/components/companies/company-detail-header.tsx
Normal file
153
src/components/companies/company-detail-header.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Pencil, Archive } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
|
||||||
|
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||||
|
import { CompanyForm } from '@/components/companies/company-form';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
|
||||||
|
interface CompanyDetailHeaderCompany {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
legalName: string | null;
|
||||||
|
taxId: string | null;
|
||||||
|
registrationNumber: string | null;
|
||||||
|
incorporationCountry: string | null;
|
||||||
|
incorporationDate: string | null;
|
||||||
|
status: string;
|
||||||
|
billingEmail: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
archivedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompanyDetailHeaderProps {
|
||||||
|
company: CompanyDetailHeaderCompany;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
active: 'bg-green-100 text-green-800 border-green-300',
|
||||||
|
dissolved: 'bg-red-100 text-red-800 border-red-300',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
active: 'Active',
|
||||||
|
dissolved: 'Dissolved',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompanyDetailHeader({ company }: CompanyDetailHeaderProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams<{ portSlug: string }>();
|
||||||
|
const portSlug = params?.portSlug ?? '';
|
||||||
|
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [archiveOpen, setArchiveOpen] = useState(false);
|
||||||
|
|
||||||
|
const isArchived = !!company.archivedAt;
|
||||||
|
const showLegalName = company.legalName && company.legalName !== company.name;
|
||||||
|
|
||||||
|
const archiveMutation = useMutation({
|
||||||
|
mutationFn: () => apiFetch(`/api/v1/companies/${company.id}`, { method: 'DELETE' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['companies', company.id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['companies'] });
|
||||||
|
toast.success('Company archived');
|
||||||
|
setArchiveOpen(false);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
router.push(`/${portSlug}/companies` as any);
|
||||||
|
},
|
||||||
|
onError: (err: Error) => {
|
||||||
|
toast.error(err.message || 'Failed to archive company');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusLabel = STATUS_LABELS[company.status] ?? company.status;
|
||||||
|
const statusColor =
|
||||||
|
STATUS_COLORS[company.status] ?? 'bg-muted text-muted-foreground border-muted';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-start gap-3 flex-wrap">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<h1 className="text-2xl font-bold text-foreground truncate">{company.name}</h1>
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium ${statusColor}`}
|
||||||
|
>
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
|
{isArchived && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
Archived
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1 space-y-0.5 text-sm text-muted-foreground">
|
||||||
|
{showLegalName && <p>{company.legalName}</p>}
|
||||||
|
{company.taxId && <p>Tax ID: {company.taxId}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<PermissionGate resource="companies" action="edit">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
|
||||||
|
<Pencil className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</PermissionGate>
|
||||||
|
<PermissionGate resource="companies" action="delete">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setArchiveOpen(true)}
|
||||||
|
disabled={isArchived}
|
||||||
|
>
|
||||||
|
<Archive className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Archive
|
||||||
|
</Button>
|
||||||
|
</PermissionGate>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CompanyForm
|
||||||
|
open={editOpen}
|
||||||
|
onOpenChange={setEditOpen}
|
||||||
|
company={{
|
||||||
|
id: company.id,
|
||||||
|
name: company.name,
|
||||||
|
legalName: company.legalName,
|
||||||
|
taxId: company.taxId,
|
||||||
|
registrationNumber: company.registrationNumber,
|
||||||
|
incorporationCountry: company.incorporationCountry,
|
||||||
|
incorporationDate: company.incorporationDate,
|
||||||
|
status: company.status,
|
||||||
|
billingEmail: company.billingEmail,
|
||||||
|
notes: company.notes,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ArchiveConfirmDialog
|
||||||
|
open={archiveOpen}
|
||||||
|
onOpenChange={setArchiveOpen}
|
||||||
|
entityName={company.name}
|
||||||
|
entityType="Company"
|
||||||
|
isArchived={isArchived}
|
||||||
|
onConfirm={() => {
|
||||||
|
archiveMutation.mutate();
|
||||||
|
}}
|
||||||
|
isLoading={archiveMutation.isPending}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
src/components/companies/company-detail.tsx
Normal file
62
src/components/companies/company-detail.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
|
||||||
|
import { DetailLayout } from '@/components/shared/detail-layout';
|
||||||
|
import { CompanyDetailHeader } from '@/components/companies/company-detail-header';
|
||||||
|
import { getCompanyTabs } from '@/components/companies/company-tabs';
|
||||||
|
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
|
||||||
|
export interface CompanyData {
|
||||||
|
id: string;
|
||||||
|
portId: string;
|
||||||
|
name: string;
|
||||||
|
legalName: string | null;
|
||||||
|
taxId: string | null;
|
||||||
|
registrationNumber: string | null;
|
||||||
|
incorporationCountry: string | null;
|
||||||
|
incorporationDate: string | null;
|
||||||
|
status: string;
|
||||||
|
billingEmail: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
archivedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompanyDetailProps {
|
||||||
|
companyId: string;
|
||||||
|
currentUserId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompanyDetail({ companyId, currentUserId }: CompanyDetailProps) {
|
||||||
|
const params = useParams<{ portSlug: string }>();
|
||||||
|
const portSlug = params?.portSlug ?? '';
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<CompanyData>({
|
||||||
|
queryKey: ['companies', companyId],
|
||||||
|
queryFn: () =>
|
||||||
|
apiFetch<{ data: CompanyData }>(`/api/v1/companies/${companyId}`).then((r) => r.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
useRealtimeInvalidation({
|
||||||
|
'company:updated': [['companies', companyId]],
|
||||||
|
'company:archived': [['companies', companyId]],
|
||||||
|
'company_membership:added': [['companies', companyId, 'members']],
|
||||||
|
'company_membership:updated': [['companies', companyId, 'members']],
|
||||||
|
'company_membership:ended': [['companies', companyId, 'members']],
|
||||||
|
});
|
||||||
|
|
||||||
|
const tabs = data ? getCompanyTabs({ companyId, portSlug, currentUserId, company: data }) : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DetailLayout
|
||||||
|
header={data ? <CompanyDetailHeader company={data} /> : null}
|
||||||
|
tabs={tabs}
|
||||||
|
defaultTab="overview"
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
288
src/components/companies/company-members-tab.tsx
Normal file
288
src/components/companies/company-members-tab.tsx
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Loader2, MoreHorizontal, Plus, Star, XCircle } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import { EmptyState } from '@/components/shared/empty-state';
|
||||||
|
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
|
||||||
|
interface MembershipRow {
|
||||||
|
id: string;
|
||||||
|
companyId: string;
|
||||||
|
clientId: string;
|
||||||
|
role: string;
|
||||||
|
roleDetail: string | null;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string | null;
|
||||||
|
isPrimary: boolean;
|
||||||
|
notes: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompanyMembersTabProps {
|
||||||
|
companyId: string;
|
||||||
|
portSlug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROLE_LABELS: Record<string, string> = {
|
||||||
|
director: 'Director',
|
||||||
|
officer: 'Officer',
|
||||||
|
broker: 'Broker',
|
||||||
|
representative: 'Representative',
|
||||||
|
legal_counsel: 'Legal counsel',
|
||||||
|
employee: 'Employee',
|
||||||
|
shareholder: 'Shareholder',
|
||||||
|
other: 'Other',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDate(value: string | null): string {
|
||||||
|
if (!value) return '—';
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a client's name as a link by fetching the client record.
|
||||||
|
* Memoization is handled via the TanStack Query cache, so repeat renders
|
||||||
|
* for the same clientId are free.
|
||||||
|
*/
|
||||||
|
function ClientName({ clientId, portSlug }: { clientId: string; portSlug: string }) {
|
||||||
|
const { data } = useQuery<{ fullName: string | null }>({
|
||||||
|
queryKey: ['clients', clientId, 'name-only'],
|
||||||
|
queryFn: () =>
|
||||||
|
apiFetch<{ data: { fullName: string | null } }>(`/api/v1/clients/${clientId}`).then(
|
||||||
|
(r) => r.data,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
href={`/${portSlug}/clients/${clientId}` as any}
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{data?.fullName ?? `Client ${clientId.slice(0, 8)}`}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompanyMembersTab({ companyId, portSlug }: CompanyMembersTabProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [activeOnly, setActiveOnly] = useState(true);
|
||||||
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
|
|
||||||
|
const membersKey = ['companies', companyId, 'members', { activeOnly }];
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<MembershipRow[]>({
|
||||||
|
queryKey: membersKey,
|
||||||
|
queryFn: () =>
|
||||||
|
apiFetch<{ data: MembershipRow[] }>(
|
||||||
|
`/api/v1/companies/${companyId}/members?activeOnly=${activeOnly ? 'true' : 'false'}`,
|
||||||
|
).then((r) => r.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const endMutation = useMutation({
|
||||||
|
mutationFn: (membershipId: string) =>
|
||||||
|
apiFetch(`/api/v1/companies/${companyId}/members/${membershipId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['companies', companyId, 'members'] });
|
||||||
|
toast.success('Membership ended');
|
||||||
|
},
|
||||||
|
onError: (err: Error) => {
|
||||||
|
toast.error(err.message || 'Failed to end membership');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const setPrimaryMutation = useMutation({
|
||||||
|
mutationFn: (membershipId: string) =>
|
||||||
|
apiFetch(`/api/v1/companies/${companyId}/members/${membershipId}/set-primary`, {
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['companies', companyId, 'members'] });
|
||||||
|
toast.success('Primary contact updated');
|
||||||
|
},
|
||||||
|
onError: (err: Error) => {
|
||||||
|
toast.error(err.message || 'Failed to set primary');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const members = data ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||||
|
<div className="inline-flex rounded-md border p-0.5 text-xs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveOnly(true)}
|
||||||
|
className={`px-3 py-1 rounded-sm transition-colors ${
|
||||||
|
activeOnly ? 'bg-primary text-primary-foreground' : 'text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Active
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveOnly(false)}
|
||||||
|
className={`px-3 py-1 rounded-sm transition-colors ${
|
||||||
|
!activeOnly ? 'bg-primary text-primary-foreground' : 'text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PermissionGate resource="memberships" action="manage">
|
||||||
|
<Button size="sm" onClick={() => setAddOpen(true)}>
|
||||||
|
<Plus className="mr-1.5 h-4 w-4" />
|
||||||
|
Add Member
|
||||||
|
</Button>
|
||||||
|
</PermissionGate>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : members.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title={activeOnly ? 'No active members' : 'No members yet'}
|
||||||
|
description={
|
||||||
|
activeOnly
|
||||||
|
? 'This company has no active memberships. Switch to "All" to see past members.'
|
||||||
|
: 'Add the first member to this company to get started.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Client</TableHead>
|
||||||
|
<TableHead>Role</TableHead>
|
||||||
|
<TableHead>Role Detail</TableHead>
|
||||||
|
<TableHead>Start Date</TableHead>
|
||||||
|
<TableHead>End Date</TableHead>
|
||||||
|
<TableHead>Primary</TableHead>
|
||||||
|
<TableHead className="w-[48px]"></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{members.map((m) => {
|
||||||
|
const isActive = !m.endDate;
|
||||||
|
return (
|
||||||
|
<TableRow key={m.id}>
|
||||||
|
<TableCell>
|
||||||
|
<ClientName clientId={m.clientId} portSlug={portSlug} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{ROLE_LABELS[m.role] ?? m.role}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground max-w-[240px] truncate">
|
||||||
|
{m.roleDetail ?? '—'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{formatDate(m.startDate)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{m.endDate ? (
|
||||||
|
formatDate(m.endDate)
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{m.isPrimary ? (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
Primary
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<PermissionGate resource="memberships" action="manage">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7">
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
{isActive && !m.isPrimary && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setPrimaryMutation.mutate(m.id)}
|
||||||
|
disabled={setPrimaryMutation.isPending}
|
||||||
|
>
|
||||||
|
<Star className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Set Primary
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{isActive && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => endMutation.mutate(m.id)}
|
||||||
|
disabled={endMutation.isPending}
|
||||||
|
>
|
||||||
|
<XCircle className="mr-2 h-3.5 w-3.5" />
|
||||||
|
End Membership
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</PermissionGate>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* TODO: Task 6.4 — replace this stub with the real AddMembershipDialog. */}
|
||||||
|
<Dialog open={addOpen} onOpenChange={setAddOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add Member</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
The add-membership dialog is coming in the next step. For now this is a placeholder.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setAddOpen(false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
156
src/components/companies/company-owned-yachts-tab.tsx
Normal file
156
src/components/companies/company-owned-yachts-tab.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import { EmptyState } from '@/components/shared/empty-state';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
|
||||||
|
interface OwnedYachtRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
hullNumber: string | null;
|
||||||
|
lengthFt: string | null;
|
||||||
|
widthFt: string | null;
|
||||||
|
lengthM: string | null;
|
||||||
|
widthM: string | null;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface YachtListResponse {
|
||||||
|
data: OwnedYachtRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompanyOwnedYachtsTabProps {
|
||||||
|
companyId: string;
|
||||||
|
portSlug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
active: 'bg-green-100 text-green-800 border-green-300',
|
||||||
|
retired: 'bg-gray-100 text-gray-800 border-gray-300',
|
||||||
|
sold_away: 'bg-amber-100 text-amber-800 border-amber-300',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
active: 'Active',
|
||||||
|
retired: 'Retired',
|
||||||
|
sold_away: 'Sold Away',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDimensions(y: OwnedYachtRow): string | null {
|
||||||
|
if (y.lengthFt || y.widthFt) {
|
||||||
|
const length = y.lengthFt ?? '—';
|
||||||
|
const width = y.widthFt ?? '—';
|
||||||
|
return `${length} × ${width} ft`;
|
||||||
|
}
|
||||||
|
if (y.lengthM || y.widthM) {
|
||||||
|
const length = y.lengthM ?? '—';
|
||||||
|
const width = y.widthM ?? '—';
|
||||||
|
return `${length} × ${width} m`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompanyOwnedYachtsTab({ companyId, portSlug }: CompanyOwnedYachtsTabProps) {
|
||||||
|
const { data, isLoading } = useQuery<OwnedYachtRow[]>({
|
||||||
|
queryKey: ['companies', companyId, 'owned-yachts'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
ownerType: 'company',
|
||||||
|
ownerId: companyId,
|
||||||
|
page: '1',
|
||||||
|
limit: '50',
|
||||||
|
includeArchived: 'false',
|
||||||
|
order: 'desc',
|
||||||
|
});
|
||||||
|
const res = await apiFetch<YachtListResponse>(`/api/v1/yachts?${params.toString()}`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const yachts = data ?? [];
|
||||||
|
|
||||||
|
if (yachts.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
title="No yachts owned"
|
||||||
|
description="Yachts owned by this company will appear here."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Dimensions</TableHead>
|
||||||
|
<TableHead>Hull Number</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{yachts.map((y) => {
|
||||||
|
const dims = formatDimensions(y);
|
||||||
|
const statusLabel = STATUS_LABELS[y.status] ?? y.status;
|
||||||
|
const statusColor =
|
||||||
|
STATUS_COLORS[y.status] ?? 'bg-muted text-muted-foreground border-muted';
|
||||||
|
return (
|
||||||
|
<TableRow key={y.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Link
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
href={`/${portSlug}/yachts/${y.id}` as any}
|
||||||
|
className="font-medium text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{y.name}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{dims ? (
|
||||||
|
<span className="text-sm">{dims}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{y.hullNumber ? (
|
||||||
|
<span className="text-sm">{y.hullNumber}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${statusColor}`}
|
||||||
|
>
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
167
src/components/companies/company-tabs.tsx
Normal file
167
src/components/companies/company-tabs.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { DetailTab } from '@/components/shared/detail-layout';
|
||||||
|
import { EmptyState } from '@/components/shared/empty-state';
|
||||||
|
import { CompanyMembersTab } from '@/components/companies/company-members-tab';
|
||||||
|
import { CompanyOwnedYachtsTab } from '@/components/companies/company-owned-yachts-tab';
|
||||||
|
|
||||||
|
interface CompanyTabsCompany {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
legalName: string | null;
|
||||||
|
taxId: string | null;
|
||||||
|
registrationNumber: string | null;
|
||||||
|
incorporationCountry: string | null;
|
||||||
|
incorporationDate: string | null;
|
||||||
|
status: string;
|
||||||
|
billingEmail: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompanyTabsOptions {
|
||||||
|
companyId: string;
|
||||||
|
portSlug: string;
|
||||||
|
currentUserId?: string;
|
||||||
|
company: CompanyTabsCompany;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
active: 'Active',
|
||||||
|
dissolved: 'Dissolved',
|
||||||
|
};
|
||||||
|
|
||||||
|
function InfoRow({ label, value }: { label: string; value?: string | number | null }) {
|
||||||
|
if (value === null || value === undefined || value === '') return null;
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2 py-1.5 border-b last:border-0">
|
||||||
|
<dt className="w-40 shrink-0 text-sm text-muted-foreground">{label}</dt>
|
||||||
|
<dd className="text-sm">{value}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string | null): string | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function OverviewTab({ company }: { company: CompanyTabsCompany }) {
|
||||||
|
const incorporationDate = formatDate(company.incorporationDate);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{/* Identity */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-sm font-medium mb-2">Identity</h3>
|
||||||
|
<dl>
|
||||||
|
<InfoRow label="Name" value={company.name} />
|
||||||
|
<InfoRow label="Legal Name" value={company.legalName} />
|
||||||
|
<InfoRow label="Status" value={STATUS_LABELS[company.status] ?? company.status} />
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Registration */}
|
||||||
|
{(company.taxId ||
|
||||||
|
company.registrationNumber ||
|
||||||
|
company.incorporationCountry ||
|
||||||
|
incorporationDate) && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-sm font-medium mb-2">Registration</h3>
|
||||||
|
<dl>
|
||||||
|
<InfoRow label="Tax ID" value={company.taxId} />
|
||||||
|
<InfoRow label="Registration Number" value={company.registrationNumber} />
|
||||||
|
<InfoRow label="Incorporation Country" value={company.incorporationCountry} />
|
||||||
|
<InfoRow label="Incorporation Date" value={incorporationDate} />
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Contact */}
|
||||||
|
{company.billingEmail && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-sm font-medium mb-2">Contact</h3>
|
||||||
|
<dl>
|
||||||
|
<InfoRow label="Billing Email" value={company.billingEmail} />
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
{company.notes && (
|
||||||
|
<div className="space-y-1 md:col-span-2">
|
||||||
|
<h3 className="text-sm font-medium mb-2">Notes</h3>
|
||||||
|
<p className="text-sm whitespace-pre-wrap rounded-md border bg-muted/30 p-3">
|
||||||
|
{company.notes}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCompanyTabs({
|
||||||
|
companyId,
|
||||||
|
portSlug,
|
||||||
|
// currentUserId reserved for when NotesList supports entityType='companies'.
|
||||||
|
currentUserId: _currentUserId,
|
||||||
|
company,
|
||||||
|
}: CompanyTabsOptions): DetailTab[] {
|
||||||
|
void _currentUserId;
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'overview',
|
||||||
|
label: 'Overview',
|
||||||
|
content: <OverviewTab company={company} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'members',
|
||||||
|
label: 'Members',
|
||||||
|
content: <CompanyMembersTab companyId={companyId} portSlug={portSlug} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'owned-yachts',
|
||||||
|
label: 'Owned Yachts',
|
||||||
|
content: <CompanyOwnedYachtsTab companyId={companyId} portSlug={portSlug} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'addresses',
|
||||||
|
label: 'Addresses',
|
||||||
|
// TODO: wire to future company-addresses endpoint (see company-addresses schema).
|
||||||
|
content: (
|
||||||
|
<EmptyState
|
||||||
|
title="Addresses"
|
||||||
|
description="Company addresses coming soon — the addresses endpoint is pending wiring."
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'documents',
|
||||||
|
label: 'Documents',
|
||||||
|
content: <EmptyState title="Documents" description="Coming soon" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'notes',
|
||||||
|
label: 'Notes',
|
||||||
|
// TODO: NotesList currently supports entityType 'clients' | 'interests'.
|
||||||
|
// Extend NotesList (or swap to a company-notes endpoint) in a follow-up.
|
||||||
|
content: (
|
||||||
|
<EmptyState
|
||||||
|
title="Notes"
|
||||||
|
description="Company notes coming soon — the notes endpoint is pending wiring."
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tags',
|
||||||
|
label: 'Tags',
|
||||||
|
// TODO: replace with an inline tag editor once one exists; company tags
|
||||||
|
// can be edited via the Edit form in the meantime.
|
||||||
|
content: (
|
||||||
|
<EmptyState title="Tags" description="Manage tags from the Edit company form for now." />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user