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
118 lines
3.9 KiB
TypeScript
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)}
|
|
/>
|
|
</>
|
|
);
|
|
}
|