feat(mobile): mobile cards for yachts, companies, berths, invoices, expenses

Five new <EntityCard> files using the shared <ListCard> shell, wired
into each list page's <DataTable> via cardRender. Desktop view
(lg+) is unchanged.

  - YachtCard:    Ship icon, owner subtitle (User/Building2 icon by
                  ownerType), dimensions in meters preferred, hull #,
                  status pill. No accent bar (status is free-text).
  - CompanyCard:  Building2 icon, legalName subtitle, country (MapPin)
                  + tax id (Hash) meta, member/yacht count line.
  - BerthCard:    Anchor icon, area subtitle (MapPin), dimensions
                  meta, status pill. Status-encoded accent bar
                  (emerald=available, amber=under_offer, slate=sold).
  - InvoiceCard:  FileText icon, client subtitle, due date (Calendar)
                  meta, prominent currency-formatted amount. Status
                  accent bar (emerald=paid, orange=overdue, ...).
  - ExpenseCard:  Receipt icon, category subtitle, expense date meta,
                  prominent amount, payment-status pill, "Possible
                  duplicate" pill when duplicateOf is set. Accent bar
                  by paymentStatus, overridden to amber when flagged
                  as duplicate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-01 15:34:04 +02:00
parent 6009ccb7de
commit 722491a9dd
10 changed files with 889 additions and 15 deletions

View File

@@ -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) => (
<ExpenseCard
expense={row.original}
portSlug={portSlug}
onEdit={setEditExpense}
onArchive={setArchiveExpense}
/>
)}
emptyState={
<EmptyState
title="No expenses found"

View File

@@ -12,6 +12,7 @@ import { PageHeader } from '@/components/shared/page-header';
import { EmptyState } from '@/components/shared/empty-state';
import { TableSkeleton } from '@/components/shared/loading-skeleton';
import { PermissionGate } from '@/components/shared/permission-gate';
import { InvoiceCard } from '@/components/invoices/invoice-card';
import { invoiceFilterDefinitions } from '@/components/invoices/invoice-filters';
import { getInvoiceColumns, type InvoiceRow } from '@/components/invoices/invoice-columns';
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
@@ -63,8 +64,7 @@ export default function InvoicesPage() {
});
const deleteMutation = useMutation({
mutationFn: (id: string) =>
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) => (
<InvoiceCard
invoice={row.original}
portSlug={portSlug}
onSend={(invoice) => sendMutation.mutate(invoice.id)}
onRecordPayment={(invoice) =>
router.push(`/${portSlug}/invoices/${invoice.id}?tab=payment`)
}
onDelete={setDeleteTarget}
/>
)}
emptyState={
<EmptyState
title="No invoices found"
@@ -161,15 +170,11 @@ export default function InvoicesPage() {
<h3 className="font-semibold">Delete Invoice?</h3>
<p className="text-sm text-muted-foreground">
This will permanently delete invoice{' '}
<span className="font-mono font-medium">{deleteTarget.invoiceNumber}</span>.
This action cannot be undone.
<span className="font-mono font-medium">{deleteTarget.invoiceNumber}</span>. This
action cannot be undone.
</p>
<div className="flex items-center gap-2 justify-end">
<Button
variant="outline"
size="sm"
onClick={() => setDeleteTarget(null)}
>
<Button variant="outline" size="sm" onClick={() => setDeleteTarget(null)}>
Cancel
</Button>
<Button

View File

@@ -0,0 +1,177 @@
'use client';
import { Activity, Anchor, MapPin, MoreHorizontal, Pencil } from 'lucide-react';
import { useRouter, useParams } from 'next/navigation';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { TagBadge } from '@/components/shared/tag-badge';
import { ListCard, ListCardAvatar, ListCardMeta } from '@/components/shared/list-card';
import { cn } from '@/lib/utils';
import type { BerthRow } from './berth-columns';
const STATUS_VARIANTS: Record<string, string> = {
available: 'bg-green-100 text-green-800 border-green-200',
under_offer: 'bg-yellow-100 text-yellow-800 border-yellow-200',
sold: 'bg-red-100 text-red-800 border-red-200',
};
const STATUS_LABELS: Record<string, string> = {
available: 'Available',
under_offer: 'Under Offer',
sold: 'Sold',
};
const ACCENT_CLASS: Record<string, string> = {
available: 'bg-emerald-400',
under_offer: 'bg-amber-400',
sold: 'bg-slate-400',
};
function formatPrice(price: string, currency: string): string {
try {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency || 'USD',
maximumFractionDigits: 0,
}).format(Number(price));
} catch {
return `${currency} ${price}`;
}
}
interface BerthCardProps {
berth: BerthRow;
}
export function BerthCard({ berth }: BerthCardProps) {
const router = useRouter();
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const statusLabel = STATUS_LABELS[berth.status] ?? berth.status;
const statusColor =
STATUS_VARIANTS[berth.status] ?? 'bg-muted text-muted-foreground border-muted';
const accentClass = ACCENT_CLASS[berth.status] ?? 'bg-slate-300';
// Dimensions string
let dimText: string | null = null;
if (berth.lengthM || berth.widthM) {
const l = berth.lengthM ?? '?';
const w = berth.widthM ?? '?';
dimText = `${l}m × ${w}m`;
}
const metaParts: string[] = [];
if (dimText) metaParts.push(dimText);
if (berth.price) metaParts.push(formatPrice(berth.price, berth.priceCurrency));
const tags = berth.tags ?? [];
return (
<ListCard
href={`/${portSlug}/berths/${berth.id}`}
ariaLabel={`Berth ${berth.mooringNumber}`}
accentClassName={accentClass}
actions={
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-9 w-9"
onClick={(e) => e.stopPropagation()}
aria-label={`Actions for berth ${berth.mooringNumber}`}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
router.push(`/${portSlug}/berths/${berth.id}`);
}}
>
<Activity className="mr-2 h-3.5 w-3.5" />
View details
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
router.push(`/${portSlug}/berths/${berth.id}?edit=true`);
}}
>
<Pencil className="mr-2 h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
}
>
<div className="flex items-start gap-3">
<ListCardAvatar icon={<Anchor className="h-5 w-5" />} />
<div className="min-w-0 flex-1">
{/* Title row + spacer for actions button */}
<div className="flex items-start justify-between gap-2">
<h3 className="truncate text-base font-semibold tracking-tight text-foreground">
{berth.mooringNumber}
</h3>
<span aria-hidden className="block h-9 w-9 shrink-0" />
</div>
{/* Area subtitle */}
{berth.area ? (
<p className="mt-0.5 inline-flex items-center gap-1 truncate text-sm text-muted-foreground">
<MapPin className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" aria-hidden />
<span className="truncate">{berth.area}</span>
</p>
) : null}
{/* Dimensions · Price meta line */}
{metaParts.length > 0 ? (
<div className="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-xs text-muted-foreground">
{metaParts.map((part, i) => (
<span key={part} className="inline-flex items-center gap-1">
{i > 0 ? <span aria-hidden>·</span> : null}
<ListCardMeta>{part}</ListCardMeta>
</span>
))}
</div>
) : null}
{/* Status pill */}
<div className="mt-1.5">
<span
className={cn(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium',
statusColor,
)}
>
{statusLabel}
</span>
</div>
{/* Tags */}
{tags.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1">
{tags.slice(0, 2).map((tag) => (
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
))}
{tags.length > 2 ? (
<span className="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-xs text-secondary-foreground">
+{tags.length - 2}
</span>
) : null}
</div>
) : null}
</div>
</div>
</ListCard>
);
}

View File

@@ -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) => <BerthCard berth={row.original} />}
emptyState={
<EmptyState
icon={Anchor}

View File

@@ -0,0 +1,141 @@
'use client';
import { Archive, Building2, Eye, Hash, MapPin, MoreHorizontal, Pencil } 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 { getCountryName } from '@/lib/i18n/countries';
import type { CompanyRow } from './company-columns';
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',
};
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 (
<ListCard
href={`/${portSlug}/companies/${company.id}`}
ariaLabel={`Company ${company.name}`}
actions={
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-9 w-9"
onClick={(e) => e.stopPropagation()}
aria-label={`Actions for ${company.name}`}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/${portSlug}/companies/${company.id}`}>
<Eye className="mr-2 h-3.5 w-3.5" />
View
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEdit(company)}>
<Pencil className="mr-2 h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
<DropdownMenuItem className="text-destructive" onClick={() => onArchive(company)}>
<Archive className="mr-2 h-3.5 w-3.5" />
Archive
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
}
>
<div className="flex items-start gap-3">
<ListCardAvatar icon={<Building2 className="h-5 w-5" />} />
<div className="min-w-0 flex-1">
{/* Title row + spacer for actions button */}
<div className="flex items-start justify-between gap-2">
<h3 className="truncate text-base font-semibold tracking-tight text-foreground">
{company.name}
</h3>
<span aria-hidden className="block h-9 w-9 shrink-0" />
</div>
{/* Legal name subtitle */}
{showLegalName ? (
<p className="mt-0.5 truncate text-sm text-muted-foreground">{company.legalName}</p>
) : null}
{/* Country + Tax ID meta line */}
{country || company.taxId ? (
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
{country ? (
<ListCardMeta icon={<MapPin className="h-3 w-3" />}>{country}</ListCardMeta>
) : null}
{company.taxId ? (
<ListCardMeta icon={<Hash className="h-3 w-3" />}>{company.taxId}</ListCardMeta>
) : null}
</div>
) : null}
{/* Member / yacht counts */}
{countParts.length > 0 ? (
<p className="mt-0.5 text-xs text-muted-foreground">{countParts.join(' · ')}</p>
) : null}
{/* Status pill */}
{company.status ? (
<div className="mt-1.5">
<span
className={cn(
'inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium',
statusColor,
)}
>
{statusLabel}
</span>
</div>
) : null}
</div>
</div>
</ListCard>
);
}

View File

@@ -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) => (
<CompanyCard
company={row.original}
portSlug={portSlug}
onEdit={setEditCompany}
onArchive={setArchiveCompany}
/>
)}
emptyState={
<EmptyState
title="No companies yet"

View File

@@ -0,0 +1,194 @@
'use client';
import { format } from 'date-fns';
import { Archive, Calendar, Eye, MoreHorizontal, Pencil, Receipt, Tag } 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 { ExpenseRow } from './expense-columns';
const PAYMENT_STATUS_COLORS: Record<string, string> = {
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 (
<ListCard
href={`/${portSlug}/expenses/${expense.id}`}
ariaLabel={`Expense: ${title}`}
accentClassName={accentClass}
actions={
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-9 w-9"
onClick={(e) => e.stopPropagation()}
aria-label={`Actions for expense: ${title}`}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/${portSlug}/expenses/${expense.id}`}>
<Eye className="mr-2 h-3.5 w-3.5" />
View
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEdit(expense)}>
<Pencil className="mr-2 h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
<DropdownMenuItem className="text-destructive" onClick={() => onArchive(expense)}>
<Archive className="mr-2 h-3.5 w-3.5" />
Archive
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
}
>
<div className="flex items-start gap-3">
<ListCardAvatar icon={<Receipt className="h-5 w-5" />} />
<div className="min-w-0 flex-1">
{/* Title row + spacer for actions button */}
<div className="flex items-start justify-between gap-2">
<h3 className="truncate text-base font-semibold tracking-tight text-foreground">
{title}
</h3>
<span aria-hidden className="block h-9 w-9 shrink-0" />
</div>
{/* Category / payer subtitle */}
{subtitle ? (
<p className="mt-0.5 inline-flex items-center gap-1 truncate text-sm text-muted-foreground">
{hasCategory ? (
<Tag className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" aria-hidden />
) : null}
<span className="truncate">{subtitle}</span>
</p>
) : null}
{/* Amount — prominent */}
<p className="mt-1 text-base font-semibold tabular-nums text-foreground">
{amountFormatted}
</p>
{/* Date meta */}
{dateFormatted ? (
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 text-xs text-muted-foreground">
<ListCardMeta icon={<Calendar className="h-3 w-3" />}>{dateFormatted}</ListCardMeta>
</div>
) : null}
{/* Status + duplicate pills */}
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
{statusColor && expense.paymentStatus ? (
<span
className={cn(
'inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium capitalize',
statusColor,
)}
>
{expense.paymentStatus}
</span>
) : null}
{expense.duplicateOf ? (
<span className="inline-flex items-center rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700">
Possible duplicate
</span>
) : null}
</div>
</div>
</div>
</ListCard>
);
}

View File

@@ -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<string, string> = {
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<string, string> = {
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 (
<ListCard
href={`/${portSlug}/invoices/${invoice.id}`}
ariaLabel={`Invoice ${invoice.invoiceNumber}`}
accentClassName={accentClass}
actions={
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-9 w-9"
onClick={(e) => e.stopPropagation()}
aria-label={`Actions for invoice ${invoice.invoiceNumber}`}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/${portSlug}/invoices/${invoice.id}`}>
<Eye className="mr-2 h-3.5 w-3.5" />
View
</Link>
</DropdownMenuItem>
{invoice.pdfFileId ? (
<DropdownMenuItem asChild>
<Link href={`/api/v1/files/${invoice.pdfFileId}/preview`} target="_blank">
<FileText className="mr-2 h-3.5 w-3.5" />
View PDF
</Link>
</DropdownMenuItem>
) : null}
{invoice.status === 'draft' && onSend ? (
<DropdownMenuItem onClick={() => onSend(invoice)}>
<Send className="mr-2 h-3.5 w-3.5" />
Send
</DropdownMenuItem>
) : null}
{(invoice.status === 'sent' || invoice.status === 'overdue') && onRecordPayment ? (
<DropdownMenuItem onClick={() => onRecordPayment(invoice)}>
<CreditCard className="mr-2 h-3.5 w-3.5" />
Record Payment
</DropdownMenuItem>
) : null}
{invoice.status === 'draft' && onDelete ? (
<DropdownMenuItem className="text-destructive" onClick={() => onDelete(invoice)}>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
}
>
<div className="flex items-start gap-3">
<ListCardAvatar icon={<FileText className="h-5 w-5" />} />
<div className="min-w-0 flex-1">
{/* Title row: invoice number + spacer for actions */}
<div className="flex items-start justify-between gap-2">
<h3 className="truncate font-mono text-base font-semibold tabular-nums tracking-tight text-foreground">
{invoice.invoiceNumber}
</h3>
<span aria-hidden className="block h-9 w-9 shrink-0" />
</div>
{/* Client name */}
<p className="mt-0.5 inline-flex items-center gap-1 truncate text-sm text-muted-foreground">
<User className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" aria-hidden />
<span className="truncate">{invoice.clientName}</span>
</p>
{/* Amount — prominent */}
<p className="mt-1 text-base font-semibold tabular-nums text-foreground">
{amountFormatted}
</p>
{/* Due date */}
{dueDateFormatted ? (
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 text-xs text-muted-foreground">
<ListCardMeta icon={<Calendar className="h-3 w-3" />}>
Due {dueDateFormatted}
</ListCardMeta>
</div>
) : null}
{/* Status pill */}
<div className="mt-1.5">
<span
className={cn(
'inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium capitalize',
statusColor,
)}
>
{invoice.status}
</span>
</div>
</div>
</div>
</ListCard>
);
}

View File

@@ -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<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',
};
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 (
<ListCard
href={`/${portSlug}/yachts/${yacht.id}`}
ariaLabel={`Yacht ${yacht.name}`}
actions={
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-9 w-9"
onClick={(e) => e.stopPropagation()}
aria-label={`Actions for ${yacht.name}`}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/${portSlug}/yachts/${yacht.id}`}>
<Eye className="mr-2 h-3.5 w-3.5" />
View
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEdit(yacht)}>
<Pencil className="mr-2 h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
<DropdownMenuItem className="text-destructive" onClick={() => onArchive(yacht)}>
<Archive className="mr-2 h-3.5 w-3.5" />
Archive
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
}
>
<div className="flex items-start gap-3">
<ListCardAvatar icon={<Ship className="h-5 w-5" />} />
<div className="min-w-0 flex-1">
{/* Title row + spacer for actions button */}
<div className="flex items-start justify-between gap-2">
<h3 className="truncate text-base font-semibold tracking-tight text-foreground">
{yacht.name}
</h3>
<span aria-hidden className="block h-9 w-9 shrink-0" />
</div>
{/* Owner subtitle */}
{yacht.currentOwnerName ? (
<p className="mt-0.5 inline-flex items-center gap-1 truncate text-sm text-muted-foreground">
<OwnerIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" aria-hidden />
<span className="truncate">{yacht.currentOwnerName}</span>
</p>
) : null}
{/* Dimensions · Hull number */}
{metaParts.length > 0 ? (
<div className="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-xs text-muted-foreground">
{metaParts.map((part, i) => (
<span key={part} className="inline-flex items-center gap-1">
{i > 0 ? <span aria-hidden>·</span> : null}
<ListCardMeta>{part}</ListCardMeta>
</span>
))}
</div>
) : null}
{/* Status pill */}
{yacht.status ? (
<div className="mt-1.5">
<span
className={cn(
'inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium',
statusColor,
)}
>
{statusLabel}
</span>
</div>
) : null}
</div>
</div>
</ListCard>
);
}

View File

@@ -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) => (
<YachtCard
yacht={row.original}
portSlug={portSlug}
onEdit={setEditYacht}
onArchive={setArchiveYacht}
/>
)}
emptyState={
<EmptyState
title="No yachts found"