feat(dashboard) review UI: static pages, Beszel-styled CSS and vanilla JS (US-30, US-32..36)

This commit is contained in:
2026-11-07 19:27:59 +01:00
parent b7bfe1152a
commit 9d8b560bac
9 changed files with 1207 additions and 0 deletions
+2
View File
@@ -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
+1
View File
@@ -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).
+35
View File
@@ -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))
}
}
})
}
+425
View File
@@ -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();
+155
View File
@@ -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 <html> 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 };
}
}
+294
View File
@@ -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();
+51
View File
@@ -0,0 +1,51 @@
<!doctype html>
<html lang="fr" class="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Archivist — Document</title>
<link rel="stylesheet" href="/styles.css">
<script>(function(){try{var t=localStorage.getItem("archivist.theme");var d=t==="dark"||(t!=="light"&&window.matchMedia("(prefers-color-scheme: dark)").matches);document.documentElement.className=d?"dark":"light";}catch(e){document.documentElement.className="light";}})();</script>
</head>
<body class="archivist-dashboard">
<header class="topbar">
<div class="wrap">
<h1><a href="/">← Archivist</a></h1>
<nav>
<a href="/_/">Admin</a>
<button id="themeBtn" type="button" class="btn secondary">Thème</button>
</nav>
</div>
</header>
<main class="wrap">
<div class="detailhead">
<h2 id="docname"></h2>
<span id="docstatus" class="badge"></span>
</div>
<div class="detailgrid">
<section class="card pdfpane">
<iframe id="pdf" title="Aperçu du document"></iframe>
</section>
<section>
<div id="importerrors" class="errbox" hidden></div>
<div class="card">
<div id="targetname" class="targetname"></div>
<div id="dryerrors" class="errbox" hidden></div>
<form id="docform" class="formgrid" onsubmit="return false"></form>
<div class="actions">
<button id="draftBtn" type="button" class="btn primary">Enregistrer le brouillon</button>
<button id="correctBtn" type="button" class="btn primary">Correction complète et validation</button>
<button id="validateBtn" type="button" class="btn secondary" hidden>Valider</button>
</div>
</div>
<div class="card">
<h3>Historique</h3>
<div id="history"></div>
</div>
</section>
</div>
</main>
<script src="/common.js"></script>
<script src="/detail.js"></script>
</body>
</html>
+48
View File
@@ -0,0 +1,48 @@
<!doctype html>
<html lang="fr" class="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Archivist — Relecture</title>
<link rel="stylesheet" href="/styles.css">
<script>(function(){try{var t=localStorage.getItem("archivist.theme");var d=t==="dark"||(t!=="light"&&window.matchMedia("(prefers-color-scheme: dark)").matches);document.documentElement.className=d?"dark":"light";}catch(e){document.documentElement.className="light";}})();</script>
</head>
<body class="archivist-dashboard">
<header class="topbar">
<div class="wrap">
<h1>Archivist — Relecture</h1>
<nav>
<a href="/_/">Admin</a>
<button id="themeBtn" type="button" class="btn secondary">Thème</button>
</nav>
</div>
</header>
<main class="wrap">
<div id="tabs" class="tabs"></div>
<details id="colchooser" class="card chooser">
<summary>Colonnes affichées</summary>
<div id="colgrid" class="colgrid"></div>
</details>
<div id="bulkbar" class="card bulkbar" hidden>
<span id="bulkcount"></span>
<label>Champ <select id="bulkfield"></select></label>
<span id="bulkvalue"></span>
<button id="bulkapply" type="button" class="btn primary">Appliquer</button>
<button id="bulkall" type="button" class="btn secondary">Sélection filtrée entière</button>
<button id="bulkclear" type="button" class="btn secondary">Effacer la sélection</button>
<span id="bulkresult"></span>
</div>
<div class="card">
<div class="tablewrap">
<table id="doctable">
<thead></thead>
<tbody></tbody>
</table>
</div>
<div id="pager" class="pager"></div>
</div>
</main>
<script src="/common.js"></script>
<script src="/app.js"></script>
</body>
</html>
+196
View File
@@ -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 <html>. */
: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; }