Files
pn-new-crm/src/components/expenses/expense-columns.tsx

193 lines
5.5 KiB
TypeScript
Raw Normal View History

'use client';
import Link from 'next/link';
import { format } from 'date-fns';
import { MoreHorizontal, Pencil, Archive, Eye } from 'lucide-react';
import type { ColumnDef } from '@tanstack/react-table';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
export interface ExpenseRow {
id: string;
establishmentName: string | null;
amount: string;
currency: string;
amountUsd: string | null;
category: string | null;
paymentStatus: string | null;
paymentMethod: string | null;
expenseDate: string;
description: string | null;
payer: string | null;
receiptFileIds: string[] | null;
fix(audit-final): pre-merge hardening + expense receipt UI Final audit pass on feat/berth-recommender (3 parallel Opus agents) caught 5 critical and ~12 high-severity findings. All addressed in-branch; medium/low items deferred to docs/audit-final-deferred.md. Critical: - Add filesystem-backend PUT handler at /api/storage/[token] so presigned uploads stop 405-ing in filesystem mode (every browser-driven berth-PDF + brochure upload was broken). Same token-verify + replay protection as GET, plus magic-byte gate when c=application/pdf. - Forward req.signal into streamExpensePdf so an aborted 1000-receipt export no longer keeps grinding for minutes. - Strengthen Content-Disposition filename sanitization: \s matches CR/LF which would let documentName forge headers; restrict to [\w. -]+ and add filename* RFC 5987 fallback. - Lock public berths feed behind an explicit slug allowlist instead of ?portSlug= enumeration. - Reject cross-port interest_berths upserts (defense-in-depth on top of the recommender SQL port filter). High: - Recommender: width-only feasibility now caps length via L/W ratio so a 200ft berth doesn't surface for a 30ft beam request; total_interest_count filters out junction rows whose interest is in another port. - Mooring normalization follow-up migration (0034) catches un-hyphenated padded forms (A01) the original 0024 WHERE missed. - Send-out rate limit moved AFTER validation and scoped per-(port, user) so typos don't burn a slot and a multi-port rep can't be DoS'd by another tenant. - Default-brochure path now blocks an archived row from sneaking through the partial unique index. - NocoDB import --update-snapshot honoured under --dry-run so reps can refresh the seed JSON without committing DB writes. - PDF export: orderBy desc(expenseDate); apply isNull(archivedAt) when expenseIds are passed (was bypassed); flag rate-unavailable rows with an amber footer instead of silently treating them as 1:1; skip the USD->EUR chain when source already matches target. - expense-form-dialog: revokeObjectURL captures the URL in the closure instead of revoking the still-displayed one; reset upload state on close. - scan/page: handleClearReceipt resets in-flight scan/upload mutations; Save disabled while upload pending. - updateExpense re-asserts receipt-or-acknowledgement at the merged row so PATCH can't slip past the create-time refine. Plus the in-progress receipt upload UI for the expense form dialog (receipt picker + "I have no receipt" checkbox + warning banner) and a noReceiptAcknowledged flag on ExpenseRow for edit-mode hydration. Includes the canonical plan doc (referenced in CLAUDE.md), the handoff prompt, and a deferred-findings index for follow-up issues. 1163/1163 vitest passing. Typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 05:11:26 +02:00
noReceiptAcknowledged?: boolean;
feat(expenses+interests): trip/event grouping (lightweight) Per the trips/events design discussion: instead of building a full events domain (table + CRUD UI + calendar) for the 6–12 yacht shows a year, ship the cheap version that covers the actual asks. Expenses — `tripLabel` free-text: - New `expenses.trip_label` text column (migration 0039) + index for filter / autocomplete lookup. - Validator: createExpenseShape + listExpensesSchema + exportExpensePdfSchema.filter all accept tripLabel. - Service: createExpense + updateExpense persist; listExpenses filters; new `listTripLabels(portId, search?)` returns distinct values ordered by most-recent expenseDate so the autocomplete surfaces recently-used labels first. - New `GET /api/v1/expenses/trip-labels` endpoint (gated by expenses.view) backs the autocomplete. - Form dialog: native `<datalist>` powered by the autocomplete query so reps don't end up with "Palm Beach 2026" / "palm-beach 2026" fragmented across two PDF sections. - Expense list: new "Trip" column (badge) + free-text filter. - Detail page: trip label rendered alongside Category / Payer. - PDF export: GroupBy gains 'trip'; filter.tripLabel narrows the export. Untagged rows fall under "(no trip)". - Trim/normalize on write so " Palm Beach 2026 " === "Palm Beach 2026". Interests — event tagging via existing tag system: - Reps can tag interests with an event tag (e.g. "Palm Beach 2026") via the existing InlineTagEditor on the detail page; tags are port-scoped and reusable. - Interest list now has a TagPicker filter rendered next to the FilterBar so reps can sort prospects by event attended ("show me every lead from Palm Beach"). Hidden 'relation'-typed FilterDefinition for tagIds wires URL round-trip + saved-views capture without rendering inside the FilterBar. - FilterBar deserializer now handles `relation` types as comma-joined arrays on URL load. Why a free-text trip label and not a trips table: - 6–12 events/year doesn't justify a domain. The CRUD UI cost would be most of the engineering, and reps already have the events on their personal calendars. - If usage proves demand for per-event ROI dashboards or richer attribution, promote to a real `trips` table later. Migration path: trip_label → tripId is a backfill+swap. Test status: 1168/1168 vitest. tsc clean. Migration 0039 applied in dev (also caught + fixed an unrelated audit-v3 follow-up: 0037 had `idx_br_interest` colliding with the existing `berth_recommendations.idx_br_interest`; renamed to `idx_brr_interest` / `idx_brr_contract_file`). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 13:46:54 +02:00
tripLabel: string | null;
archivedAt: string | null;
createdAt: string;
feat(phase-b): ship analytics dashboard, alerts, scanner PWA, dedup, audit view Phase B (Insights & Alerts) PR4-11 in one drop. Builds on the schema + service skeletons committed in PRs 1-3. PR4 Analytics dashboard — 4 chart types (funnel/timeline/breakdown/source), date-range picker (today/7d/30d/90d), CSV+PNG export per card. PR5 Alert rail UI + /alerts page — topbar bell w/ live count, dashboard right-rail, three-tab page (active/dismissed/resolved), socket-driven invalidation. Bell lazy-loads list on popover open to keep cold pages fast in non-dashboard routes. PR6 EOI queue tab on documents hub — filters to in-flight EOIs, count surfaces in tab label. PR7 Interests-by-berth tab on berth detail — replaces the stub. PR8 Expense duplicate detection — BullMQ job runs scan on create, yellow banner on detail w/ Merge / Not-a-duplicate, transactional merge consolidates receipts and archives the source. PR9 Receipt scanner PWA + multi-provider AI — port-scoped /scan route in its own (scanner) group with no dashboard chrome, dynamic per-port manifest, OpenAI + Claude provider abstraction, admin OCR settings page (port-level + super-admin global default w/ opt-in fallback), test-connection endpoint, manual-entry fallback when no key is configured. Verify form always shown before save — no ghost rows. PR10 Audit log read view — swap to tsvector full-text search on the existing GIN index, cursor pagination, filters for entity/action/user /date range, batched actor-email resolution. PR11 Real-API tests — opt-in receipt-ocr.spec (admin save+test, optional real-receipt parse via REALAPI_RECEIPT_FIXTURE) and alert-engine socket-fanout spec gated behind RUN_ALERT_ENGINE_REALAPI. Both skip cleanly without their gate envs so CI stays green. Test totals: vitest 690 -> 713, smoke 130 -> 138, realapi +2 opt-in. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:21:55 +02:00
/** Set by the dedup engine when this expense looks like a duplicate of another. */
duplicateOf: string | null;
dedupScannedAt: string | null;
}
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',
};
interface GetColumnsOptions {
portSlug: string;
onEdit: (expense: ExpenseRow) => void;
onArchive: (expense: ExpenseRow) => void;
}
export function getExpenseColumns({
portSlug,
onEdit,
onArchive,
}: GetColumnsOptions): ColumnDef<ExpenseRow, unknown>[] {
return [
{
id: 'expenseDate',
accessorKey: 'expenseDate',
header: 'Date',
cell: ({ getValue }) => (
<span className="text-sm text-muted-foreground">
{format(new Date(getValue() as string), 'MMM d, yyyy')}
</span>
),
},
{
id: 'establishmentName',
accessorKey: 'establishmentName',
header: 'Establishment',
cell: ({ row }) => (
<Link
href={`/${portSlug}/expenses/${row.original.id}`}
className="font-medium text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
{row.original.establishmentName ?? '-'}
</Link>
),
},
{
id: 'amount',
header: 'Amount',
enableSorting: false,
cell: ({ row }) => (
<span className="font-medium tabular-nums">
{Number(row.original.amount).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}{' '}
{row.original.currency}
</span>
),
},
{
id: 'amountUsd',
header: 'USD Equiv.',
enableSorting: false,
cell: ({ row }) =>
row.original.amountUsd ? (
<span className="text-sm text-muted-foreground tabular-nums">
feat(phase-b): ship analytics dashboard, alerts, scanner PWA, dedup, audit view Phase B (Insights & Alerts) PR4-11 in one drop. Builds on the schema + service skeletons committed in PRs 1-3. PR4 Analytics dashboard — 4 chart types (funnel/timeline/breakdown/source), date-range picker (today/7d/30d/90d), CSV+PNG export per card. PR5 Alert rail UI + /alerts page — topbar bell w/ live count, dashboard right-rail, three-tab page (active/dismissed/resolved), socket-driven invalidation. Bell lazy-loads list on popover open to keep cold pages fast in non-dashboard routes. PR6 EOI queue tab on documents hub — filters to in-flight EOIs, count surfaces in tab label. PR7 Interests-by-berth tab on berth detail — replaces the stub. PR8 Expense duplicate detection — BullMQ job runs scan on create, yellow banner on detail w/ Merge / Not-a-duplicate, transactional merge consolidates receipts and archives the source. PR9 Receipt scanner PWA + multi-provider AI — port-scoped /scan route in its own (scanner) group with no dashboard chrome, dynamic per-port manifest, OpenAI + Claude provider abstraction, admin OCR settings page (port-level + super-admin global default w/ opt-in fallback), test-connection endpoint, manual-entry fallback when no key is configured. Verify form always shown before save — no ghost rows. PR10 Audit log read view — swap to tsvector full-text search on the existing GIN index, cursor pagination, filters for entity/action/user /date range, batched actor-email resolution. PR11 Real-API tests — opt-in receipt-ocr.spec (admin save+test, optional real-receipt parse via REALAPI_RECEIPT_FIXTURE) and alert-engine socket-fanout spec gated behind RUN_ALERT_ENGINE_REALAPI. Both skip cleanly without their gate envs so CI stays green. Test totals: vitest 690 -> 713, smoke 130 -> 138, realapi +2 opt-in. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:21:55 +02:00
$
{Number(row.original.amountUsd).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</span>
) : (
<span className="text-sm text-muted-foreground">N/A</span>
),
},
{
id: 'category',
accessorKey: 'category',
header: 'Category',
cell: ({ getValue }) => {
const cat = getValue() as string | null;
if (!cat) return <span className="text-muted-foreground">-</span>;
return (
<Badge variant="outline" className="capitalize text-xs">
{cat.replace(/_/g, ' ')}
</Badge>
);
},
},
feat(expenses+interests): trip/event grouping (lightweight) Per the trips/events design discussion: instead of building a full events domain (table + CRUD UI + calendar) for the 6–12 yacht shows a year, ship the cheap version that covers the actual asks. Expenses — `tripLabel` free-text: - New `expenses.trip_label` text column (migration 0039) + index for filter / autocomplete lookup. - Validator: createExpenseShape + listExpensesSchema + exportExpensePdfSchema.filter all accept tripLabel. - Service: createExpense + updateExpense persist; listExpenses filters; new `listTripLabels(portId, search?)` returns distinct values ordered by most-recent expenseDate so the autocomplete surfaces recently-used labels first. - New `GET /api/v1/expenses/trip-labels` endpoint (gated by expenses.view) backs the autocomplete. - Form dialog: native `<datalist>` powered by the autocomplete query so reps don't end up with "Palm Beach 2026" / "palm-beach 2026" fragmented across two PDF sections. - Expense list: new "Trip" column (badge) + free-text filter. - Detail page: trip label rendered alongside Category / Payer. - PDF export: GroupBy gains 'trip'; filter.tripLabel narrows the export. Untagged rows fall under "(no trip)". - Trim/normalize on write so " Palm Beach 2026 " === "Palm Beach 2026". Interests — event tagging via existing tag system: - Reps can tag interests with an event tag (e.g. "Palm Beach 2026") via the existing InlineTagEditor on the detail page; tags are port-scoped and reusable. - Interest list now has a TagPicker filter rendered next to the FilterBar so reps can sort prospects by event attended ("show me every lead from Palm Beach"). Hidden 'relation'-typed FilterDefinition for tagIds wires URL round-trip + saved-views capture without rendering inside the FilterBar. - FilterBar deserializer now handles `relation` types as comma-joined arrays on URL load. Why a free-text trip label and not a trips table: - 6–12 events/year doesn't justify a domain. The CRUD UI cost would be most of the engineering, and reps already have the events on their personal calendars. - If usage proves demand for per-event ROI dashboards or richer attribution, promote to a real `trips` table later. Migration path: trip_label → tripId is a backfill+swap. Test status: 1168/1168 vitest. tsc clean. Migration 0039 applied in dev (also caught + fixed an unrelated audit-v3 follow-up: 0037 had `idx_br_interest` colliding with the existing `berth_recommendations.idx_br_interest`; renamed to `idx_brr_interest` / `idx_brr_contract_file`). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 13:46:54 +02:00
{
id: 'tripLabel',
accessorKey: 'tripLabel',
header: 'Trip',
enableSorting: false,
cell: ({ getValue }) => {
const trip = getValue() as string | null;
if (!trip) return <span className="text-muted-foreground text-xs">-</span>;
return (
<Badge variant="secondary" className="text-xs font-normal">
{trip}
</Badge>
);
},
},
{
id: 'paymentStatus',
accessorKey: 'paymentStatus',
header: 'Status',
cell: ({ getValue }) => {
const status = (getValue() as string | null) ?? 'unpaid';
const colorClass = PAYMENT_STATUS_COLORS[status] ?? '';
return (
feat(phase-b): ship analytics dashboard, alerts, scanner PWA, dedup, audit view Phase B (Insights & Alerts) PR4-11 in one drop. Builds on the schema + service skeletons committed in PRs 1-3. PR4 Analytics dashboard — 4 chart types (funnel/timeline/breakdown/source), date-range picker (today/7d/30d/90d), CSV+PNG export per card. PR5 Alert rail UI + /alerts page — topbar bell w/ live count, dashboard right-rail, three-tab page (active/dismissed/resolved), socket-driven invalidation. Bell lazy-loads list on popover open to keep cold pages fast in non-dashboard routes. PR6 EOI queue tab on documents hub — filters to in-flight EOIs, count surfaces in tab label. PR7 Interests-by-berth tab on berth detail — replaces the stub. PR8 Expense duplicate detection — BullMQ job runs scan on create, yellow banner on detail w/ Merge / Not-a-duplicate, transactional merge consolidates receipts and archives the source. PR9 Receipt scanner PWA + multi-provider AI — port-scoped /scan route in its own (scanner) group with no dashboard chrome, dynamic per-port manifest, OpenAI + Claude provider abstraction, admin OCR settings page (port-level + super-admin global default w/ opt-in fallback), test-connection endpoint, manual-entry fallback when no key is configured. Verify form always shown before save — no ghost rows. PR10 Audit log read view — swap to tsvector full-text search on the existing GIN index, cursor pagination, filters for entity/action/user /date range, batched actor-email resolution. PR11 Real-API tests — opt-in receipt-ocr.spec (admin save+test, optional real-receipt parse via REALAPI_RECEIPT_FIXTURE) and alert-engine socket-fanout spec gated behind RUN_ALERT_ENGINE_REALAPI. Both skip cleanly without their gate envs so CI stays green. Test totals: vitest 690 -> 713, smoke 130 -> 138, realapi +2 opt-in. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:21:55 +02:00
<Badge variant="outline" className={`capitalize text-xs border ${colorClass}`}>
{status}
</Badge>
);
},
},
{
id: 'actions',
header: '',
enableSorting: false,
size: 48,
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/${portSlug}/expenses/${row.original.id}`}>
<Eye className="mr-2 h-3.5 w-3.5" />
View
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEdit(row.original)}>
<Pencil className="mr-2 h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
feat(phase-b): ship analytics dashboard, alerts, scanner PWA, dedup, audit view Phase B (Insights & Alerts) PR4-11 in one drop. Builds on the schema + service skeletons committed in PRs 1-3. PR4 Analytics dashboard — 4 chart types (funnel/timeline/breakdown/source), date-range picker (today/7d/30d/90d), CSV+PNG export per card. PR5 Alert rail UI + /alerts page — topbar bell w/ live count, dashboard right-rail, three-tab page (active/dismissed/resolved), socket-driven invalidation. Bell lazy-loads list on popover open to keep cold pages fast in non-dashboard routes. PR6 EOI queue tab on documents hub — filters to in-flight EOIs, count surfaces in tab label. PR7 Interests-by-berth tab on berth detail — replaces the stub. PR8 Expense duplicate detection — BullMQ job runs scan on create, yellow banner on detail w/ Merge / Not-a-duplicate, transactional merge consolidates receipts and archives the source. PR9 Receipt scanner PWA + multi-provider AI — port-scoped /scan route in its own (scanner) group with no dashboard chrome, dynamic per-port manifest, OpenAI + Claude provider abstraction, admin OCR settings page (port-level + super-admin global default w/ opt-in fallback), test-connection endpoint, manual-entry fallback when no key is configured. Verify form always shown before save — no ghost rows. PR10 Audit log read view — swap to tsvector full-text search on the existing GIN index, cursor pagination, filters for entity/action/user /date range, batched actor-email resolution. PR11 Real-API tests — opt-in receipt-ocr.spec (admin save+test, optional real-receipt parse via REALAPI_RECEIPT_FIXTURE) and alert-engine socket-fanout spec gated behind RUN_ALERT_ENGINE_REALAPI. Both skip cleanly without their gate envs so CI stays green. Test totals: vitest 690 -> 713, smoke 130 -> 138, realapi +2 opt-in. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:21:55 +02:00
<DropdownMenuItem className="text-destructive" onClick={() => onArchive(row.original)}>
<Archive className="mr-2 h-3.5 w-3.5" />
Archive
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
},
];
}