/* Review workspace — the hero screen
Left: source viewer (tabs + provenance highlights)
Right: CMR form grouped + goods table + validation panel
Sticky action bar.
*/
// ---- Source documents (rendered as faux paper pages with highlights) ----
const InvoiceDoc = ({ refs, activeKey, onActivate }) => (
SOREMED Industries SARL
Zone Industrielle Sidi Bernoussi · 20600 Casablanca · Maroc
ICE 001745829000034 · RC 132 445
FACTURE COMMERCIALE
N° INV-2026-77231
22 / 05 / 2026
SOREMED Industries SARL
Zone Industrielle Sidi Bernoussi
20600 Casablanca, Maroc
ICE: 001745829000034
Logística Ibérica Distribución S.A.
Calle del Comercio 142
28038 Madrid, España
CIF: A-87614520
Lieu et date de chargement
Port de Casablanca — 24/05/2026
Lieu de déchargement
Entrepôt Vallecas — Madrid (ES)
Documents annexés
Facture INV-2026-77231 · Liste de colisage PKL-77231 · Certificat d'origine EUR.1 N° A 4421987
Désignation
Qté
P.U. €
Total €
Conserves de poisson — sardines 12 pal. 185,00 2 220,00
Textile coton — vêtements travail 8 cart. 62,50 500,00
Pièces auto Renault — radiateurs 3 cs. 118,00 354,00
Phosphates 70BPL — sacs OCP 1 ctn. — —
Total HT 3 074,00 €
Établi à Casablanca, le 22/05/2026 · H. El Khattabi, Service logistique
{/* Highlights — absolutely positioned over the page */}
{refs.filter(r => r.doc === "invoice").map((r) => (
onActivate(r.id)} />
))}
);
const PackingDoc = ({ refs, activeKey, onActivate }) => (
RE: Expédition #DK-88421 — Casablanca → Madrid
De · H. El Khattabi <logistique@soremed.ma>
À · ops+cargonote@atlas-maghreb.ma
Date · 22 mai 2026, 09:12
{`Bonjour,
Veuillez trouver ci-joint les documents pour l'expédition de demain : facture commerciale, liste de colisage et photo du BL signé.
Détails :
— Transporteur retenu : Transports Atlas Maghreb (licence TIR-MA-2241)
— Enlèvement : 24/05/2026 au Port de Casablanca
— Livraison : Entrepôt Vallecas, Madrid
— Contact destinataire : M. Martínez, +34 911 22 88 ●● (texte effacé)
— Tarif : franco, 2 850 € forfait porte-à-porte (cf. devis DV-2026-118)
— Température < 25 °C — livraison sur RDV uniquement
Merci de confirmer la prise en charge.
Cordialement,
H. El Khattabi
Service logistique, SOREMED Industries`}
{refs.filter(r => r.doc === "email").map((r) => (
onActivate(r.id)} />
))}
);
const PhotoDoc = ({ refs, activeKey, onActivate }) => (
IMG_8842.jpg — photo BL manuscrit
2 8 MB · 3024 × 4032
BORDEREAU DE LIVRAISON
Atlas Maghreb · BL-MAN-3318
Lot SRM/77231-C · 3 caisses bois · 168 kg
Lot SRM/77231-D · 1 conteneur 20' · 18 4__ kg (illisible)
Observations: caisses bois (C) légèrement
endommagées au chargement — coin sup. droit.
Signature chauffeur: Y. Benali
{refs.filter(r => r.doc === "photo").map((r) => (
onActivate(r.id)} />
))}
);
const Block = ({ label, children }) => (
{pageRefs.map((r) => (
onActivate && onActivate(r.id)}
title={r.id}
/>
))}
);
}
if (doc.kind === "pdf") {
return (
);
}
return
;
};
// Build the flat list of provenance refs from a real case's fields + goods rows.
// Matches the shape the seeded demo uses for InvoiceDoc/PackingDoc.
const buildRealRefs = (fields) => {
const refs = [];
for (const [key, f] of Object.entries(fields || {})) {
if (key === "goods") continue;
if (!f || typeof f !== "object") continue;
if (f.doc && Array.isArray(f.rect) && f.rect.length === 4) {
refs.push({ id: key, doc: f.doc, rect: f.rect, conf: f.bucket });
}
}
for (const g of (fields && fields.goods) || []) {
if (g.doc && Array.isArray(g.rect) && g.rect.length === 4) {
const minConf = Math.min(g.marksConf, g.packagesConf, g.packingConf, g.natureConf, g.hsConf, g.weightConf, g.volumeConf);
const bucket = minConf >= 0.9 ? "hi" : minConf >= 0.7 ? "med" : "lo";
refs.push({ id: `goods.${g.id}`, doc: g.doc, rect: g.rect, conf: bucket });
}
}
return refs;
};
// Parse a French-formatted number ("4 820", "8,40") to a float.
const parseFrNum = (s) => {
const n = parseFloat(String(s == null ? "" : s).replace(/\s/g, "").replace(",", "."));
return Number.isFinite(n) ? n : 0;
};
const goodsTotals = (rows) => rows.reduce((a, r) => ({
packages: a.packages + parseFrNum(r.packages),
weight: a.weight + parseFrNum(r.weight),
volume: a.volume + parseFrNum(r.volume),
}), { packages: 0, weight: 0, volume: 0 });
const Highlight = ({ r, active, onClick }) => (
r.onHover && r.onHover(r.id)}
/>
);
// ---- The review workspace ----
const ReviewView = ({ data, onApprove, onBack }) => {
const docList = data.heroCase.docs || [];
const [activeField, setActiveField] = React.useState(null);
const [edited, setEdited] = React.useState({});
const [fields, setFields] = React.useState(data.heroCase.fields);
// Real-case provenance refs — re-derive from current (possibly edited) fields.
const realRefs = React.useMemo(() => buildRealRefs(fields), [fields]);
// Open on the doc that actually has the most provenance — otherwise the first.
const [activeDoc, setActiveDoc] = React.useState(() => {
if (realRefs.length) {
const counts = realRefs.reduce((m, r) => ((m[r.doc] = (m[r.doc] || 0) + 1), m), {});
const best = Object.entries(counts).sort((a, b) => b[1] - a[1])[0];
if (best) return best[0];
}
return (docList[0] && docList[0].id) || "invoice";
});
// Field activation also switches doc to its source
const activate = (id, fromDoc) => {
setActiveField(id);
if (fromDoc) {
setActiveDoc(fromDoc);
} else {
const f = fields[id];
if (f && f.doc) setActiveDoc(f.doc);
}
};
const editField = (id, val) => {
setFields((prev) => ({ ...prev, [id]: { ...prev[id], value: val } }));
setEdited((prev) => ({ ...prev, [id]: true }));
};
// Build list of highlight refs from current fields (single-field provenance)
const docRefs = Object.entries(fields)
.filter(([id, f]) => f.doc && f.rect)
.map(([id, f]) => ({ id, doc: f.doc, rect: f.rect, conf: f.bucket }));
const renderConf = (id, score, conflictWith) => {
if (edited[id]) return "edited";
if (conflictWith) return "conflict";
return null;
};
return (
{/* LEFT — doc viewer */}
{docList.map((d) => {
const label = d.url
? (d.label.length > 22 ? d.label.slice(0, 20) + "…" : d.label)
: (d.kind === "email" ? "Email" : d.kind === "image" ? "Photo BL" : d.id === "invoice" ? "Facture" : "Colisage");
return (
setActiveDoc(d.id)} style={{whiteSpace:"nowrap", flexShrink:0}}>
{label}
);
})}
Cliquez un champ pour voir sa source. Cliquez une zone pour ouvrir le champ correspondant.
{(() => {
const docObj = docList.find((d) => d.id === activeDoc);
if (docObj && docObj.url) {
return
;
}
return (
<>
{activeDoc === "invoice" &&
}
{activeDoc === "packing" && }
{activeDoc === "email" && }
{activeDoc === "photo" && }
>
);
})()}
{/* RIGHT — CMR form */}
Dossier CMR
{data.heroCase.ref}
{Object.values(fields).filter(f => f.bucket === "hi").length + Object.keys(edited).length} /{Object.keys(fields).length} champs validés
{/* Validation panel on top */}
activate(id)}/>
{/* PARTIES */}
{/* ROUTE */}
{/* GOODS */}
Ajouter une ligne
}>
{
const row = fields.goods[rowIdx];
setActiveDoc(row.doc || "packing");
setActiveField(`goods.${row.id}`);
}}/>
{(() => {
const tot = goodsTotals(fields.goods);
const fr = (n) => n.toLocaleString("fr-FR", { maximumFractionDigits: 2 });
return (
Total colis : {fr(tot.packages)}
Poids brut total : {fr(tot.weight)} kg
Volume total : {fr(tot.volume)} m³
);
})()}
{/* DOCUMENTS & INSTRUCTIONS */}
{/* PAYMENT & CHARGES */}
À payer par boîte 20
{/* RESERVATIONS */}
{/* ISSUE */}
Signatures (boîtes 22–24) — apposées lors de l'enlèvement et de la livraison, laissées vierges sur le brouillon.
{/* Sticky action bar */}
5 champs à vérifier · {Object.keys(edited).length} modifications manuelles · prêt à 78 %
Régénérer
Enregistrer le brouillon
Valider & générer le CMR
);
};
// ---- Goods table component ----
const GoodsTable = ({ rows, onActivate }) => {
return (
Marques6
Col.7
Emballage8
Nature de la marchandise9
SH10
kg11
m³12
{rows.map((r, idx) => {
const minConf = Math.min(r.marksConf, r.packagesConf, r.packingConf, r.natureConf, r.hsConf, r.weightConf, r.volumeConf);
const cls = minConf >= 0.9 ? "hi" : minConf >= 0.7 ? "med" : "lo";
return (
onActivate(idx)} style={{cursor:"pointer"}}>
{r.marks}
{r.packages}
{r.packing}
{r.nature}
{r.conflict && (
Conflit poids ↗
)}
{r.hs}
{r.weight}
{r.conflict && r.conflict.field === "weight" && (
{r.conflict.other}
)}
{r.volume}
);
})}
);
};
// ---- Charges table ----
const ChargesTable = ({ rows }) => (
Libellé
Payé par l'expéditeur
Payé par le destinataire
{rows.map((r, i) => (
{r.label}
{r.sender}
{r.consignee}
))}
);
// ---- Validation panel ----
const ValidationPanel = ({ issues, fields, onJump }) => {
const [open, setOpen] = React.useState(true);
return (
{issues.length} points à vérifier
· champs requis manquants & faibles confiances
setOpen(!open)}>
{open?"Réduire":"Voir"}
{open && (
{issues.map((it, i) => (
onJump(it.field.split(".")[0])}
style={{display:"flex", alignItems:"start", gap:10, padding:"9px 12px", borderTop: i===0?"0":"1px solid var(--hairline)", cursor:"pointer", fontSize:12.5}}
>
{it.sev === "lo" ? "Faible" : "Moyenne"}
{it.msg}
{it.field}
))}
)}
);
};
window.ReviewView = ReviewView;