Replaced 174 em-dashes (—) with " - " (space-hyphen-space) across 49 files in src/components + src/app. The em-dash reads as a tell-tale "AI-generated" marker per the user's design feedback; hyphens with spaces preserve the connector semantics without the AI tint. Touched only lines outside pure-comment context (// /* * */). Code comments, JSDoc, audit-log strings, structured logging strings, and templates outside the lint scope retain their em-dashes for now — they're not user-visible. Also captured two remaining cases that used the `—` HTML entity instead of the literal character (system-monitoring-dashboard, interest-stage-picker) — replaced with a plain hyphen. Bumped the existing `no-restricted-syntax` rule from `warn` → `error` in eslint.config.mjs scoped to src/components/**/*.tsx + src/app/**/*.tsx. New code reintroducing em-dashes in JSX text now fails the lint gate. Verified: tsc clean, vitest 1448/1448, eslint 0 em-dash warnings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
504 lines
18 KiB
TypeScript
504 lines
18 KiB
TypeScript
'use client';
|
|
|
|
/**
|
|
* Two-step wizard for bulk-creating berths during new-port setup.
|
|
*
|
|
* Step 1: pick the dock letter, the range (start..end mooring number),
|
|
* and the genuinely-standard defaults (tenure, status). Generates one
|
|
* empty row per mooring in the range.
|
|
*
|
|
* Step 2: editable table of the generated rows. Reps fill in per-row
|
|
* dimensions / pontoon / pricing. "Apply to all" inputs at the top
|
|
* of each column copy a value down. Validation is inline.
|
|
*
|
|
* Per PRE-DEPLOY-PLAN § 1.4.13. Drag-fill is a stretch — left as a
|
|
* follow-up; keyboard-friendly "Apply to all" covers most of the
|
|
* speed win without the complexity.
|
|
*/
|
|
|
|
import { useState } from 'react';
|
|
import { useMutation } from '@tanstack/react-query';
|
|
import { useParams, useRouter } from 'next/navigation';
|
|
import { Loader2, Trash2 } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import { apiFetch } from '@/lib/api/client';
|
|
import { toastError } from '@/lib/api/toast-error';
|
|
import { useVocabulary } from '@/hooks/use-vocabulary';
|
|
import { CurrencySelect } from '@/components/shared/currency-select';
|
|
|
|
const DOCK_LETTERS = ['A', 'B', 'C', 'D', 'E'] as const;
|
|
type DockLetter = (typeof DOCK_LETTERS)[number];
|
|
|
|
interface RowDraft {
|
|
mooringNumber: string;
|
|
area: string;
|
|
status: 'available';
|
|
tenureType: 'permanent' | 'fixed_term';
|
|
lengthFt: string;
|
|
widthFt: string;
|
|
draftFt: string;
|
|
sidePontoon: string;
|
|
price: string;
|
|
priceCurrency: string;
|
|
}
|
|
|
|
function genRange(letter: DockLetter, start: number, end: number): RowDraft[] {
|
|
const out: RowDraft[] = [];
|
|
for (let i = start; i <= end; i += 1) {
|
|
out.push({
|
|
mooringNumber: `${letter}${i}`,
|
|
area: letter,
|
|
status: 'available',
|
|
tenureType: 'permanent',
|
|
lengthFt: '',
|
|
widthFt: '',
|
|
draftFt: '',
|
|
sidePontoon: '',
|
|
price: '',
|
|
priceCurrency: 'USD',
|
|
});
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function BulkAddBerthsWizard() {
|
|
const params = useParams<{ portSlug: string }>();
|
|
const portSlug = params?.portSlug ?? '';
|
|
const router = useRouter();
|
|
// Canonical, admin-editable side-pontoon vocabulary (per-port overrides
|
|
// honoured). Falls back to BERTH_SIDE_PONTOON_OPTIONS defaults when the
|
|
// /api/v1/vocabularies request hasn't resolved yet.
|
|
const sidePontoonOptions = useVocabulary('berth_side_pontoon_options');
|
|
|
|
const [step, setStep] = useState<'sequence' | 'edit'>('sequence');
|
|
|
|
// Step 1 state
|
|
const [letter, setLetter] = useState<DockLetter>('A');
|
|
const [rangeStart, setRangeStart] = useState('1');
|
|
const [rangeEnd, setRangeEnd] = useState('10');
|
|
const [tenure, setTenure] = useState<'permanent' | 'fixed_term'>('permanent');
|
|
|
|
// Step 2 state
|
|
const [rows, setRows] = useState<RowDraft[]>([]);
|
|
/** Mooring numbers already present in the port. Populated by the
|
|
* pre-flight check fired during Step 1 → Step 2 transition. */
|
|
const [duplicates, setDuplicates] = useState<Set<string>>(new Set());
|
|
const [checkingDups, setCheckingDups] = useState(false);
|
|
|
|
async function handleGenerate() {
|
|
const s = parseInt(rangeStart, 10);
|
|
const e = parseInt(rangeEnd, 10);
|
|
if (!Number.isFinite(s) || !Number.isFinite(e) || s < 0 || e < s) {
|
|
toast.error('Invalid range');
|
|
return;
|
|
}
|
|
if (e - s > 499) {
|
|
toast.error('Cap is 500 berths per batch.');
|
|
return;
|
|
}
|
|
const seeded = genRange(letter, s, e).map((r) => ({ ...r, tenureType: tenure }));
|
|
|
|
// Pre-flight duplicate check: ask the server which of the generated
|
|
// mooring numbers already exist as non-archived berths in this port.
|
|
// Fail-open if the request errors (the bulk-add endpoint still
|
|
// enforces uniqueness server-side; the pre-flight is a UX nicety).
|
|
setCheckingDups(true);
|
|
try {
|
|
const res = await apiFetch<{ data: { duplicates: string[] } }>(
|
|
'/api/v1/berths/check-duplicates',
|
|
{
|
|
method: 'POST',
|
|
body: { mooringNumbers: seeded.map((r) => r.mooringNumber) },
|
|
},
|
|
);
|
|
setDuplicates(new Set(res.data.duplicates));
|
|
if (res.data.duplicates.length > 0) {
|
|
toast.warning(
|
|
`${res.data.duplicates.length} mooring number${
|
|
res.data.duplicates.length === 1 ? '' : 's'
|
|
} already exist in this port. They're flagged in Step 2.`,
|
|
);
|
|
}
|
|
} catch {
|
|
// Pre-flight failure is non-blocking: the user still proceeds and
|
|
// the server's uniqueness constraint catches collisions at submit.
|
|
setDuplicates(new Set());
|
|
} finally {
|
|
setCheckingDups(false);
|
|
}
|
|
setRows(seeded);
|
|
setStep('edit');
|
|
}
|
|
|
|
/** Drop any rows whose mooring number is a known duplicate. */
|
|
function removeAllDuplicates() {
|
|
setRows((prev) => prev.filter((r) => !duplicates.has(r.mooringNumber)));
|
|
}
|
|
|
|
function setRowField<K extends keyof RowDraft>(idx: number, key: K, value: RowDraft[K]) {
|
|
setRows((prev) => prev.map((r, i) => (i === idx ? { ...r, [key]: value } : r)));
|
|
}
|
|
|
|
function applyToAll<K extends keyof RowDraft>(key: K, value: RowDraft[K]) {
|
|
setRows((prev) => prev.map((r) => ({ ...r, [key]: value })));
|
|
}
|
|
|
|
function removeRow(idx: number) {
|
|
setRows((prev) => prev.filter((_, i) => i !== idx));
|
|
}
|
|
|
|
const mutation = useMutation({
|
|
mutationFn: async () => {
|
|
const payload = {
|
|
berths: rows.map((r) => ({
|
|
mooringNumber: r.mooringNumber,
|
|
area: r.area,
|
|
status: r.status,
|
|
tenureType: r.tenureType,
|
|
lengthFt: r.lengthFt ? Number(r.lengthFt) : undefined,
|
|
widthFt: r.widthFt ? Number(r.widthFt) : undefined,
|
|
draftFt: r.draftFt ? Number(r.draftFt) : undefined,
|
|
price: r.price ? Number(r.price) : undefined,
|
|
priceCurrency: r.priceCurrency || undefined,
|
|
sidePontoon: r.sidePontoon || undefined,
|
|
})),
|
|
};
|
|
const res = await apiFetch<{ data: { inserted: number } }>('/api/v1/berths/bulk-add', {
|
|
method: 'POST',
|
|
body: payload,
|
|
});
|
|
return res.data;
|
|
},
|
|
onSuccess: (data) => {
|
|
toast.success(`Created ${data.inserted} berths`);
|
|
router.push(`/${portSlug}/berths`);
|
|
},
|
|
onError: (err) => toastError(err),
|
|
});
|
|
|
|
if (step === 'sequence') {
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Step 1 - Sequence</CardTitle>
|
|
<CardDescription>
|
|
Pick the dock letter and the mooring-number range. Tenure + status apply to every row;
|
|
everything else (dimensions, pricing, pontoon) is filled per row in Step 2.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-4">
|
|
<div className="space-y-1">
|
|
<Label>Dock letter</Label>
|
|
<Select value={letter} onValueChange={(v) => setLetter(v as DockLetter)}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{DOCK_LETTERS.map((l) => (
|
|
<SelectItem key={l} value={l}>
|
|
{l}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label>Range start</Label>
|
|
<Input value={rangeStart} onChange={(e) => setRangeStart(e.target.value)} />
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label>Range end</Label>
|
|
<Input value={rangeEnd} onChange={(e) => setRangeEnd(e.target.value)} />
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label>Tenure (all rows)</Label>
|
|
<Select
|
|
value={tenure}
|
|
onValueChange={(v) => setTenure(v as 'permanent' | 'fixed_term')}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="permanent">Permanent</SelectItem>
|
|
<SelectItem value="fixed_term">Fixed term</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
Will generate {Math.max(0, parseInt(rangeEnd, 10) - parseInt(rangeStart, 10) + 1)} rows
|
|
(e.g. {letter}
|
|
{rangeStart} … {letter}
|
|
{rangeEnd}).
|
|
</p>
|
|
<Button onClick={handleGenerate} disabled={checkingDups}>
|
|
{checkingDups ? (
|
|
<>
|
|
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" aria-hidden />
|
|
Checking…
|
|
</>
|
|
) : (
|
|
'Continue →'
|
|
)}
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
const remainingDuplicates = rows.filter((r) => duplicates.has(r.mooringNumber));
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Step 2 - Fill in each row</CardTitle>
|
|
<CardDescription>
|
|
Per-row dimensions, pricing, pontoon. Use the “Apply to all” inputs in the
|
|
header to copy a value down every row at once.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{remainingDuplicates.length > 0 ? (
|
|
<div
|
|
role="alert"
|
|
className="mb-3 flex items-start justify-between gap-3 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900"
|
|
>
|
|
<div>
|
|
<div className="font-medium">
|
|
{remainingDuplicates.length} duplicate{' '}
|
|
{remainingDuplicates.length === 1 ? 'mooring number' : 'mooring numbers'} found
|
|
</div>
|
|
<div className="mt-0.5 text-xs">
|
|
{remainingDuplicates
|
|
.slice(0, 8)
|
|
.map((r) => r.mooringNumber)
|
|
.join(', ')}
|
|
{remainingDuplicates.length > 8
|
|
? ` and ${remainingDuplicates.length - 8} more`
|
|
: ''}
|
|
. Submit will fail on these rows. Remove them or change the range.
|
|
</div>
|
|
</div>
|
|
<Button size="sm" variant="outline" onClick={removeAllDuplicates}>
|
|
Remove all duplicates
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-xs">
|
|
<thead>
|
|
<tr className="border-b text-left text-muted-foreground">
|
|
<th scope="col" className="py-2 pr-2">
|
|
Mooring
|
|
</th>
|
|
<th scope="col" className="py-2 pr-2">
|
|
Length (ft)
|
|
</th>
|
|
<th scope="col" className="py-2 pr-2">
|
|
Width (ft)
|
|
</th>
|
|
<th scope="col" className="py-2 pr-2">
|
|
Draft (ft)
|
|
</th>
|
|
<th scope="col" className="py-2 pr-2">
|
|
Side pontoon
|
|
</th>
|
|
<th scope="col" className="py-2 pr-2">
|
|
Price
|
|
</th>
|
|
<th scope="col" className="py-2 pr-2">
|
|
Currency
|
|
</th>
|
|
<th scope="col" className="py-2 pr-2" />
|
|
</tr>
|
|
<tr className="border-b bg-muted/30">
|
|
<td className="py-1 pr-2 text-[10px] text-muted-foreground">apply to all →</td>
|
|
{(
|
|
[
|
|
['lengthFt', 'number'],
|
|
['widthFt', 'number'],
|
|
['draftFt', 'number'],
|
|
] as const
|
|
).map(([k, type]) => (
|
|
<td key={k} className="py-1 pr-2">
|
|
<Input
|
|
className="h-7 text-xs"
|
|
type={type}
|
|
onBlur={(e) => {
|
|
if (e.target.value) applyToAll(k, e.target.value);
|
|
}}
|
|
placeholder="all"
|
|
/>
|
|
</td>
|
|
))}
|
|
<td className="py-1 pr-2">
|
|
<Select
|
|
onValueChange={(v) => applyToAll('sidePontoon', v === '__none__' ? '' : v)}
|
|
>
|
|
<SelectTrigger className="h-7 text-xs">
|
|
<SelectValue placeholder="all" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__none__">(none)</SelectItem>
|
|
{sidePontoonOptions.filter(Boolean).map((p) => (
|
|
<SelectItem key={p} value={p}>
|
|
{p}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</td>
|
|
<td className="py-1 pr-2">
|
|
<Input
|
|
className="h-7 text-xs"
|
|
type="number"
|
|
onBlur={(e) => {
|
|
if (e.target.value) applyToAll('price', e.target.value);
|
|
}}
|
|
placeholder="all"
|
|
/>
|
|
</td>
|
|
<td className="py-1 pr-2">
|
|
<CurrencySelect
|
|
value={undefined}
|
|
onValueChange={(v) => applyToAll('priceCurrency', v)}
|
|
className="h-7 text-xs"
|
|
/>
|
|
</td>
|
|
<td />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.map((row, idx) => {
|
|
const isDup = duplicates.has(row.mooringNumber);
|
|
return (
|
|
<tr
|
|
key={row.mooringNumber}
|
|
className={
|
|
isDup ? 'border-b last:border-b-0 bg-amber-50/60' : 'border-b last:border-b-0'
|
|
}
|
|
>
|
|
<td className="py-1 pr-2 font-medium">
|
|
{row.mooringNumber}
|
|
{isDup ? (
|
|
<span className="ml-1 inline-flex items-center rounded-sm bg-amber-200/70 px-1 text-[10px] font-semibold uppercase tracking-wide text-amber-900">
|
|
Dup
|
|
</span>
|
|
) : null}
|
|
</td>
|
|
<td className="py-1 pr-2">
|
|
<Input
|
|
className="h-7 text-xs"
|
|
type="number"
|
|
value={row.lengthFt}
|
|
onChange={(e) => setRowField(idx, 'lengthFt', e.target.value)}
|
|
/>
|
|
</td>
|
|
<td className="py-1 pr-2">
|
|
<Input
|
|
className="h-7 text-xs"
|
|
type="number"
|
|
value={row.widthFt}
|
|
onChange={(e) => setRowField(idx, 'widthFt', e.target.value)}
|
|
/>
|
|
</td>
|
|
<td className="py-1 pr-2">
|
|
<Input
|
|
className="h-7 text-xs"
|
|
type="number"
|
|
value={row.draftFt}
|
|
onChange={(e) => setRowField(idx, 'draftFt', e.target.value)}
|
|
/>
|
|
</td>
|
|
<td className="py-1 pr-2">
|
|
<Select
|
|
value={row.sidePontoon || '__none__'}
|
|
onValueChange={(v) =>
|
|
setRowField(idx, 'sidePontoon', v === '__none__' ? '' : v)
|
|
}
|
|
>
|
|
<SelectTrigger className="h-7 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__none__"> - </SelectItem>
|
|
{sidePontoonOptions.filter(Boolean).map((p) => (
|
|
<SelectItem key={p} value={p}>
|
|
{p}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</td>
|
|
<td className="py-1 pr-2">
|
|
<Input
|
|
className="h-7 text-xs"
|
|
type="number"
|
|
value={row.price}
|
|
onChange={(e) => setRowField(idx, 'price', e.target.value)}
|
|
/>
|
|
</td>
|
|
<td className="py-1 pr-2">
|
|
<CurrencySelect
|
|
value={row.priceCurrency || undefined}
|
|
onValueChange={(v) => setRowField(idx, 'priceCurrency', v)}
|
|
className="h-7 w-24 text-xs"
|
|
/>
|
|
</td>
|
|
<td className="py-1 pr-2">
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="h-7 w-7 p-0"
|
|
onClick={() => removeRow(idx)}
|
|
aria-label={`Remove ${row.mooringNumber}`}
|
|
>
|
|
<Trash2 className="size-3.5" aria-hidden />
|
|
</Button>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div className="mt-4 flex items-center justify-between">
|
|
<Button variant="ghost" onClick={() => setStep('sequence')} disabled={mutation.isPending}>
|
|
← Back
|
|
</Button>
|
|
<Button
|
|
disabled={mutation.isPending || rows.length === 0 || remainingDuplicates.length > 0}
|
|
onClick={() => mutation.mutate()}
|
|
title={
|
|
remainingDuplicates.length > 0
|
|
? 'Resolve the duplicate mooring numbers before submitting.'
|
|
: undefined
|
|
}
|
|
>
|
|
{mutation.isPending ? (
|
|
<Loader2 className="size-4 animate-spin" aria-hidden />
|
|
) : (
|
|
`Create ${rows.length} berths`
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|