From 5d76a8a1cfe9f4ebc27a4fc36e094efd5740d372 Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Fri, 24 Apr 2026 13:59:21 +0200 Subject: [PATCH] feat(ui): company detail page with header, tabs, members, owned yachts --- .../[portSlug]/companies/[companyId]/page.tsx | 16 + .../companies/company-detail-header.tsx | 153 ++++++++++ src/components/companies/company-detail.tsx | 62 ++++ .../companies/company-members-tab.tsx | 288 ++++++++++++++++++ .../companies/company-owned-yachts-tab.tsx | 156 ++++++++++ src/components/companies/company-tabs.tsx | 167 ++++++++++ 6 files changed, 842 insertions(+) create mode 100644 src/app/(dashboard)/[portSlug]/companies/[companyId]/page.tsx create mode 100644 src/components/companies/company-detail-header.tsx create mode 100644 src/components/companies/company-detail.tsx create mode 100644 src/components/companies/company-members-tab.tsx create mode 100644 src/components/companies/company-owned-yachts-tab.tsx create mode 100644 src/components/companies/company-tabs.tsx diff --git a/src/app/(dashboard)/[portSlug]/companies/[companyId]/page.tsx b/src/app/(dashboard)/[portSlug]/companies/[companyId]/page.tsx new file mode 100644 index 0000000..11a65fa --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/companies/[companyId]/page.tsx @@ -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 ; +} diff --git a/src/components/companies/company-detail-header.tsx b/src/components/companies/company-detail-header.tsx new file mode 100644 index 0000000..0670c7f --- /dev/null +++ b/src/components/companies/company-detail-header.tsx @@ -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 = { + active: 'bg-green-100 text-green-800 border-green-300', + dissolved: 'bg-red-100 text-red-800 border-red-300', +}; + +const STATUS_LABELS: Record = { + 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 ( + <> +
+
+
+
+

{company.name}

+ + {statusLabel} + + {isArchived && ( + + Archived + + )} +
+ +
+ {showLegalName &&

{company.legalName}

} + {company.taxId &&

Tax ID: {company.taxId}

} +
+
+ + {/* Actions */} +
+ + + + + + +
+
+
+ + + + { + archiveMutation.mutate(); + }} + isLoading={archiveMutation.isPending} + /> + + ); +} diff --git a/src/components/companies/company-detail.tsx b/src/components/companies/company-detail.tsx new file mode 100644 index 0000000..ef2ca4b --- /dev/null +++ b/src/components/companies/company-detail.tsx @@ -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({ + 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 ( + : null} + tabs={tabs} + defaultTab="overview" + isLoading={isLoading} + /> + ); +} diff --git a/src/components/companies/company-members-tab.tsx b/src/components/companies/company-members-tab.tsx new file mode 100644 index 0000000..f5bc758 --- /dev/null +++ b/src/components/companies/company-members-tab.tsx @@ -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 = { + 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 ( + + {data?.fullName ?? `Client ${clientId.slice(0, 8)}`} + + ); +} + +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({ + 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 ( +
+
+
+ + +
+ + + + +
+ + {isLoading ? ( +
+ +
+ ) : members.length === 0 ? ( + + ) : ( +
+ + + + Client + Role + Role Detail + Start Date + End Date + Primary + + + + + {members.map((m) => { + const isActive = !m.endDate; + return ( + + + + + {ROLE_LABELS[m.role] ?? m.role} + + {m.roleDetail ?? '—'} + + {formatDate(m.startDate)} + + {m.endDate ? ( + formatDate(m.endDate) + ) : ( + + )} + + + {m.isPrimary ? ( + + Primary + + ) : ( + + )} + + + + + + + + + {isActive && !m.isPrimary && ( + setPrimaryMutation.mutate(m.id)} + disabled={setPrimaryMutation.isPending} + > + + Set Primary + + )} + {isActive && ( + endMutation.mutate(m.id)} + disabled={endMutation.isPending} + > + + End Membership + + )} + + + + + + ); + })} + +
+
+ )} + + {/* TODO: Task 6.4 — replace this stub with the real AddMembershipDialog. */} + + + + Add Member + + The add-membership dialog is coming in the next step. For now this is a placeholder. + + + + + + + +
+ ); +} diff --git a/src/components/companies/company-owned-yachts-tab.tsx b/src/components/companies/company-owned-yachts-tab.tsx new file mode 100644 index 0000000..63145ae --- /dev/null +++ b/src/components/companies/company-owned-yachts-tab.tsx @@ -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 = { + 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 = { + 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({ + 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(`/api/v1/yachts?${params.toString()}`); + return res.data; + }, + }); + + if (isLoading) { + return ( +
+ +
+ ); + } + + const yachts = data ?? []; + + if (yachts.length === 0) { + return ( + + ); + } + + return ( +
+ + + + Name + Dimensions + Hull Number + Status + + + + {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 ( + + + + {y.name} + + + + {dims ? ( + {dims} + ) : ( + + )} + + + {y.hullNumber ? ( + {y.hullNumber} + ) : ( + + )} + + + + {statusLabel} + + + + ); + })} + +
+
+ ); +} diff --git a/src/components/companies/company-tabs.tsx b/src/components/companies/company-tabs.tsx new file mode 100644 index 0000000..9b992fc --- /dev/null +++ b/src/components/companies/company-tabs.tsx @@ -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 = { + active: 'Active', + dissolved: 'Dissolved', +}; + +function InfoRow({ label, value }: { label: string; value?: string | number | null }) { + if (value === null || value === undefined || value === '') return null; + return ( +
+
{label}
+
{value}
+
+ ); +} + +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 ( +
+ {/* Identity */} +
+

Identity

+
+ + + +
+
+ + {/* Registration */} + {(company.taxId || + company.registrationNumber || + company.incorporationCountry || + incorporationDate) && ( +
+

Registration

+
+ + + + +
+
+ )} + + {/* Contact */} + {company.billingEmail && ( +
+

Contact

+
+ +
+
+ )} + + {/* Notes */} + {company.notes && ( +
+

Notes

+

+ {company.notes} +

+
+ )} +
+ ); +} + +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: , + }, + { + id: 'members', + label: 'Members', + content: , + }, + { + id: 'owned-yachts', + label: 'Owned Yachts', + content: , + }, + { + id: 'addresses', + label: 'Addresses', + // TODO: wire to future company-addresses endpoint (see company-addresses schema). + content: ( + + ), + }, + { + id: 'documents', + label: 'Documents', + content: , + }, + { + 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: ( + + ), + }, + { + 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: ( + + ), + }, + ]; +}