diff --git a/src/app/(dashboard)/[portSlug]/companies/page.tsx b/src/app/(dashboard)/[portSlug]/companies/page.tsx new file mode 100644 index 0000000..4fe2fb1 --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/companies/page.tsx @@ -0,0 +1,5 @@ +import { CompanyList } from '@/components/companies/company-list'; + +export default function CompaniesPage() { + return ; +} diff --git a/src/components/companies/company-columns.tsx b/src/components/companies/company-columns.tsx new file mode 100644 index 0000000..869041b --- /dev/null +++ b/src/components/companies/company-columns.tsx @@ -0,0 +1,148 @@ +'use client'; + +import Link from 'next/link'; +import { MoreHorizontal, Pencil, Archive, Eye } from 'lucide-react'; +import type { ColumnDef } from '@tanstack/react-table'; + +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; + +// TODO: add member/yacht counts once the list endpoint returns them via a join. +export interface CompanyRow { + 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; + createdAt: string; + updatedAt: string; +} + +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', +}; + +interface GetCompanyColumnsOptions { + portSlug: string; + onEdit: (company: CompanyRow) => void; + onArchive: (company: CompanyRow) => void; +} + +export function getCompanyColumns({ + portSlug, + onEdit, + onArchive, +}: GetCompanyColumnsOptions): ColumnDef[] { + return [ + { + id: 'name', + accessorKey: 'name', + header: 'Name', + cell: ({ row }) => ( + e.stopPropagation()} + > + {row.original.name} + + ), + }, + { + id: 'legalName', + accessorKey: 'legalName', + header: 'Legal Name', + enableSorting: false, + cell: ({ getValue }) => { + const value = getValue() as string | null; + if (!value) return ; + return {value}; + }, + }, + { + id: 'taxId', + accessorKey: 'taxId', + header: 'Tax ID', + enableSorting: false, + cell: ({ getValue }) => { + const value = getValue() as string | null; + if (!value) return ; + return {value}; + }, + }, + { + id: 'status', + accessorKey: 'status', + header: 'Status', + cell: ({ row }) => { + const status = row.original.status; + const label = STATUS_LABELS[status] ?? status; + const color = STATUS_COLORS[status] ?? 'bg-muted text-muted-foreground border-muted'; + return ( + + {label} + + ); + }, + }, + { + id: 'actions', + header: '', + enableSorting: false, + size: 48, + cell: ({ row }) => ( + + + + + + + + + View + + + onEdit(row.original)}> + + Edit + + onArchive(row.original)}> + + Archive + + + + ), + }, + ]; +} diff --git a/src/components/companies/company-filters.tsx b/src/components/companies/company-filters.tsx new file mode 100644 index 0000000..90782e3 --- /dev/null +++ b/src/components/companies/company-filters.tsx @@ -0,0 +1,24 @@ +import type { FilterDefinition } from '@/components/shared/filter-bar'; + +export const companyFilterDefinitions: FilterDefinition[] = [ + { + key: 'search', + label: 'Search', + type: 'text', + placeholder: 'Search by name, legal name, tax ID...', + }, + { + key: 'status', + label: 'Status', + type: 'select', + options: [ + { label: 'Active', value: 'active' }, + { label: 'Dissolved', value: 'dissolved' }, + ], + }, + { + key: 'includeArchived', + label: 'Include Archived', + type: 'boolean', + }, +]; diff --git a/src/components/companies/company-list.tsx b/src/components/companies/company-list.tsx new file mode 100644 index 0000000..ca5dd5c --- /dev/null +++ b/src/components/companies/company-list.tsx @@ -0,0 +1,167 @@ +'use client'; + +import { useState } from 'react'; +import { useParams } from 'next/navigation'; +import { Plus } from 'lucide-react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { Button } from '@/components/ui/button'; +import { DataTable } from '@/components/shared/data-table'; +import { FilterBar } from '@/components/shared/filter-bar'; +import { SavedViewsDropdown } from '@/components/shared/saved-views-dropdown'; +import { PageHeader } from '@/components/shared/page-header'; +import { EmptyState } from '@/components/shared/empty-state'; +import { TableSkeleton } from '@/components/shared/loading-skeleton'; +import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog'; +import { PermissionGate } from '@/components/shared/permission-gate'; +import { CompanyForm } from '@/components/companies/company-form'; +import { companyFilterDefinitions } from '@/components/companies/company-filters'; +import { getCompanyColumns, type CompanyRow } from '@/components/companies/company-columns'; +import { usePaginatedQuery } from '@/hooks/use-paginated-query'; +import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; +import { apiFetch } from '@/lib/api/client'; + +export function CompanyList() { + const params = useParams<{ portSlug: string }>(); + const portSlug = params?.portSlug ?? ''; + const queryClient = useQueryClient(); + + const [createOpen, setCreateOpen] = useState(false); + const [editCompany, setEditCompany] = useState(null); + const [archiveCompany, setArchiveCompany] = useState(null); + + const { + data, + pagination, + isLoading, + isFetching, + sort, + setSort, + setPage, + setPageSize, + filters, + setFilter, + clearFilters, + } = usePaginatedQuery({ + queryKey: ['companies'], + endpoint: '/api/v1/companies', + filterDefinitions: companyFilterDefinitions, + }); + + useRealtimeInvalidation({ + 'company:created': [['companies']], + 'company:updated': [['companies']], + 'company:archived': [['companies']], + }); + + const archiveMutation = useMutation({ + mutationFn: (id: string) => apiFetch(`/api/v1/companies/${id}`, { method: 'DELETE' }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['companies'] }); + setArchiveCompany(null); + }, + }); + + const columns = getCompanyColumns({ + portSlug, + onEdit: (company) => setEditCompany(company), + onArchive: (company) => setArchiveCompany(company), + }); + + return ( +
+ + + + } + /> + +
+ + { + clearFilters(); + Object.entries(savedFilters).forEach(([key, val]) => setFilter(key, val)); + }} + /> +
+ + {isLoading ? ( + + ) : !data.length ? ( + setCreateOpen(true) }} + /> + ) : ( + { + setPage(p); + setPageSize(ps); + }} + sort={sort} + onSortChange={setSort} + isLoading={isFetching && !isLoading} + getRowId={(row) => row.id} + emptyState={ + setCreateOpen(true) }} + /> + } + /> + )} + + + + {editCompany && ( + !open && setEditCompany(null)} + company={{ + id: editCompany.id, + name: editCompany.name, + legalName: editCompany.legalName, + taxId: editCompany.taxId, + registrationNumber: editCompany.registrationNumber, + incorporationCountry: editCompany.incorporationCountry, + incorporationDate: editCompany.incorporationDate, + status: editCompany.status, + billingEmail: editCompany.billingEmail, + notes: editCompany.notes, + }} + /> + )} + + !open && setArchiveCompany(null)} + entityName={archiveCompany?.name ?? ''} + entityType="Company" + isArchived={false} + onConfirm={() => archiveCompany && archiveMutation.mutate(archiveCompany.id)} + isLoading={archiveMutation.isPending} + /> +
+ ); +} diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 92a56d5..2802179 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -9,6 +9,7 @@ import { Bookmark, Anchor, Ship, + Building2, Receipt, FileText, FolderOpen, @@ -62,6 +63,7 @@ function buildNavSections(portSlug: string | undefined): NavSection[] { { href: `${base}/dashboard`, label: 'Dashboard', icon: LayoutDashboard }, { href: `${base}/clients`, label: 'Clients', icon: Users }, { href: `${base}/yachts`, label: 'Yachts', icon: Ship }, + { href: `${base}/companies`, label: 'Companies', icon: Building2 }, { href: `${base}/interests`, label: 'Interests', icon: Bookmark }, { href: `${base}/berths`, label: 'Berths', icon: Anchor }, ],