/** * 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 { toastError } from '@/lib/api/toast-error'; 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>( 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>( Object.fromEntries(conflicts.map((c) => [c.field, 'crm'])), ); const apply = useMutation({ mutationFn: async () => { const fieldsToApply: Record = {}; 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) => { toastError(err); }, }); return ( (!open ? onClose() : undefined)}> Review parsed fields The PDF parser extracted these values. Review and apply the ones you trust. {warnings.length > 0 ? (

Warnings

    {warnings.map((w, i) => (
  • {w}
  • ))}
) : null} {autoApplied.length > 0 ? (

Auto-applied ({autoApplied.length})

CRM had no value; the PDF supplied one. Uncheck to skip.

    {autoApplied.map((f) => (
  • setAutoChecked((prev) => ({ ...prev, [f.field]: checked === true })) } />
  • ))}
) : null} {conflicts.length > 0 ? (

Conflicts ({conflicts.length})

Pick which value to keep for each field.

    {conflicts.map((c) => (
  • {c.field}
  • ))}
) : null}
); }