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