mirror of
https://github.com/napnap75/multiarch-docker-images.git
synced 2026-11-11 00:22:12 +01:00
feat(dashboard) review UI: static pages, Beszel-styled CSS and vanilla JS (US-30, US-32..36)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
Reference in New Issue
Block a user