diff --git a/src/app/(dashboard)/[portSlug]/expenses/page.tsx b/src/app/(dashboard)/[portSlug]/expenses/page.tsx index 3d6cc7b..3058091 100644 --- a/src/app/(dashboard)/[portSlug]/expenses/page.tsx +++ b/src/app/(dashboard)/[portSlug]/expenses/page.tsx @@ -20,6 +20,7 @@ import { TableSkeleton } from '@/components/shared/loading-skeleton'; import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog'; import { PermissionGate } from '@/components/shared/permission-gate'; import { ExpenseFormDialog } from '@/components/expenses/expense-form-dialog'; +import { ExpenseCard } from '@/components/expenses/expense-card'; import { expenseFilterDefinitions } from '@/components/expenses/expense-filters'; import { getExpenseColumns, type ExpenseRow } from '@/components/expenses/expense-columns'; import { usePaginatedQuery } from '@/hooks/use-paginated-query'; @@ -60,8 +61,7 @@ export default function ExpensesPage() { }); const archiveMutation = useMutation({ - mutationFn: (id: string) => - apiFetch(`/api/v1/expenses/${id}`, { method: 'DELETE' }), + mutationFn: (id: string) => apiFetch(`/api/v1/expenses/${id}`, { method: 'DELETE' }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['expenses'] }); setArchiveExpense(null); @@ -151,6 +151,14 @@ export default function ExpensesPage() { onSortChange={setSort} isLoading={isFetching && !isLoading} getRowId={(row) => row.id} + cardRender={(row) => ( + + )} emptyState={ - apiFetch(`/api/v1/invoices/${id}`, { method: 'DELETE' }), + mutationFn: (id: string) => apiFetch(`/api/v1/invoices/${id}`, { method: 'DELETE' }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['invoices'] }); setDeleteTarget(null); @@ -72,8 +72,7 @@ export default function InvoicesPage() { }); const sendMutation = useMutation({ - mutationFn: (id: string) => - apiFetch(`/api/v1/invoices/${id}/send`, { method: 'POST' }), + mutationFn: (id: string) => apiFetch(`/api/v1/invoices/${id}/send`, { method: 'POST' }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['invoices'] }); }, @@ -82,8 +81,7 @@ export default function InvoicesPage() { const columns = getInvoiceColumns({ portSlug, onSend: (invoice) => sendMutation.mutate(invoice.id), - onRecordPayment: (invoice) => - router.push(`/${portSlug}/invoices/${invoice.id}?tab=payment`), + onRecordPayment: (invoice) => router.push(`/${portSlug}/invoices/${invoice.id}?tab=payment`), onDelete: (invoice) => setDeleteTarget(invoice), }); @@ -141,6 +139,17 @@ export default function InvoicesPage() { onSortChange={setSort} isLoading={isFetching && !isLoading} getRowId={(row) => row.id} + cardRender={(row) => ( + sendMutation.mutate(invoice.id)} + onRecordPayment={(invoice) => + router.push(`/${portSlug}/invoices/${invoice.id}?tab=payment`) + } + onDelete={setDeleteTarget} + /> + )} emptyState={ Delete Invoice?

This will permanently delete invoice{' '} - {deleteTarget.invoiceNumber}. - This action cannot be undone. + {deleteTarget.invoiceNumber}. This + action cannot be undone.

- + + + { + e.stopPropagation(); + router.push(`/${portSlug}/berths/${berth.id}`); + }} + > + + View details + + { + e.stopPropagation(); + router.push(`/${portSlug}/berths/${berth.id}?edit=true`); + }} + > + + Edit + + + + } + > +
+ } /> +
+ {/* Title row + spacer for actions button */} +
+

+ {berth.mooringNumber} +

+ +
+ + {/* Area subtitle */} + {berth.area ? ( +

+ + {berth.area} +

+ ) : null} + + {/* Dimensions · Price meta line */} + {metaParts.length > 0 ? ( +
+ {metaParts.map((part, i) => ( + + {i > 0 ? · : null} + {part} + + ))} +
+ ) : null} + + {/* Status pill */} +
+ + {statusLabel} + +
+ + {/* Tags */} + {tags.length > 0 ? ( +
+ {tags.slice(0, 2).map((tag) => ( + + ))} + {tags.length > 2 ? ( + + +{tags.length - 2} + + ) : null} +
+ ) : null} +
+
+ + ); +} diff --git a/src/components/berths/berth-list.tsx b/src/components/berths/berth-list.tsx index 5d46e3d..73bfe46 100644 --- a/src/components/berths/berth-list.tsx +++ b/src/components/berths/berth-list.tsx @@ -9,6 +9,7 @@ import { SavedViewsDropdown } from '@/components/shared/saved-views-dropdown'; import { EmptyState } from '@/components/shared/empty-state'; import { usePaginatedQuery } from '@/hooks/use-paginated-query'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; +import { BerthCard } from './berth-card'; import { berthColumns, type BerthRow } from './berth-columns'; import { berthFilterDefinitions } from './berth-filters'; import { Anchor } from 'lucide-react'; @@ -73,6 +74,7 @@ export function BerthList() { onSortChange={setSort} getRowId={(row) => row.id} onRowClick={(row) => router.push(`/${params.portSlug}/berths/${row.id}`)} + cardRender={(row) => } emptyState={ = { + 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 CompanyCardProps { + company: CompanyRow; + portSlug: string; + onEdit: (company: CompanyRow) => void; + onArchive: (company: CompanyRow) => void; +} + +export function CompanyCard({ company, portSlug, onEdit, onArchive }: CompanyCardProps) { + const statusLabel = STATUS_LABELS[company.status] ?? company.status; + const statusColor = + STATUS_COLORS[company.status] ?? 'bg-muted text-muted-foreground border-muted'; + + const country = company.incorporationCountryIso + ? getCountryName(company.incorporationCountryIso, 'en') + : null; + + const memberCount = company.memberCount ?? 0; + const yachtCount = company.yachtCount ?? 0; + const countParts: string[] = []; + if (memberCount > 0) + countParts.push(`${memberCount} ${memberCount === 1 ? 'member' : 'members'}`); + if (yachtCount > 0) countParts.push(`${yachtCount} ${yachtCount === 1 ? 'yacht' : 'yachts'}`); + + // Skip legalName if it is identical to name or absent + const showLegalName = + company.legalName && company.legalName.toLowerCase() !== company.name.toLowerCase(); + + return ( + + + + + + + + + View + + + onEdit(company)}> + + Edit + + onArchive(company)}> + + Archive + + + + } + > +
+ } /> +
+ {/* Title row + spacer for actions button */} +
+

+ {company.name} +

+ +
+ + {/* Legal name subtitle */} + {showLegalName ? ( +

{company.legalName}

+ ) : null} + + {/* Country + Tax ID meta line */} + {country || company.taxId ? ( +
+ {country ? ( + }>{country} + ) : null} + {company.taxId ? ( + }>{company.taxId} + ) : null} +
+ ) : null} + + {/* Member / yacht counts */} + {countParts.length > 0 ? ( +

{countParts.join(' · ')}

+ ) : null} + + {/* Status pill */} + {company.status ? ( +
+ + {statusLabel} + +
+ ) : null} +
+
+
+ ); +} diff --git a/src/components/companies/company-list.tsx b/src/components/companies/company-list.tsx index d222223..ee12103 100644 --- a/src/components/companies/company-list.tsx +++ b/src/components/companies/company-list.tsx @@ -14,6 +14,7 @@ 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 { CompanyCard } from '@/components/companies/company-card'; import { CompanyForm } from '@/components/companies/company-form'; import { companyFilterDefinitions } from '@/components/companies/company-filters'; import { getCompanyColumns, type CompanyRow } from '@/components/companies/company-columns'; @@ -123,6 +124,14 @@ export function CompanyList() { onSortChange={setSort} isLoading={isFetching && !isLoading} getRowId={(row) => row.id} + cardRender={(row) => ( + + )} emptyState={ = { + unpaid: 'bg-red-100 text-red-700 border-red-200', + paid: 'bg-green-100 text-green-700 border-green-200', + partial: 'bg-yellow-100 text-yellow-700 border-yellow-200', + reconciled: 'bg-green-100 text-green-700 border-green-200', + flagged: 'bg-rose-100 text-rose-700 border-rose-200', + pending: 'bg-amber-100 text-amber-700 border-amber-200', +}; + +/** + * Accent bar by payment status: + * paid / reconciled → emerald + * pending → amber + * flagged → rose + * other / null → slate + * If duplicateOf is set, override to amber-500. + */ +function deriveAccent(status: string | null, duplicateOf: string | null): string { + if (duplicateOf) return 'bg-amber-500'; + switch (status) { + case 'paid': + case 'reconciled': + return 'bg-emerald-400'; + case 'pending': + return 'bg-amber-400'; + case 'flagged': + return 'bg-rose-400'; + default: + return 'bg-slate-300'; + } +} + +function formatAmount(amount: string, currency: string): string { + try { + return new Intl.NumberFormat('en', { style: 'currency', currency }).format(Number(amount)); + } catch { + return `${currency} ${amount}`; + } +} + +interface ExpenseCardProps { + expense: ExpenseRow; + portSlug: string; + onEdit: (expense: ExpenseRow) => void; + onArchive: (expense: ExpenseRow) => void; +} + +export function ExpenseCard({ expense, portSlug, onEdit, onArchive }: ExpenseCardProps) { + const accentClass = deriveAccent(expense.paymentStatus, expense.duplicateOf); + + const title = + expense.establishmentName ?? + (expense.description ? expense.description.slice(0, 40) : null) ?? + 'Untitled expense'; + + // Subtitle: category (capitalized) or "{paymentMethod} by {payer}" + let subtitle: string | null = null; + if (expense.category) { + subtitle = expense.category.replace(/_/g, ' '); + // Capitalize first letter + subtitle = subtitle.charAt(0).toUpperCase() + subtitle.slice(1); + } else if (expense.paymentMethod || expense.payer) { + const parts: string[] = []; + if (expense.paymentMethod) parts.push(expense.paymentMethod); + if (expense.payer) parts.push(`by ${expense.payer}`); + subtitle = parts.join(' '); + } + const hasCategory = !!expense.category; + + let dateFormatted: string | null = null; + try { + dateFormatted = format(new Date(expense.expenseDate), 'MMM d, yyyy'); + } catch { + dateFormatted = expense.expenseDate; + } + + const amountFormatted = formatAmount(expense.amount, expense.currency); + + const statusColor = expense.paymentStatus + ? (PAYMENT_STATUS_COLORS[expense.paymentStatus] ?? + 'bg-muted text-muted-foreground border-muted') + : null; + + return ( + + + + + + + + + View + + + onEdit(expense)}> + + Edit + + onArchive(expense)}> + + Archive + + + + } + > +
+ } /> +
+ {/* Title row + spacer for actions button */} +
+

+ {title} +

+ +
+ + {/* Category / payer subtitle */} + {subtitle ? ( +

+ {hasCategory ? ( + + ) : null} + {subtitle} +

+ ) : null} + + {/* Amount — prominent */} +

+ {amountFormatted} +

+ + {/* Date meta */} + {dateFormatted ? ( +
+ }>{dateFormatted} +
+ ) : null} + + {/* Status + duplicate pills */} +
+ {statusColor && expense.paymentStatus ? ( + + {expense.paymentStatus} + + ) : null} + + {expense.duplicateOf ? ( + + Possible duplicate + + ) : null} +
+
+
+
+ ); +} diff --git a/src/components/invoices/invoice-card.tsx b/src/components/invoices/invoice-card.tsx new file mode 100644 index 0000000..c81e857 --- /dev/null +++ b/src/components/invoices/invoice-card.tsx @@ -0,0 +1,187 @@ +'use client'; + +import { format } from 'date-fns'; +import { + Calendar, + CreditCard, + Eye, + FileText, + MoreHorizontal, + Send, + Trash2, + User, +} from 'lucide-react'; +import Link from 'next/link'; + +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { ListCard, ListCardAvatar, ListCardMeta } from '@/components/shared/list-card'; +import { cn } from '@/lib/utils'; +import type { InvoiceRow } from './invoice-columns'; + +const STATUS_COLORS: Record = { + draft: 'bg-gray-100 text-gray-700 border-gray-200', + sent: 'bg-blue-100 text-blue-700 border-blue-200', + paid: 'bg-green-100 text-green-700 border-green-200', + overdue: 'bg-red-100 text-red-700 border-red-200', + cancelled: 'bg-gray-100 text-gray-500 border-gray-200', +}; + +/** + * Accent bar encodes payment completeness using `status`: + * paid → green + * overdue → orange (past-due unpaid) + * sent → slate (awaiting payment, not yet overdue) + * draft → slate-200 + * other → slate-300 + */ +const STATUS_ACCENT: Record = { + paid: 'bg-emerald-400', + overdue: 'bg-orange-400', + sent: 'bg-slate-300', + draft: 'bg-slate-200', + cancelled: 'bg-slate-200', +}; + +function formatAmount(total: string, currency: string): string { + try { + return new Intl.NumberFormat('en', { style: 'currency', currency }).format(Number(total)); + } catch { + return `${currency} ${total}`; + } +} + +interface InvoiceCardProps { + invoice: InvoiceRow; + portSlug: string; + onSend?: (invoice: InvoiceRow) => void; + onRecordPayment?: (invoice: InvoiceRow) => void; + onDelete?: (invoice: InvoiceRow) => void; +} + +export function InvoiceCard({ + invoice, + portSlug, + onSend, + onRecordPayment, + onDelete, +}: InvoiceCardProps) { + const statusColor = STATUS_COLORS[invoice.status] ?? STATUS_COLORS.draft; + const accentClass = STATUS_ACCENT[invoice.status] ?? 'bg-slate-300'; + + let dueDateFormatted: string | null = null; + try { + dueDateFormatted = format(new Date(invoice.dueDate), 'MMM d, yyyy'); + } catch { + dueDateFormatted = invoice.dueDate; + } + + const amountFormatted = formatAmount(invoice.total, invoice.currency); + + return ( + + + + + + + + + View + + + {invoice.pdfFileId ? ( + + + + View PDF + + + ) : null} + {invoice.status === 'draft' && onSend ? ( + onSend(invoice)}> + + Send + + ) : null} + {(invoice.status === 'sent' || invoice.status === 'overdue') && onRecordPayment ? ( + onRecordPayment(invoice)}> + + Record Payment + + ) : null} + {invoice.status === 'draft' && onDelete ? ( + onDelete(invoice)}> + + Delete + + ) : null} + + + } + > +
+ } /> +
+ {/* Title row: invoice number + spacer for actions */} +
+

+ {invoice.invoiceNumber} +

+ +
+ + {/* Client name */} +

+ + {invoice.clientName} +

+ + {/* Amount — prominent */} +

+ {amountFormatted} +

+ + {/* Due date */} + {dueDateFormatted ? ( +
+ }> + Due {dueDateFormatted} + +
+ ) : null} + + {/* Status pill */} +
+ + {invoice.status} + +
+
+
+
+ ); +} diff --git a/src/components/yachts/yacht-card.tsx b/src/components/yachts/yacht-card.tsx new file mode 100644 index 0000000..b2b82e6 --- /dev/null +++ b/src/components/yachts/yacht-card.tsx @@ -0,0 +1,142 @@ +'use client'; + +import { Archive, Building2, Eye, MoreHorizontal, Pencil, Ship, User } from 'lucide-react'; +import Link from 'next/link'; + +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { ListCard, ListCardAvatar, ListCardMeta } from '@/components/shared/list-card'; +import { cn } from '@/lib/utils'; +import type { YachtRow } from './yacht-columns'; + +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', +}; + +interface YachtCardProps { + yacht: YachtRow; + portSlug: string; + onEdit: (yacht: YachtRow) => void; + onArchive: (yacht: YachtRow) => void; +} + +export function YachtCard({ yacht, portSlug, onEdit, onArchive }: YachtCardProps) { + const statusLabel = STATUS_LABELS[yacht.status] ?? yacht.status; + const statusColor = STATUS_COLORS[yacht.status] ?? 'bg-muted text-muted-foreground border-muted'; + + // Prefer metric dimensions; fall back to imperial + let dimText: string | null = null; + if (yacht.lengthM || yacht.widthM) { + const l = yacht.lengthM ?? '—'; + const w = yacht.widthM ?? '—'; + dimText = `${l}m × ${w}m`; + } else if (yacht.lengthFt || yacht.widthFt) { + const l = yacht.lengthFt ?? '—'; + const w = yacht.widthFt ?? '—'; + dimText = `${l}ft × ${w}ft`; + } + + const metaParts: string[] = []; + if (dimText) metaParts.push(dimText); + if (yacht.hullNumber) metaParts.push(`Hull #${yacht.hullNumber}`); + + const OwnerIcon = yacht.currentOwnerType === 'company' ? Building2 : User; + + return ( + + + + + + + + + View + + + onEdit(yacht)}> + + Edit + + onArchive(yacht)}> + + Archive + + + + } + > +
+ } /> +
+ {/* Title row + spacer for actions button */} +
+

+ {yacht.name} +

+ +
+ + {/* Owner subtitle */} + {yacht.currentOwnerName ? ( +

+ + {yacht.currentOwnerName} +

+ ) : null} + + {/* Dimensions · Hull number */} + {metaParts.length > 0 ? ( +
+ {metaParts.map((part, i) => ( + + {i > 0 ? · : null} + {part} + + ))} +
+ ) : null} + + {/* Status pill */} + {yacht.status ? ( +
+ + {statusLabel} + +
+ ) : null} +
+
+
+ ); +} diff --git a/src/components/yachts/yacht-list.tsx b/src/components/yachts/yacht-list.tsx index 9ab89d1..d2bb57e 100644 --- a/src/components/yachts/yacht-list.tsx +++ b/src/components/yachts/yacht-list.tsx @@ -14,6 +14,7 @@ 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 { YachtCard } from '@/components/yachts/yacht-card'; import { YachtForm } from '@/components/yachts/yacht-form'; import { yachtFilterDefinitions } from '@/components/yachts/yacht-filters'; import { getYachtColumns, type YachtRow } from '@/components/yachts/yacht-columns'; @@ -124,6 +125,14 @@ export function YachtList() { onSortChange={setSort} isLoading={isFetching && !isLoading} getRowId={(row) => row.id} + cardRender={(row) => ( + + )} emptyState={