feat(ui): company detail page with header, tabs, members, owned yachts

This commit is contained in:
Matt Ciaccio
2026-04-24 13:59:21 +02:00
parent d6743ed52c
commit 5d76a8a1cf
6 changed files with 842 additions and 0 deletions

View 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}
/>
</>
);
}

View 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}
/>
);
}

View 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>
);
}

View 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>
);
}

View 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." />
),
},
];
}