Files
pn-new-crm/src/components/admin/reconcile-queue.tsx
Matt 221ae5784e chore(autonomous-session): consolidate uncommitted work from prior session
Bundles the prior autonomous-session output that was sitting unstaged:

- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
  never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
  after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
  redirects (ocr to ai, reports to dashboard, invitations to users),
  docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
  flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
  let-reassign), set-state-in-effect disables in CountryFlag and
  UploadForSigning preview-bytes effect, unused 'confirm' destructures in
  interest contract + reservation tabs, unescaped apostrophe in test-template
  card copy
2026-05-23 00:52:59 +02:00

118 lines
3.9 KiB
TypeScript

'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { AlertTriangle, ArrowRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { EmptyState } from '@/components/ui/empty-state';
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
import { apiFetch } from '@/lib/api/client';
import { CatchUpWizard } from '@/components/berths/catch-up-wizard';
interface ReconcileRow {
id: string;
mooringNumber: string;
area: string | null;
status: string;
statusLastChangedBy: string | null;
statusLastChangedReason: string | null;
statusLastModified: string | null;
}
const STATUS_LABELS: Record<string, string> = {
available: 'Available',
under_offer: 'Under Offer',
sold: 'Sold',
};
const STATUS_PILL: Record<string, StatusPillStatus> = {
available: 'available',
under_offer: 'under_offer',
sold: 'sold',
};
function relativeAge(iso: string | null): string {
if (!iso) return '-';
const days = Math.floor((Date.now() - new Date(iso).getTime()) / 86_400_000);
if (days <= 0) return 'today';
if (days === 1) return 'yesterday';
if (days < 30) return `${days}d ago`;
if (days < 365) return `${Math.floor(days / 30)}mo ago`;
return `${Math.floor(days / 365)}y ago`;
}
export function ReconcileQueue() {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const [wizardBerthId, setWizardBerthId] = useState<string | null>(null);
const { data, isLoading } = useQuery<{ data: ReconcileRow[]; total: number }>({
queryKey: ['berths', 'reconcile-queue'],
queryFn: () => apiFetch('/api/v1/berths/reconcile-queue'),
});
if (isLoading) {
return (
<ul className="rounded-md border bg-white">
{[0, 1, 2].map((i) => (
<li key={i} className="h-14 animate-pulse border-b last:border-b-0 bg-muted/40" />
))}
</ul>
);
}
const rows = data?.data ?? [];
if (rows.length === 0) {
return (
<EmptyState
icon={<AlertTriangle className="h-7 w-7" aria-hidden />}
title="Nothing to reconcile"
body="Every berth that's been flipped manually has a backing interest. Manual status changes will show up here when there's no deal to explain them."
/>
);
}
return (
<>
<ul className="rounded-md border bg-white divide-y">
{rows.map((r) => (
<li key={r.id} className="flex flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3 text-sm">
<div className="min-w-0 flex-1">
<Link
href={`/${portSlug}/berths/${encodeURIComponent(r.mooringNumber)}`}
className="font-medium text-foreground hover:text-brand"
>
{r.mooringNumber}
</Link>
{r.area ? <span className="ml-2 text-xs text-muted-foreground">{r.area}</span> : null}
{r.statusLastChangedReason ? (
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{r.statusLastChangedReason}
</p>
) : null}
</div>
<StatusPill status={STATUS_PILL[r.status] ?? 'pending'}>
{STATUS_LABELS[r.status] ?? r.status}
</StatusPill>
<span className="text-xs tabular-nums text-muted-foreground w-20 text-right">
{relativeAge(r.statusLastModified)}
</span>
<Button size="sm" onClick={() => setWizardBerthId(r.id)} className="gap-1">
Catch up
<ArrowRight className="size-3.5" aria-hidden />
</Button>
</li>
))}
</ul>
<CatchUpWizard
berthId={wizardBerthId}
open={!!wizardBerthId}
onOpenChange={(o) => !o && setWizardBerthId(null)}
/>
</>
);
}