diff --git a/orchestre/src/domain/matching.js b/orchestre/src/domain/matching.js
index 9771711..84d26f6 100644
--- a/orchestre/src/domain/matching.js
+++ b/orchestre/src/domain/matching.js
@@ -174,6 +174,21 @@ export function applyOverrides(results) {
}
/* La frase per l'amministratore, come nella SPEC. */
+/* Cerca (V1/c): chi sceglie legge il perché, non un numero. Fascia a parole e ragioni senza nome né punteggio. */
+export function fitLabel(r) {
+ if (!r.eligible) return "Non convocabile";
+ return r.score >= 70 ? "Molto adatto" : r.score >= 50 ? "Adatto" : "Con riserve";
+}
+export function reasonsText(r) {
+ const up = (t) => t.charAt(0).toUpperCase() + t.slice(1);
+ if (!r.eligible) return "Manca: " + r.missing.map((m) => m.label.toLowerCase()).join("; ") + ".";
+ const top = r.reasons.filter((x) => x.points > 0).slice(0, 4).map((x) => x.label.charAt(0).toLowerCase() + x.label.slice(1));
+ const neg = r.reasons.filter((x) => x.points < 0).map((x) => x.label.charAt(0).toLowerCase() + x.label.slice(1));
+ let s = top.length ? up(top.join(", ")) + "." : "";
+ if (neg.length) s += (s ? " " : "") + "Attenzione: " + neg.join(", ") + ".";
+ return s || "Nessun elemento di storico: valutazione neutra.";
+}
+
export function explain(r) {
const top = r.reasons.filter((x) => x.points > 0).slice(0, 4).map((x) => x.label.charAt(0).toLowerCase() + x.label.slice(1));
const neg = r.reasons.filter((x) => x.points < 0).map((x) => x.label.charAt(0).toLowerCase() + x.label.slice(1));
diff --git a/orchestre/src/pages/produzione.js b/orchestre/src/pages/produzione.js
index 04e0057..cd56883 100644
--- a/orchestre/src/pages/produzione.js
+++ b/orchestre/src/pages/produzione.js
@@ -9,7 +9,7 @@ import * as api from "../api/productions.js";
import * as match from "../api/matching.js";
import * as inv from "../api/invitations.js";
import * as fb from "../api/feedback.js";
-import { rankCandidates, applyOverrides, explain, ENGINE_VERSION } from "../domain/matching.js";
+import { rankCandidates, applyOverrides, explain, fitLabel, reasonsText, ENGINE_VERSION } from "../domain/matching.js";
import { catalogs, list as listMusicians } from "../api/musicians.js";
import * as sp from "../api/stageplot.js";
import { typeMapFrom, docVariants, extractPositions, proposeRoles, diffProposal, importGroups, importSummary, isUuid } from "../domain/stageplot-import.js";
@@ -17,7 +17,7 @@ import { typeMapFrom, docVariants, extractPositions, proposeRoles, diffProposal,
const app = document.getElementById("app");
const q = new URLSearchParams(location.search);
let ctx = null, p = null, cat = null, tab = q.get("t") || "dati";
-const TABS = [["dati", "Dati"], ["date", "Date"], ["repertorio", "Repertorio"], ["organico", "Organico"], ["matching", "Matching"], ["convocazioni", "Convocazioni"], ["feedback", "Feedback"], ["stageplot", "StagePlot"], ["storia", "Storia"]];
+const TABS = [["dati", "Dati"], ["date", "Date"], ["repertorio", "Repertorio"], ["organico", "Organico"], ["cerca", "Cerca"], ["matching", "Matching"], ["convocazioni", "Convocazioni"], ["feedback", "Feedback"], ["stageplot", "StagePlot"], ["storia", "Storia"]];
async function main() {
ctx = await requireStaff();
@@ -49,7 +49,7 @@ function paint() {
a.onclick = (e) => { if (!p.id) { e.preventDefault(); return; } e.preventDefault(); tab = k; history.replaceState(null, "", "?id=" + p.id + "&t=" + k); paint(); };
nav.appendChild(a);
}
- ({ dati: paintDati, date: paintDate, repertorio: paintRepertorio, organico: paintOrganico, matching: paintMatching, convocazioni: paintConvocazioni, feedback: paintFeedback, stageplot: paintStageplot, storia: paintStoria })[tab]();
+ ({ dati: paintDati, date: paintDate, repertorio: paintRepertorio, organico: paintOrganico, cerca: paintCerca, matching: paintMatching, convocazioni: paintConvocazioni, feedback: paintFeedback, stageplot: paintStageplot, storia: paintStoria })[tab]();
}
/* ---------------------------------------------------------------- Dati */
@@ -518,6 +518,104 @@ function inviteDialog(musicianIds) {
document.body.appendChild(ov);
}
+/* ---------------------------------------------------------------- Cerca (V1/c)
+ Tutti i posti scoperti in una schermata: per ogni ruolo chi va bene e perché (fascia e ragioni, mai un punteggio),
+ si spunta chi interpellare e si manda una sola richiesta di disponibilità. Usa le stesse RPC del Matching. */
+const cSel = new Map(); /* roleId -> Set(musicianId) */
+const cRun = new Map(); /* roleId -> runId del calcolo salvato */
+async function paintCerca() {
+ const panel = app.querySelector("#panel");
+ panel.innerHTML = `
Un attimo…
`;
+ cSel.clear(); cRun.clear();
+ try {
+ sections = groupStaffing(await api.staffing(p.id));
+ mRuleset = await match.activeRuleset(ctx.org.org_id); mWeights = mRuleset.weights;
+ } catch (e) { setState(panel.firstElementChild, "err", errMsg(e)); return; }
+ const roles = sections.flatMap((s) => s.roles.map((r) => ({ ...r, section: s.name, open: r.slots.filter((x) => x.status === "open" || x.status === "replaced").length }))).filter((r) => r.open > 0);
+ panel.innerHTML = "";
+ if (!roles.length) { panel.appendChild(el(`Nessun posto scoperto. Quando ce ne sono, qui trovi chi interpellare per tutti insieme.
`)); return; }
+ const tot = roles.reduce((n, r) => n + r.open, 0);
+ panel.appendChild(el(`${tot} ${tot === 1 ? "posto scoperto" : "posti scoperti"} in ${roles.length} ${roles.length === 1 ? "ruolo" : "ruoli"}. Apri un ruolo per vedere chi va bene e perché; spunta più persone di quanti sono i posti, se vuoi un margine: la prima risposta positiva occupa il posto.
`));
+ const bar = el(`Spunta chi vuoi interpellare Chiedi disponibilità
`);
+ panel.appendChild(bar);
+ const refresh = () => {
+ const n = [...cSel.values()].reduce((a, x) => a + x.size, 0), r = [...cSel.values()].filter((x) => x.size).length;
+ bar.querySelector("#cCount").textContent = n ? n + (n === 1 ? " persona" : " persone") + " per " + r + (r === 1 ? " ruolo" : " ruoli") : "Spunta chi vuoi interpellare";
+ bar.querySelector("#cGo").disabled = !n;
+ };
+ bar.querySelector("#cGo").onclick = () => cercaDialog(roles);
+ for (const r of roles) {
+ const d = el(` `);
+ d.querySelector(".title").textContent = r.section + " · " + r.name;
+ d.querySelector(".pill").textContent = r.open + (r.open === 1 ? " scoperto" : " scoperti");
+ d.querySelector(".sub").textContent = [r.instrument_name, PART[r.part], r.min_level ? "livello ≥ " + r.min_level : ""].filter(Boolean).join(" · ");
+ let loaded = false;
+ d.addEventListener("toggle", async () => { if (!d.open || loaded) return; loaded = true; await cercaRole(d.querySelector(".role-body"), r, refresh); });
+ panel.appendChild(d);
+ if (r.id === q.get("role")) d.open = true;
+ }
+}
+async function cercaRole(body, role, refresh) {
+ try {
+ const data = await match.candidates(p.id, role.id);
+ data.skillNames = Object.fromEntries(cat.skills.map((s) => [s.code, s.name]));
+ const results = rankCandidates(data, mWeights, new Date());
+ try { cRun.set(role.id, await match.saveRun(p.id, role.id, mWeights, results, ENGINE_VERSION)); } catch (_) { /* la proposta resta a schermo anche se il salvataggio fallisce */ }
+ body.innerHTML = "";
+ const ok = results.filter((x) => x.eligible), no = results.filter((x) => !x.eligible);
+ if (!ok.length) body.appendChild(el(`Nessuno in elenco va bene per questo ruolo.
`));
+ const ul = el(``);
+ const row = (r, convocabile) => {
+ const li = el(` `);
+ const cb = li.querySelector("input"); if (!convocabile) cb.disabled = true;
+ cb.onchange = () => { const set = cSel.get(role.id) || new Set(); if (cb.checked) set.add(r.musician_id); else set.delete(r.musician_id); cSel.set(role.id, set); refresh(); };
+ li.querySelector(".title").textContent = r.name;
+ li.querySelector(".sub").innerHTML = `= 50 ? "accent" : "warn") : "danger"}">${esc(fitLabel(r))} ` + (r.warnings || []).map((w) => ` ${esc(w)} `).join("");
+ li.querySelector(".why").textContent = reasonsText(r);
+ return li;
+ };
+ ok.forEach((r) => ul.appendChild(row(r, true)));
+ body.appendChild(ul);
+ if (no.length) {
+ const more = el(` `);
+ more.querySelector(".title").textContent = "Non convocabili (" + no.length + ")";
+ no.forEach((r) => more.querySelector("ul").appendChild(row(r, false)));
+ body.appendChild(more);
+ }
+ } catch (e) { body.innerHTML = ""; const d = el(`
`); d.textContent = errMsg(e); body.appendChild(d); }
+}
+function cercaDialog(roles) {
+ const picks = roles.map((r) => ({ role: r, ids: [...(cSel.get(r.id) || [])] })).filter((x) => x.ids.length);
+ if (!picks.length) return;
+ const posti = picks.reduce((n, x) => n + x.role.open, 0), persone = picks.reduce((n, x) => n + x.ids.length, 0);
+ const dflt = p.reply_deadline ? toLocalInput(p.reply_deadline) : toLocalInput(new Date(Date.now() + 7 * 86400000).toISOString());
+ const ov = el(`
+
Ognuno riceve un'email con un link per rispondere dal telefono: sì, no, o solo alcune date. Le risposte arrivano nella scheda Convocazioni.
+
+
Scadenza per rispondere
+
Nota nell'email
+
Annulla Invia
`);
+ ov.querySelector("h2").textContent = posti + (posti === 1 ? " posto" : " posti") + " · " + persone + (persone === 1 ? " persona" : " persone") + " da interpellare";
+ for (const x of picks) { const li = document.createElement("li"); li.textContent = x.role.name + ": " + x.ids.length + (x.ids.length === 1 ? " persona" : " persone") + " per " + x.role.open + (x.role.open === 1 ? " posto" : " posti"); ov.querySelector("#cList").appendChild(li); }
+ ov.querySelector("#cDl").value = dflt;
+ const close = () => ov.remove();
+ ov.querySelector("#no").onclick = close;
+ ov.addEventListener("click", (e) => { if (e.target === ov) close(); });
+ ov.querySelector("#ok").onclick = async () => {
+ const deadline = fromLocalInput(ov.querySelector("#cDl").value), note = ov.querySelector("#cNote").value.trim();
+ let creati = 0, saltati = 0;
+ try {
+ for (const x of picks) { const n = await inv.invite(p.id, x.role.id, x.ids, { deadline, note, runId: cRun.get(x.role.id) || null }); creati += n; saltati += x.ids.length - n; }
+ close();
+ const esito = creati ? inv.esitoEmail(await inv.notifyNow(p.id)) : "";
+ toast(posti + (posti === 1 ? " posto" : " posti") + " · " + creati + (creati === 1 ? " persona contattata" : " persone contattate")
+ + (saltati > 0 ? "; " + saltati + (saltati === 1 ? " non contattata" : " non contattate") + ": già convocate per quel ruolo o senza consenso alle proposte" : "") + ". " + esito);
+ tab = "convocazioni"; history.replaceState(null, "", "?id=" + p.id + "&t=convocazioni"); paint();
+ } catch (e) { close(); toast(errMsg(e) + (creati ? " (" + creati + " già create: controlla Convocazioni)" : ""), { err: true }); }
+ };
+ document.body.appendChild(ov);
+}
+
/* ---------------------------------------------------------------- Convocazioni */
async function paintConvocazioni() {
const panel = app.querySelector("#panel");
diff --git a/orchestre/test/matching.test.mjs b/orchestre/test/matching.test.mjs
index d86b707..d43f8a0 100644
--- a/orchestre/test/matching.test.mjs
+++ b/orchestre/test/matching.test.mjs
@@ -3,7 +3,7 @@
altri idonei > nuovi (con rotazione). */
import test from "node:test";
import assert from "node:assert/strict";
-import { DEFAULT_WEIGHTS, maxPositive, checkRequirements, scoreCandidate, rankCandidates, applyOverrides, explain } from "../src/domain/matching.js";
+import { DEFAULT_WEIGHTS, maxPositive, checkRequirements, scoreCandidate, rankCandidates, applyOverrides, explain, fitLabel, reasonsText } from "../src/domain/matching.js";
/* punti grezzi → punteggio 0-100 con la scala dei pesi di default */
const pts = (raw, W = DEFAULT_WEIGHTS) => Math.round(50 + (50 * raw) / maxPositive(W));
@@ -166,3 +166,16 @@ test("chi ha scelto di non ricevere proposte non si può convocare, e si dice pe
assert.ok(r.missing.some((m) => m.code === "no_requests" && /non ricevere proposte/.test(m.label)), JSON.stringify(r.missing));
assert.equal(checkRequirements(base({ no_requests: false }), ctx).missing.some((m) => m.code === "no_requests"), false);
});
+
+test("Cerca: fascia a parole e ragioni senza numeri né nome", () => {
+ const base = { eligible: true, name: "Ada Prova", reasons: [{ label: "Ha già suonato Morricone", points: 10 }, { label: "Lettura a prima vista", points: 5 }, { label: "Due assenze", points: -4 }], missing: [], warnings: [] };
+ assert.equal(fitLabel({ ...base, score: 85 }), "Molto adatto");
+ assert.equal(fitLabel({ ...base, score: 60 }), "Adatto");
+ assert.equal(fitLabel({ ...base, score: 40 }), "Con riserve");
+ assert.equal(fitLabel({ ...base, eligible: false, score: 0, missing: [{ label: "Livello insufficiente" }] }), "Non convocabile");
+ const t = reasonsText({ ...base, score: 85 });
+ assert.equal(t, "Ha già suonato Morricone, lettura a prima vista. Attenzione: due assenze.");
+ assert.ok(!/\d+\s*\/\s*100|Ada/.test(t), "niente punteggio, niente nome");
+ assert.equal(reasonsText({ ...base, eligible: false, missing: [{ label: "Livello insufficiente" }] }), "Manca: livello insufficiente.");
+ assert.equal(reasonsText({ ...base, reasons: [], score: 50 }), "Nessun elemento di storico: valutazione neutra.");
+});
diff --git a/orchestre/test/pages.test.mjs b/orchestre/test/pages.test.mjs
index e338a70..7fd59da 100644
--- a/orchestre/test/pages.test.mjs
+++ b/orchestre/test/pages.test.mjs
@@ -795,3 +795,19 @@ test("la scheda produzione risolve ?item= in ruolo e posto, e lo consuma", () =>
assert.match(prod, /q\.delete\("item"\); history\.replaceState\(null, "", "\?id=" \+ p\.id \+ "&t=matching&role=" \+ mRole\)/, "una volta sola: poi l'indirizzo è quello di sempre");
assert.match(prod, /note\.textContent = "Dalla postazione «"/, "dice da dove arriva, col testo messo da textContent");
});
+
+/* V1/c: la scheda Cerca mostra ragioni, non punteggi, e convoca con una sola azione. */
+test("la scheda Cerca: tutti i posti scoperti, fasce e ragioni, nessun punteggio, una azione", () => {
+ const prod = readFileSync(join(root, "orchestre/src/pages/produzione.js"), "utf8");
+ const a = prod.indexOf("async function paintCerca("), b = prod.indexOf("/* ---------------------------------------------------------------- Convocazioni */");
+ const cerca = prod.slice(a, b);
+ assert.ok(a > 0 && b > a);
+ assert.match(prod, /\["organico", "Organico"\], \["cerca", "Cerca"\], \["matching", "Matching"\]/, "la scheda è nel menu, prima del Matching");
+ assert.match(prod, /organico: paintOrganico, cerca: paintCerca,/, "e dispatchata");
+ assert.doesNotMatch(cerca, /\.score\s*\+\s*"|\/100|explain\(|\.rank\b/, "nessun punteggio né posizione scritti a schermo");
+ assert.match(cerca, /fitLabel\(r\)/); assert.match(cerca, /reasonsText\(r\)/);
+ assert.match(cerca, /for \(const x of picks\) \{ const n = await inv\.invite\(p\.id, x\.role\.id, x\.ids,/, "una invite per ruolo, dentro una sola azione");
+ assert.match(cerca, /persone contattate/, "il riepilogo dice posti e persone");
+ assert.match(cerca, /if \(cb\.checked\) set\.add/, "chi non è convocabile ha la casella disabilitata");
+ assert.match(cerca, /if \(!convocabile\) cb\.disabled = true/);
+});