diff --git a/archivist/Dockerfile b/archivist/Dockerfile index d39bf80..40a28de 100644 --- a/archivist/Dockerfile +++ b/archivist/Dockerfile @@ -17,6 +17,7 @@ ARG TARGETARCH # with `go mod download` for fully reproducible builds. COPY go.mod main.go ./ COPY internal/ ./internal/ +COPY pb_public/ ./pb_public/ RUN go mod tidy RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -trimpath -ldflags="-s -w" -o /archivist . @@ -34,6 +35,7 @@ RUN mkdir -p /out/etc/ssl/certs /out/usr/share /out/app/pb_data /out/tmp \ FROM scratch COPY --from=build /out/ / COPY --from=build /archivist /app/archivist +COPY --from=build /src/pb_public/ /app/pb_public/ WORKDIR /app ENV TMPDIR=/tmp diff --git a/archivist/arch.md b/archivist/arch.md index e1bb5b1..c2709ed 100644 --- a/archivist/arch.md +++ b/archivist/arch.md @@ -341,6 +341,7 @@ ing, no status change), `corrected` event carrying the field diff (US-22). 12. PDF proxy — the route streams the S3 object, refused without superuser auth (US-34). 13. Dry-run — both modes return the shared response shape and persist nothing (US-35). 14. Full correction — a pending document is re-filed and validated; value errors leave everything unchanged; an unchanged name skips the S3 move and the diff drops file_key (US-36). +15. Static dashboard — the review pages are served from pb_public/ with no external resource (US-30). `POST /api/archivist/poll` (superuser only) runs one poll pass immediately. It exists so the gate and the operator trigger passes on demand instead of waiting for the cron; a fresh upload needs two calls because the poller waits for size/ETag stability across two passes (US-10). diff --git a/archivist/e2e/e2e_test.go b/archivist/e2e/e2e_test.go index baa83a2..e2f139f 100644 --- a/archivist/e2e/e2e_test.go +++ b/archivist/e2e/e2e_test.go @@ -1114,5 +1114,40 @@ ent_events/records/"+id, nil); status < 400 { t.Fatalf("file_key must be absent from the diff: %v", after) } }) + + // 15. Static dashboard (US-30): the review pages are served from + // pb_public/ by the application itself - plain HTML/CSS/JS, nothing + // loaded from a CDN. + t.Run("dashboard: static pages served with no CDN", func(t *testing.T) { + resp, err := http.Get(baseURL + "/") + if err != nil { + t.Fatal(err) + } + data, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != 200 { + t.Fatalf("dashboard index: status %d", resp.StatusCode) + } + html := string(data) + if !strings.Contains(html, "archivist-dashboard") { + t.Fatal("the index page is not the review dashboard") + } + for _, marker := range []string{"src=\"http", "href=\"http", "url(http"} { + if strings.Contains(html, marker) { + t.Fatalf("the dashboard loads an external resource (%s)", marker) + } + } + for _, asset := range []string{"/styles.css", "/common.js", "/app.js", "/detail.js", "/document.html"} { + r, err := http.Get(baseURL + asset) + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(r.Body) + r.Body.Close() + if r.StatusCode != 200 || len(body) == 0 { + t.Fatalf("asset %s: status %d, %d bytes", asset, r.StatusCode, len(body)) + } + } + }) } diff --git a/archivist/pb_public/app.js b/archivist/pb_public/app.js new file mode 100644 index 0000000..5245bc7 --- /dev/null +++ b/archivist/pb_public/app.js @@ -0,0 +1,425 @@ +"use strict"; + +// Document list page (US-32, US-33): server-side sort, per-column typed +// filters, status tabs, pagination, column chooser persisted in the +// browser, bulk single-field edit through one standard PATCH per +// document. + +var state = { + config: null, + meta: [], + page: 1, + perPage: 50, + sort: null, + columns: [], + statusTab: "", + filters: {}, + selected: {}, + docs: [], + total: 0, + totalPages: 1, + counts: {} +}; + +function $(id) { return document.getElementById(id); } + +function esc(v) { return String(v).replace(/'/g, "\\'"); } + +function store(key, val) { + if (val === undefined) { + try { + var raw = localStorage.getItem(key); + return raw ? JSON.parse(raw) : null; + } catch (e) { return null; } + } + try { localStorage.setItem(key, JSON.stringify(val)); } catch (e) {} +} + +function fixedMeta() { + return [ + { name: "status", type: "status", fixed: true }, + { name: "file_key", type: "text", fixed: true }, + { name: "created", type: "date", fixed: true }, + { name: "updated", type: "date", fixed: true } + ]; +} + +function fieldMeta(name) { + for (var i = 0; i < state.meta.length; i++) { + if (state.meta[i].name === name) return state.meta[i]; + } + return null; +} + +async function init() { + var r = await api("GET", "/api/archivist/config"); + if (r.status !== 200) return; + state.config = r.data; + state.meta = state.config.fields.map(function (f) { + return { name: f.name, type: f.type, mandatory: !!f.mandatory }; + }).concat(fixedMeta()); + + var defaults = state.config.fields.filter(function (f) { return f.mandatory; }) + .map(function (f) { return f.name; }).concat(["status", "file_key"]); + var savedCols = store("archivist.columns"); + state.columns = Array.isArray(savedCols) ? savedCols : defaults; + var savedSort = store("archivist.sort"); + state.sort = savedSort && savedSort.field ? savedSort : null; + + initTheme("themeBtn"); + buildChooser(); + bindBulk(); + renderTabs(); + await loadCounts(); + await loadDocs(); +} + +function buildChooser() { + var grid = $("colgrid"); + grid.innerHTML = ""; + state.meta.forEach(function (m) { + var label = document.createElement("label"); + var cb = document.createElement("input"); + cb.type = "checkbox"; + cb.checked = state.columns.indexOf(m.name) >= 0; + cb.addEventListener("change", function () { + var i = state.columns.indexOf(m.name); + if (cb.checked && i < 0) state.columns.push(m.name); + if (!cb.checked && i >= 0) state.columns.splice(i, 1); + store("archivist.columns", state.columns); + state.page = 1; + loadDocs(); + }); + label.appendChild(cb); + label.appendChild(document.createTextNode(" " + m.name)); + grid.appendChild(label); + }); +} + +async function loadCounts() { + var statuses = state.config.statuses || []; + var results = await Promise.all(statuses.map(function (st) { + return api("GET", "/api/collections/documents/records?perPage=1&fields=status&filter=" + + encodeURIComponent("(status='" + esc(st) + "')")); + })); + statuses.forEach(function (st, i) { + state.counts[st] = (results[i].data && results[i].data.totalItems) || 0; + }); + renderTabs(); +} + +function renderTabs() { + var tabs = $("tabs"); + tabs.innerHTML = ""; + var all = document.createElement("button"); + all.type = "button"; + all.className = "tab" + (state.statusTab === "" ? " active" : ""); + all.textContent = "Tous"; + all.addEventListener("click", function () { + state.statusTab = ""; + state.page = 1; + loadDocs(); + }); + tabs.appendChild(all); + (state.config.statuses || []).forEach(function (st) { + var b = document.createElement("button"); + b.type = "button"; + b.className = "tab" + (state.statusTab === st ? " active" : ""); + b.appendChild(document.createTextNode(st)); + var c = document.createElement("span"); + c.className = "count"; + c.textContent = state.counts[st] !== undefined ? String(state.counts[st]) : ""; + b.appendChild(c); + b.addEventListener("click", function () { + state.statusTab = st; + state.page = 1; + loadDocs(); + }); + tabs.appendChild(b); + }); +} + +function buildFilterQuery() { + var parts = []; + if (state.statusTab) parts.push("(status='" + esc(state.statusTab) + "')"); + state.columns.forEach(function (name) { + if (name === "status") return; // owned by the tabs + var m = fieldMeta(name); + var f = state.filters[name]; + if (!m || !f || m.type === "number") return; + if (m.type === "date") { + if (f.from) parts.push("(" + name + ">='" + esc(f.from) + "')"); + if (f.to) parts.push("(" + name + "<='" + esc(f.to) + "')"); + } else if (m.type === "boolean") { + if (f.value === "true" || f.value === "false") parts.push("(" + name + "=" + f.value + ")"); + } else if (m.type === "enum") { + if (f.value) parts.push("(" + name + "='" + esc(f.value) + "')"); + } else { + if (f.value) parts.push("(" + name + "~'" + esc(f.value) + "')"); + } + }); + if (!parts.length) return ""; + return "&filter=" + encodeURIComponent(parts.join(" && ")); +} + +async function loadDocs() { + var sort = state.sort + ? ("&sort=" + (state.sort.dir === "desc" ? "-" : "") + encodeURIComponent(state.sort.field)) + : ""; + var q = "/api/collections/documents/records?page=" + state.page + + "&perPage=" + state.perPage + sort + buildFilterQuery(); + var r = await api("GET", q); + if (r.status !== 200) return; + state.docs = r.data.items || []; + state.total = r.data.totalItems || 0; + state.totalPages = r.data.totalPages || 1; + renderHead(); + renderRows(); + renderPager(); + renderBulkbar(); +} + +function renderHead() { + var thead = $("doctable").tHead; + thead.innerHTML = ""; + var trh = document.createElement("tr"); + var thAll = document.createElement("th"); + var cbAll = document.createElement("input"); + cbAll.type = "checkbox"; + cbAll.addEventListener("change", function () { + state.docs.forEach(function (d) { + if (cbAll.checked) state.selected[d.id] = true; + else delete state.selected[d.id]; + }); + renderRows(); + renderBulkbar(); + }); + thAll.appendChild(cbAll); + trh.appendChild(thAll); + state.columns.forEach(function (name) { + var th = document.createElement("th"); + th.textContent = name + (state.sort && state.sort.field === name + ? (state.sort.dir === "desc" ? " ↓" : " ↑") : ""); + th.addEventListener("click", function () { + if (!state.sort || state.sort.field !== name) { + state.sort = { field: name, dir: "asc" }; + } else if (state.sort.dir === "asc") { + state.sort.dir = "desc"; + } else { + state.sort = null; + } + store("archivist.sort", state.sort); + state.page = 1; + loadDocs(); + }); + trh.appendChild(th); + }); + thead.appendChild(trh); + + var trf = document.createElement("tr"); + trf.appendChild(document.createElement("td")); + state.columns.forEach(function (name) { + var td = document.createElement("td"); + if (name !== "status") { + var m = fieldMeta(name); + if (m && m.type !== "number") { + td.appendChild(buildFilterControl(m)); + } + } + trf.appendChild(td); + }); + thead.appendChild(trf); +} + +function buildFilterControl(m) { + var f = state.filters[m.name] || (state.filters[m.name] = {}); + var wrap = document.createElement("span"); + if (m.type === "date") { + var from = document.createElement("input"); + from.type = "date"; + from.value = f.from || ""; + var to = document.createElement("input"); + to.type = "date"; + to.value = f.to || ""; + from.addEventListener("change", function () { f.from = from.value; state.page = 1; loadDocs(); }); + to.addEventListener("change", function () { f.to = to.value; state.page = 1; loadDocs(); }); + wrap.appendChild(from); + wrap.appendChild(to); + } else if (m.type === "boolean") { + var sel = document.createElement("select"); + addOption(sel, "", "—"); + addOption(sel, "true", "oui"); + addOption(sel, "false", "non"); + sel.value = f.value || ""; + sel.addEventListener("change", function () { f.value = sel.value; state.page = 1; loadDocs(); }); + wrap.appendChild(sel); + } else if (m.type === "enum") { + var es = document.createElement("select"); + addOption(es, "", "—"); + ((state.config.enums || {})[m.name] || []).forEach(function (v) { addOption(es, v, v); }); + es.value = f.value || ""; + es.addEventListener("change", function () { f.value = es.value; state.page = 1; loadDocs(); }); + wrap.appendChild(es); + } else { + var inp = document.createElement("input"); + inp.type = "text"; + inp.placeholder = "contient…"; + inp.value = f.value || ""; + var timer = null; + inp.addEventListener("input", function () { + if (timer) clearTimeout(timer); + timer = setTimeout(function () { f.value = inp.value; state.page = 1; loadDocs(); }, 300); + }); + wrap.appendChild(inp); + } + return wrap; +} + +function renderRows() { + var tbody = $("doctable").tBodies[0]; + tbody.innerHTML = ""; + state.docs.forEach(function (doc) { + var tr = document.createElement("tr"); + tr.className = "clickable"; + var tdcb = document.createElement("td"); + var cb = document.createElement("input"); + cb.type = "checkbox"; + cb.checked = !!state.selected[doc.id]; + cb.addEventListener("click", function (ev) { ev.stopPropagation(); }); + cb.addEventListener("change", function () { + if (cb.checked) state.selected[doc.id] = true; + else delete state.selected[doc.id]; + renderBulkbar(); + }); + tdcb.appendChild(cb); + tr.appendChild(tdcb); + state.columns.forEach(function (name) { + var m = fieldMeta(name); + var td = document.createElement("td"); + if (name === "status") { + var b = document.createElement("span"); + b.className = "badge st-" + doc.status; + b.textContent = doc.status; + td.appendChild(b); + } else if (m && m.type === "date") { + td.textContent = fmtDate(doc[name]); + } else { + td.textContent = fmtVal(doc[name]); + } + tr.appendChild(td); + }); + tr.addEventListener("click", function (ev) { + if (ev.target.tagName === "INPUT") return; + window.location.href = "/document.html?id=" + doc.id; + }); + tbody.appendChild(tr); + }); +} + +function renderPager() { + var pager = $("pager"); + pager.innerHTML = ""; + var prev = document.createElement("button"); + prev.type = "button"; + prev.className = "btn secondary"; + prev.textContent = "‹ Précédent"; + prev.disabled = state.page <= 1; + prev.addEventListener("click", function () { state.page--; loadDocs(); }); + var info = document.createElement("span"); + info.textContent = state.total + " document(s) — page " + state.page + " / " + Math.max(1, state.totalPages); + var next = document.createElement("button"); + next.type = "button"; + next.className = "btn secondary"; + next.textContent = "Suivant ›"; + next.disabled = state.page >= state.totalPages; + next.addEventListener("click", function () { state.page++; loadDocs(); }); + pager.appendChild(prev); + pager.appendChild(info); + pager.appendChild(next); +} + +function bindBulk() { + var sel = $("bulkfield"); + sel.innerHTML = ""; + state.config.fields.forEach(function (f) { addOption(sel, f.name, f.name); }); + sel.addEventListener("change", rebuildBulkValue); + $("bulkapply").addEventListener("click", applyBulk); + $("bulkclear").addEventListener("click", function () { + state.selected = {}; + renderRows(); + renderBulkbar(); + }); + $("bulkall").addEventListener("click", selectAllFiltered); + rebuildBulkValue(); +} + +function bulkField() { + var name = $("bulkfield").value; + for (var i = 0; i < state.config.fields.length; i++) { + if (state.config.fields[i].name === name) return state.config.fields[i]; + } + return null; +} + +function rebuildBulkValue() { + var holder = $("bulkvalue"); + holder.innerHTML = ""; + var f = bulkField(); + if (!f) return; + holder.appendChild(buildValueControl(f, (state.config.enums || {})[f.name] || [], null)); +} + +async function selectAllFiltered() { + var page = 1; + for (;;) { + var q = "/api/collections/documents/records?page=" + page + "&perPage=200&fields=id" + buildFilterQuery(); + var r = await api("GET", q); + if (r.status !== 200) break; + (r.data.items || []).forEach(function (d) { state.selected[d.id] = true; }); + if (page >= (r.data.totalPages || 1)) break; + page++; + } + renderRows(); + renderBulkbar(); +} + +async function applyBulk() { + var f = bulkField(); + if (!f) { alert("Choisissez un champ."); return; } + var el = $("bulkvalue").firstChild; + var value; + try { + var r = readValueControl(f, el); + if (r.clear) { + if (f.mandatory) { alert("Ce champ est obligatoire : une valeur est requise."); return; } + value = null; + } else { + value = r.value; + } + } catch (e) { alert(e.message); return; } + var ids = Object.keys(state.selected); + if (!ids.length) { alert("Aucun document sélectionné."); return; } + $("bulkresult").textContent = "…"; + var ok = 0; + var failed = []; + for (var i = 0; i < ids.length; i++) { + var body = {}; + body[f.name] = value; + var res = await api("PATCH", "/api/collections/documents/records/" + ids[i], body); + if (res.status === 200) ok++; + else failed.push(ids[i] + " : " + errMsg(res)); + } + $("bulkresult").textContent = ok + " document(s) modifié(s)" + + (failed.length ? ", " + failed.length + " échec(s) — " + failed[0] : ""); + state.selected = {}; + await loadCounts(); + await loadDocs(); +} + +function renderBulkbar() { + var n = Object.keys(state.selected).length; + $("bulkbar").hidden = n === 0; + $("bulkcount").textContent = n + " document(s) sélectionné(s)"; +} + +init(); diff --git a/archivist/pb_public/common.js b/archivist/pb_public/common.js new file mode 100644 index 0000000..c815d05 --- /dev/null +++ b/archivist/pb_public/common.js @@ -0,0 +1,155 @@ +"use strict"; + +// Shared helpers of the review-dashboard pages (US-30 to US-36). +// The pages carry no data; everything goes through authenticated API +// calls, the admin session cookie riding along. + +function redirectLogin() { + window.location.href = "/_/"; +} + +function storedToken() { + try { + return localStorage.getItem("archivist.token") || ""; + } catch (e) { + return ""; + } +} + +// api performs one authenticated call. A stale stored token is dropped +// and the call retried once; an unauthenticated visitor is redirected to +// the admin UI (US-30). +async function api(method, path, body) { + var res = await rawFetch(method, path, body, storedToken()); + if (res.status === 401 && storedToken()) { + try { localStorage.removeItem("archivist.token"); } catch (e) {} + res = await rawFetch(method, path, body, ""); + } + if (res.status === 401 || (res.status === 403 && path.indexOf("/api/archivist/") === 0)) { + redirectLogin(); + } + return res; +} + +async function rawFetch(method, path, body, token) { + var opts = { method: method, headers: {} }; + if (token) { + opts.headers["Authorization"] = token; + } + if (body !== undefined) { + opts.headers["Content-Type"] = "application/json"; + opts.body = JSON.stringify(body); + } + var res = await fetch(path, opts); + var data = null; + try { data = await res.json(); } catch (e) { data = null; } + return { status: res.status, data: data }; +} + +function errMsg(res) { + if (res && res.data && res.data.message) return res.data.message; + if (res && res.data) { + var s = JSON.stringify(res.data); + if (s && s !== "{}") return s.slice(0, 300); + } + return "status " + (res ? res.status : "?"); +} + +// initTheme wires the light/dark toggle: the class on is set by +// the inline head script before first paint, the button persists the +// manual choice (arch.md 7.5). +function initTheme(btnId) { + var btn = document.getElementById(btnId); + if (!btn) return; + btn.addEventListener("click", function () { + var dark = document.documentElement.classList.toggle("dark"); + document.documentElement.classList.toggle("light", !dark); + try { + localStorage.setItem("archivist.theme", dark ? "dark" : "light"); + } catch (e) {} + }); +} + +function fmtVal(v) { + if (v === null || v === undefined || v === "") return "—"; + if (typeof v === "boolean") return v ? "true" : "false"; + if (typeof v === "string") return v; + return String(v); +} + +function fmtDate(v) { + if (!v) return "—"; + return String(v).replace("T", " ").slice(0, 16); +} + +function pbDateInput(v) { + if (!v) return ""; + return String(v).slice(0, 10); +} + +function addOption(sel, value, label) { + var o = document.createElement("option"); + o.value = value; + o.textContent = label; + sel.appendChild(o); +} + +// buildValueControl builds the typed control of one field (US-33 bulk +// edit, US-34 form, US-32 filters). +function buildValueControl(meta, vocab, value) { + var el; + var s = (value === null || value === undefined) ? "" : String(value); + switch (meta.type) { + case "number": + el = document.createElement("input"); + el.type = "number"; + el.step = "any"; + el.value = s; + break; + case "date": + el = document.createElement("input"); + el.type = "date"; + el.value = pbDateInput(s); + break; + case "boolean": + el = document.createElement("select"); + addOption(el, "", "—"); + addOption(el, "true", "oui"); + addOption(el, "false", "non"); + el.value = value === true ? "true" : (value === false ? "false" : ""); + break; + case "enum": + el = document.createElement("select"); + addOption(el, "", "—"); + (vocab || []).forEach(function (v) { addOption(el, v, v); }); + el.value = s; + break; + default: + el = document.createElement("input"); + el.type = "text"; + el.value = s; + } + el.dataset.field = meta.name; + return el; +} + +// readValueControl reads the control back: {clear:true} when empty, +// {value:v} otherwise. Throws on a malformed value. +function readValueControl(meta, el) { + var v = el.value; + switch (meta.type) { + case "number": + if (v === "") return { clear: true }; + if (isNaN(Number(v))) throw new Error(meta.name + " : nombre invalide"); + return { value: Number(v) }; + case "date": + if (v === "") return { clear: true }; + return { value: v }; + case "boolean": + if (v === "") return { clear: true }; + return { value: v === "true" }; + default: + if (v === "") return { clear: true }; + return { value: v }; + } +} diff --git a/archivist/pb_public/detail.js b/archivist/pb_public/detail.js new file mode 100644 index 0000000..8464020 --- /dev/null +++ b/archivist/pb_public/detail.js @@ -0,0 +1,294 @@ +"use strict"; + +// Document detail page (US-34 to US-36): PDF preview, typed form, +// pipeline proposals, live rendered-name dry-run, event history, and +// the three distinct exits - draft save, full correction, validation. + +var docId = new URLSearchParams(window.location.search).get("id"); + +var cfg = null; +var record = null; +var controls = {}; // field name -> control element +var originals = {}; // field name -> control value at load +var dirty = {}; // field name -> boolean +var dryErrors = []; +var dryTimer = null; + +function $(id) { return document.getElementById(id); } + +async function init() { + if (!docId) { window.location.href = "/"; return; } + var rc = await api("GET", "/api/archivist/config"); + if (rc.status !== 200) return; + var rr = await api("GET", "/api/collections/documents/records/" + docId); + if (rr.status !== 200) { alert("Document introuvable."); window.location.href = "/"; return; } + cfg = rc.data; + record = rr.data; + + initTheme("themeBtn"); + renderHead(); + var evs = await loadEvents(); + renderImportErrors(evs); + renderHistory(evs); + renderForm(); + bindActions(); + if (record.status === "processed") $("validateBtn").hidden = false; + $("targetname").textContent = "Fichier actuel : " + (record.file_key || "—"); + loadProposals(); +} + +async function loadEvents() { + var q = "/api/collections/document_events/records?perPage=200&sort=timestamp&filter=" + + encodeURIComponent("(document='" + docId + "')"); + var r = await api("GET", q); + return (r.status === 200 && r.data.items) ? r.data.items : []; +} + +function renderHead() { + document.title = "Archivist — " + (record.file_key || docId); + $("docname").textContent = record.file_key || ("Document " + docId); + var st = $("docstatus"); + st.textContent = record.status || ""; + st.className = "badge st-" + record.status; + $("pdf").src = "/api/archivist/documents/" + docId + "/pdf"; +} + +function payloadOf(ev) { + var p = ev.payload; + if (typeof p === "string") { + try { p = JSON.parse(p); } catch (e) { p = {}; } + } + return p || {}; +} + +function renderImportErrors(evs) { + var box = $("importerrors"); + for (var i = 0; i < evs.length; i++) { + var p = payloadOf(evs[i]); + if (evs[i].event_type === "imported" && p.errors && p.errors.length) { + box.hidden = false; + box.innerHTML = ""; + var head = document.createElement("div"); + head.textContent = "Échec de la classification à l'import :"; + box.appendChild(head); + var ul = document.createElement("ul"); + p.errors.forEach(function (e) { + var li = document.createElement("li"); + li.textContent = e; + ul.appendChild(li); + }); + box.appendChild(ul); + return; + } + } + box.hidden = true; +} + +function renderForm() { + var form = $("docform"); + form.innerHTML = ""; + cfg.fields.forEach(function (f) { + var row = document.createElement("div"); + row.className = "fieldrow"; + var label = document.createElement("label"); + label.textContent = f.name + (f.mandatory ? " *" : ""); + var ctl = buildValueControl(f, (cfg.enums || {})[f.name] || [], record[f.name]); + controls[f.name] = ctl; + originals[f.name] = ctl.value; + dirty[f.name] = false; + ctl.addEventListener("input", function () { fieldChanged(f); }); + ctl.addEventListener("change", function () { fieldChanged(f); }); + var prop = document.createElement("div"); + prop.className = "proposal"; + prop.hidden = true; + prop.id = "prop-" + f.name; + row.appendChild(label); + row.appendChild(ctl); + row.appendChild(prop); + form.appendChild(row); + }); +} + +function fieldChanged(f) { + dirty[f.name] = controls[f.name].value !== originals[f.name]; + if (dryTimer) clearTimeout(dryTimer); + dryTimer = setTimeout(runDryRun, 400); +} + +// dirtyBody collects the modified fields for a dry-run, a draft save or +// a full correction; empty or null clears the field. +function dirtyBody() { + var body = {}; + var any = false; + for (var i = 0; i < cfg.fields.length; i++) { + var f = cfg.fields[i]; + if (!dirty[f.name]) continue; + var r; + try { + r = readValueControl(f, controls[f.name]); + } catch (e) { + return { error: e.message }; + } + body[f.name] = r.clear ? null : r.value; + any = true; + } + return { body: body, any: any }; +} + +async function runDryRun() { + var d = dirtyBody(); + if (d.error) { renderDryErrors([d.error]); return; } + var res = await api("POST", "/api/archivist/documents/" + docId + "/dry-run", d.body); + if (res.status !== 200) { renderDryErrors(["dry-run : " + errMsg(res)]); return; } + dryErrors = res.data.errors || []; + renderDryErrors(dryErrors); + if (dryErrors.length) { + $("targetname").textContent = ""; + } else { + $("targetname").textContent = "Nom cible : files/" + (res.data.rendered || ""); + } +} + +function renderDryErrors(errs) { + var box = $("dryerrors"); + if (!errs || !errs.length) { + box.hidden = true; + box.innerHTML = ""; + $("correctBtn").disabled = false; + return; + } + box.hidden = false; + box.innerHTML = ""; + errs.forEach(function (e) { + var div = document.createElement("div"); + div.textContent = e; + box.appendChild(div); + }); + $("correctBtn").disabled = true; // US-35: no full correction while errors remain +} + +// loadProposals runs the classification dry-run (no body) and offers, +// under each field whose proposed value differs from the current one, +// the pipeline value with one-click adoption (US-35). +async function loadProposals() { + var res = await api("POST", "/api/archivist/documents/" + docId + "/dry-run"); + if (res.status !== 200) return; + var props = res.data.fields || {}; + cfg.fields.forEach(function (f) { + var p = props[f.name]; + if (p === null || p === undefined) return; + var raw; + switch (f.type) { + case "date": raw = pbDateInput(p); break; + case "boolean": raw = p ? "true" : "false"; break; + default: raw = String(p); + } + if (raw === originals[f.name]) return; + var slot = document.getElementById("prop-" + f.name); + slot.hidden = false; + slot.innerHTML = ""; + var span = document.createElement("span"); + span.textContent = "Pipeline : " + fmtVal(p); + slot.appendChild(span); + var btn = document.createElement("button"); + btn.type = "button"; + btn.className = "btn secondary"; + btn.textContent = "Adopter"; + btn.addEventListener("click", function () { + controls[f.name].value = raw; + slot.hidden = true; + fieldChanged(f); + }); + slot.appendChild(btn); + }); +} + +function bindActions() { + $("draftBtn").addEventListener("click", draftSave); + $("correctBtn").addEventListener("click", fullCorrect); + $("validateBtn").addEventListener("click", validate); +} + +// Draft save: a standard PATCH, no re-filing, no status change (US-22). +async function draftSave() { + var d = dirtyBody(); + if (d.error) { renderDryErrors([d.error]); return; } + if (!d.any) return; + var res = await api("PATCH", "/api/collections/documents/records/" + docId, d.body); + if (res.status !== 200) { renderDryErrors([errMsg(res)]); return; } + window.location.reload(); +} + +// Full correction: re-filing and validation in one step (US-36). +async function fullCorrect() { + if (dryErrors.length) return; + var d = dirtyBody(); + if (d.error) { renderDryErrors([d.error]); return; } + var res = await api("POST", "/api/archivist/documents/" + docId + "/correct", d.body); + if (res.status === 200) { window.location.reload(); return; } + renderDryErrors((res.data && res.data.errors && res.data.errors.length) + ? res.data.errors : [errMsg(res)]); +} + +// Simple validation of a processed document (US-23). +async function validate() { + var res = await api("PATCH", "/api/collections/documents/records/" + docId, { status: "validated" }); + if (res.status === 200) { window.location.reload(); return; } + renderDryErrors([errMsg(res)]); +} + +function renderHistory(evs) { + var hist = $("history"); + hist.innerHTML = ""; + evs.forEach(function (ev) { + var p = payloadOf(ev); + var item = document.createElement("div"); + item.className = "hist"; + var head = document.createElement("div"); + head.className = "head"; + var b = document.createElement("span"); + b.className = "badge"; + b.textContent = ev.event_type; + head.appendChild(b); + var time = document.createElement("time"); + time.textContent = fmtDate(ev.timestamp); + head.appendChild(time); + item.appendChild(head); + if (p.errors && p.errors.length) { + var ul = document.createElement("ul"); + p.errors.forEach(function (e) { + var li = document.createElement("li"); + li.textContent = e; + ul.appendChild(li); + }); + item.appendChild(ul); + } + var before = p.before || {}; + var after = p.after || {}; + var keys = {}; + Object.keys(before).forEach(function (k) { keys[k] = true; }); + Object.keys(after).forEach(function (k) { keys[k] = true; }); + Object.keys(keys).forEach(function (k) { + var line = document.createElement("div"); + line.className = "diff"; + var ks = document.createElement("span"); + ks.className = "k"; + ks.textContent = k + " : "; + var bv = document.createElement("span"); + bv.textContent = fmtVal(before[k]); + var ar = document.createElement("span"); + ar.className = "arrow"; + ar.textContent = " → "; + var av = document.createElement("span"); + av.textContent = fmtVal(after[k]); + line.appendChild(ks); + line.appendChild(bv); + line.appendChild(ar); + line.appendChild(av); + item.appendChild(line); + }); + hist.appendChild(item); + }); +} + +init(); diff --git a/archivist/pb_public/document.html b/archivist/pb_public/document.html new file mode 100644 index 0000000..6c2c171 --- /dev/null +++ b/archivist/pb_public/document.html @@ -0,0 +1,51 @@ + + + + + +Archivist — Document + + + + +
+
+

← Archivist

+ +
+
+
+
+

+ +
+
+
+ +
+
+ +
+
+ +
+
+ + + +
+
+
+

Historique

+
+
+
+
+
+ + + + diff --git a/archivist/pb_public/index.html b/archivist/pb_public/index.html new file mode 100644 index 0000000..43ec657 --- /dev/null +++ b/archivist/pb_public/index.html @@ -0,0 +1,48 @@ + + + + + +Archivist — Relecture + + + + +
+
+

Archivist — Relecture

+ +
+
+
+
+
+ Colonnes affichées +
+
+ +
+
+ + + +
+
+
+
+
+ + + + diff --git a/archivist/pb_public/styles.css b/archivist/pb_public/styles.css new file mode 100644 index 0000000..c1cba14 --- /dev/null +++ b/archivist/pb_public/styles.css @@ -0,0 +1,196 @@ +/* Archivist review dashboard - Beszel reference design (arch.md 7.5). + One hand-written stylesheet, no framework: semantic HSL tokens, light + and dark pair switched by a class on . */ + +:root, :root.light { + --background: hsl(30 8% 98%); + --foreground: hsl(30 0% 10%); + --card: hsl(30 0% 100%); + --card-foreground: hsl(240 6% 12%); + --primary: hsl(240 5.88% 10%); + --primary-foreground: hsl(30 0% 100%); + --secondary: hsl(240 4.76% 95.88%); + --secondary-foreground: hsl(240 6% 12%); + --muted: hsl(26 6% 94%); + --muted-foreground: hsl(24 2.79% 35.1%); + --accent: hsl(20 23.08% 94%); + --accent-foreground: hsl(24 10% 12%); + --destructive: hsl(0 66% 53%); + --destructive-foreground: hsl(30 0% 100%); + --border: hsl(30 8.11% 85.49%); + --input: hsl(30 4.29% 72.55%); + --ring: hsl(30 3.97% 49.41%); + --table-header: hsl(225 6% 97%); + --status-validated: hsl(140 50% 48%); + --radius: 0.8rem; + color-scheme: light; +} + +:root.dark { + --background: hsl(220 5.5% 9%); + --foreground: hsl(220 2% 97%); + --card: hsl(220 5.5% 10.5%); + --card-foreground: hsl(220 2% 97%); + --primary: hsl(220 2% 96%); + --primary-foreground: hsl(220 4% 10%); + --secondary: hsl(220 4% 16%); + --secondary-foreground: hsl(220 2% 97%); + --muted: hsl(220 6% 16%); + --muted-foreground: hsl(220 4% 67%); + --accent: hsl(220 5% 15.5%); + --accent-foreground: hsl(220 2% 97%); + --destructive: hsl(0 62% 46%); + --destructive-foreground: hsl(220 2% 97%); + --border: hsl(220 3% 17%); + --input: hsl(220 4% 22%); + --ring: hsl(220 4% 80%); + --table-header: hsl(220 6% 13%); + --status-validated: hsl(140 49% 60%); + color-scheme: dark; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--background); + color: var(--foreground); + font-family: Inter, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + font-size: 14px; + line-height: 1.5; +} + +.wrap { max-width: 1200px; margin: 0 auto; padding: 0 1rem; } + +a { color: var(--foreground); text-decoration: none; } +a:hover { text-decoration: underline; } + +.topbar { border-bottom: 1px solid var(--border); background: var(--card); } +.topbar .wrap { display: flex; align-items: center; justify-content: space-between; height: 3.5rem; } +.topbar h1 { font-size: 1rem; font-weight: 600; margin: 0; } +.topbar nav { display: flex; gap: .5rem; align-items: center; } + +.card { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1rem; + margin: 1rem 0; +} + +.btn { + font: inherit; + padding: .4rem .8rem; + border-radius: calc(var(--radius) - 4px); + border: 1px solid var(--border); + background: var(--secondary); + color: var(--secondary-foreground); + cursor: pointer; +} +.btn.primary { background: var(--primary); color: var(--primary-foreground); } +.btn:disabled { opacity: .5; cursor: not-allowed; } +.btn:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } + +input, select { + font: inherit; + padding: .35rem .5rem; + border: 1px solid var(--input); + border-radius: calc(var(--radius) - 4px); + background: var(--card); + color: var(--card-foreground); +} +input:focus-visible, select:focus-visible { outline: 2px solid var(--ring); outline-offset: 1px; } + +.tabs { display: flex; gap: .25rem; margin: 1rem 0 0; flex-wrap: wrap; } +.tab { + font: inherit; + padding: .35rem .8rem; + border-radius: calc(var(--radius) - 4px); + border: 1px solid transparent; + background: transparent; + color: var(--muted-foreground); + cursor: pointer; +} +.tab.active { background: var(--secondary); color: var(--secondary-foreground); border-color: var(--border); } +.tab .count { opacity: .7; margin-left: .35rem; font-size: .85em; } + +.chooser summary { cursor: pointer; color: var(--muted-foreground); } +.colgrid { display: flex; flex-wrap: wrap; gap: .5rem 1.25rem; padding-top: .75rem; } +.colgrid label { display: flex; gap: .35rem; align-items: center; } + +.bulkbar { display: flex; flex-wrap: wrap; gap: .75rem; align-items: center; } +.bulkbar[hidden] { display: none; } +.bulkbar label { display: flex; gap: .5rem; align-items: center; color: var(--muted-foreground); } +#bulkresult { color: var(--muted-foreground); } + +.tablewrap { overflow: auto; border: 1px solid var(--border); border-radius: var(--radius); background: var(--card); } +table { border-collapse: collapse; width: 100%; font-size: 13px; } +thead th { + position: sticky; top: 0; z-index: 1; + background: var(--table-header); + text-align: left; font-weight: 600; + padding: .5rem .6rem; + border-bottom: 1px solid var(--border); + white-space: nowrap; cursor: pointer; user-select: none; +} +thead td { background: var(--table-header); padding: .3rem .6rem; border-bottom: 1px solid var(--border); } +thead td input, thead td select { width: 100%; min-width: 90px; padding: .2rem .35rem; font-size: 12px; } +tbody td { padding: .45rem .6rem; border-bottom: 1px solid var(--border); white-space: nowrap; } +tbody tr.clickable { cursor: pointer; } +tbody tr:hover { background: var(--accent); } + +.badge { + display: inline-block; + padding: .05rem .55rem; + border-radius: 999px; + font-size: 12px; + border: 1px solid var(--border); + background: var(--muted); + color: var(--muted-foreground); +} +.badge.st-pending { background: var(--accent); color: var(--accent-foreground); } +.badge.st-validated { background: var(--status-validated); color: hsl(0 0% 100%); border-color: transparent; } + +.pager { display: flex; gap: 1rem; align-items: center; padding: .75rem 0; color: var(--muted-foreground); } + +.detailhead { display: flex; align-items: center; gap: .75rem; margin: 1rem 0 0; flex-wrap: wrap; } +.detailhead h2 { font-size: 1rem; margin: 0; font-weight: 600; word-break: break-all; } + +.detailgrid { display: grid; grid-template-columns: minmax(320px, 2fr) 3fr; gap: 1rem; align-items: start; margin-top: 1rem; } +@media (max-width: 900px) { .detailgrid { grid-template-columns: 1fr; } } + +.pdfpane { padding: 0; overflow: hidden; height: 75vh; } +.pdfpane iframe { width: 100%; height: 100%; border: 0; display: block; background: var(--muted); } + +.formgrid { display: grid; grid-template-columns: 1fr 1fr; gap: .75rem 1rem; } +@media (max-width: 700px) { .formgrid { grid-template-columns: 1fr; } } +.fieldrow { display: flex; flex-direction: column; gap: .25rem; } +.fieldrow > label { font-size: 12px; color: var(--muted-foreground); } +.fieldrow input, .fieldrow select { width: 100%; } +.mandatory { color: var(--destructive); } +.proposal { font-size: 12px; color: var(--muted-foreground); display: flex; gap: .5rem; align-items: center; } +.proposal[hidden] { display: none; } +.proposal .btn { font-size: 12px; padding: .05rem .5rem; } + +.errbox { + border: 1px solid var(--destructive); + color: var(--destructive); + border-radius: var(--radius); + padding: .75rem 1rem; + margin: .5rem 0; +} +.errbox[hidden] { display: none; } +.errbox ul { margin: .25rem 0 0 1rem; padding: 0; } + +.targetname { font-size: 13px; color: var(--muted-foreground); margin-bottom: .75rem; word-break: break-all; } +.actions { display: flex; gap: .75rem; margin-top: 1rem; flex-wrap: wrap; } + +h3 { margin: 0 0 .5rem; font-size: .9rem; } +.hist { border-bottom: 1px solid var(--border); padding: .5rem 0; } +.hist:last-child { border-bottom: 0; } +.hist .head { display: flex; gap: .75rem; align-items: center; } +.hist time { color: var(--muted-foreground); font-size: 12px; } +.hist ul { margin: .25rem 0 0 1rem; color: var(--destructive); } +.diff { font-size: 13px; margin-top: .15rem; word-break: break-all; } +.diff .k { color: var(--muted-foreground); } +.diff .arrow { color: var(--muted-foreground); padding: 0 .2rem; }