196 lines
6.8 KiB
TypeScript
196 lines
6.8 KiB
TypeScript
|
|
/**
|
||
|
|
* Reconcile-diff dialog (Phase 6b — see plan §4.7b, §14.6).
|
||
|
|
*
|
||
|
|
* Shown after a successful per-berth PDF upload + parse. Surfaces three
|
||
|
|
* sections:
|
||
|
|
* - Warnings (mooring-number mismatch, imperial-vs-metric drift, etc.)
|
||
|
|
* so the rep can abort before applying.
|
||
|
|
* - Auto-applied fields — fields the parser found that the CRM had as null;
|
||
|
|
* these are pre-checked and applied on confirm.
|
||
|
|
* - Conflicts — fields where CRM and PDF disagree on a non-null value.
|
||
|
|
* The rep picks "Keep CRM" or "Use PDF" per row before confirming.
|
||
|
|
*
|
||
|
|
* On confirm, the dialog POSTs to /pdf-versions/parse-results/apply with the
|
||
|
|
* rep-curated `fieldsToApply` map.
|
||
|
|
*/
|
||
|
|
|
||
|
|
'use client';
|
||
|
|
|
||
|
|
import { useState } from 'react';
|
||
|
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||
|
|
import { toast } from 'sonner';
|
||
|
|
|
||
|
|
import { apiFetch } from '@/lib/api/client';
|
||
|
|
import { Button } from '@/components/ui/button';
|
||
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
||
|
|
import {
|
||
|
|
Dialog,
|
||
|
|
DialogContent,
|
||
|
|
DialogDescription,
|
||
|
|
DialogFooter,
|
||
|
|
DialogHeader,
|
||
|
|
DialogTitle,
|
||
|
|
} from '@/components/ui/dialog';
|
||
|
|
|
||
|
|
interface AutoAppliedField {
|
||
|
|
field: string;
|
||
|
|
value: string | number;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface ConflictField {
|
||
|
|
field: string;
|
||
|
|
crmValue: string | number | null;
|
||
|
|
pdfValue: string | number | null;
|
||
|
|
pdfConfidence: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface PdfReconcileDialogProps {
|
||
|
|
berthId: string;
|
||
|
|
versionId: string;
|
||
|
|
autoApplied: AutoAppliedField[];
|
||
|
|
conflicts: ConflictField[];
|
||
|
|
warnings: string[];
|
||
|
|
onClose: () => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function PdfReconcileDialog({
|
||
|
|
berthId,
|
||
|
|
versionId,
|
||
|
|
autoApplied,
|
||
|
|
conflicts,
|
||
|
|
warnings,
|
||
|
|
onClose,
|
||
|
|
}: PdfReconcileDialogProps) {
|
||
|
|
const qc = useQueryClient();
|
||
|
|
// For each auto-applied field: rep can opt out by unchecking.
|
||
|
|
const [autoChecked, setAutoChecked] = useState<Record<string, boolean>>(
|
||
|
|
Object.fromEntries(autoApplied.map((f) => [f.field, true])),
|
||
|
|
);
|
||
|
|
// For each conflict: 'pdf' applies the PDF value, 'crm' keeps CRM (omit from
|
||
|
|
// payload), 'skip' is the same as 'crm' but distinct in the UI for clarity.
|
||
|
|
const [conflictChoice, setConflictChoice] = useState<Record<string, 'pdf' | 'crm'>>(
|
||
|
|
Object.fromEntries(conflicts.map((c) => [c.field, 'crm'])),
|
||
|
|
);
|
||
|
|
|
||
|
|
const apply = useMutation({
|
||
|
|
mutationFn: async () => {
|
||
|
|
const fieldsToApply: Record<string, string | number> = {};
|
||
|
|
for (const f of autoApplied) if (autoChecked[f.field]) fieldsToApply[f.field] = f.value;
|
||
|
|
for (const c of conflicts) {
|
||
|
|
if (conflictChoice[c.field] === 'pdf' && c.pdfValue != null) {
|
||
|
|
fieldsToApply[c.field] = c.pdfValue;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return apiFetch(`/api/v1/berths/${berthId}/pdf-versions/parse-results/apply`, {
|
||
|
|
method: 'POST',
|
||
|
|
body: { versionId, fieldsToApply },
|
||
|
|
});
|
||
|
|
},
|
||
|
|
onSuccess: () => {
|
||
|
|
void qc.invalidateQueries({ queryKey: ['berth', berthId] });
|
||
|
|
void qc.invalidateQueries({ queryKey: ['berth-pdf-versions', berthId] });
|
||
|
|
toast.success('Berth fields updated from PDF.');
|
||
|
|
onClose();
|
||
|
|
},
|
||
|
|
onError: (err: Error) => {
|
||
|
|
toast.error('Apply failed', { description: err.message });
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Dialog open onOpenChange={(open) => (!open ? onClose() : undefined)}>
|
||
|
|
<DialogContent className="max-w-2xl">
|
||
|
|
<DialogHeader>
|
||
|
|
<DialogTitle>Review parsed fields</DialogTitle>
|
||
|
|
<DialogDescription>
|
||
|
|
The PDF parser extracted these values. Review and apply the ones you trust.
|
||
|
|
</DialogDescription>
|
||
|
|
</DialogHeader>
|
||
|
|
|
||
|
|
{warnings.length > 0 ? (
|
||
|
|
<div className="rounded-md border border-yellow-300 bg-yellow-50 p-3 text-sm">
|
||
|
|
<p className="font-medium">Warnings</p>
|
||
|
|
<ul className="mt-1 list-disc pl-5">
|
||
|
|
{warnings.map((w, i) => (
|
||
|
|
<li key={i}>{w}</li>
|
||
|
|
))}
|
||
|
|
</ul>
|
||
|
|
</div>
|
||
|
|
) : null}
|
||
|
|
|
||
|
|
{autoApplied.length > 0 ? (
|
||
|
|
<section>
|
||
|
|
<h3 className="text-sm font-medium">
|
||
|
|
Auto-applied <span className="text-muted-foreground">({autoApplied.length})</span>
|
||
|
|
</h3>
|
||
|
|
<p className="text-xs text-muted-foreground">
|
||
|
|
CRM had no value; the PDF supplied one. Uncheck to skip.
|
||
|
|
</p>
|
||
|
|
<ul className="mt-2 space-y-1">
|
||
|
|
{autoApplied.map((f) => (
|
||
|
|
<li key={f.field} className="flex items-center gap-2 text-sm">
|
||
|
|
<Checkbox
|
||
|
|
id={`auto-${f.field}`}
|
||
|
|
checked={autoChecked[f.field]}
|
||
|
|
onCheckedChange={(checked) =>
|
||
|
|
setAutoChecked((prev) => ({ ...prev, [f.field]: checked === true }))
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
<label htmlFor={`auto-${f.field}`} className="flex-1">
|
||
|
|
<span className="font-medium">{f.field}</span>:{' '}
|
||
|
|
<span className="text-muted-foreground">{String(f.value)}</span>
|
||
|
|
</label>
|
||
|
|
</li>
|
||
|
|
))}
|
||
|
|
</ul>
|
||
|
|
</section>
|
||
|
|
) : null}
|
||
|
|
|
||
|
|
{conflicts.length > 0 ? (
|
||
|
|
<section>
|
||
|
|
<h3 className="text-sm font-medium">
|
||
|
|
Conflicts <span className="text-muted-foreground">({conflicts.length})</span>
|
||
|
|
</h3>
|
||
|
|
<p className="text-xs text-muted-foreground">
|
||
|
|
Pick which value to keep for each field.
|
||
|
|
</p>
|
||
|
|
<ul className="mt-2 space-y-2">
|
||
|
|
{conflicts.map((c) => (
|
||
|
|
<li
|
||
|
|
key={c.field}
|
||
|
|
className="grid grid-cols-[1fr_auto_auto] items-center gap-2 rounded border p-2 text-sm"
|
||
|
|
>
|
||
|
|
<span className="font-medium">{c.field}</span>
|
||
|
|
<Button
|
||
|
|
size="sm"
|
||
|
|
variant={conflictChoice[c.field] === 'crm' ? 'default' : 'outline'}
|
||
|
|
onClick={() => setConflictChoice((prev) => ({ ...prev, [c.field]: 'crm' }))}
|
||
|
|
>
|
||
|
|
Keep: {String(c.crmValue)}
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
size="sm"
|
||
|
|
variant={conflictChoice[c.field] === 'pdf' ? 'default' : 'outline'}
|
||
|
|
onClick={() => setConflictChoice((prev) => ({ ...prev, [c.field]: 'pdf' }))}
|
||
|
|
>
|
||
|
|
Use PDF: {String(c.pdfValue)} ({Math.round(c.pdfConfidence * 100)}%)
|
||
|
|
</Button>
|
||
|
|
</li>
|
||
|
|
))}
|
||
|
|
</ul>
|
||
|
|
</section>
|
||
|
|
) : null}
|
||
|
|
|
||
|
|
<DialogFooter>
|
||
|
|
<Button variant="outline" onClick={onClose}>
|
||
|
|
Cancel
|
||
|
|
</Button>
|
||
|
|
<Button onClick={() => apply.mutate()} disabled={apply.isPending}>
|
||
|
|
{apply.isPending ? 'Applying…' : 'Apply'}
|
||
|
|
</Button>
|
||
|
|
</DialogFooter>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
);
|
||
|
|
}
|