From a8404aac2bad715c8cafcfac61cdf4749d96c8be Mon Sep 17 00:00:00 2001 From: simone castellan Date: Fri, 13 Nov 2026 17:13:58 +0100 Subject: [PATCH] =?UTF-8?q?feat(orchestre):=20V1/c=20=E2=80=94=20scheda=20?= =?UTF-8?q?=C2=ABCerca=C2=BB=20per=20tutti=20i=20posti=20scoperti,=20ragio?= =?UTF-8?q?ni=20senza=20punteggio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nuova scheda nella produzione: un blocco per ogni ruolo con posti scoperti, calcolato all'apertura; fascia a parole (fitLabel) e ragioni (reasonsText) invece di numero e posizione; non convocabili in coda con il motivo; una sola azione «Chiedi disponibilità» che manda gli inviti per ruolo e riepiloga «N posti · M persone contattate». Nessuna migrazione: stesse RPC del Matching (orc_matching_candidates, orc_matching_save_run, orc_invite). Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Af3zn7Y33PA6ZKEsSnzq22 --- orchestre/src/domain/matching.js | 15 +++++ orchestre/src/pages/produzione.js | 104 +++++++++++++++++++++++++++++- orchestre/test/matching.test.mjs | 15 ++++- orchestre/test/pages.test.mjs | 16 +++++ 4 files changed, 146 insertions(+), 4 deletions(-) diff --git a/orchestre/src/domain/matching.js b/orchestre/src/domain/matching.js index 97717114..84d26f68 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 04e00577..cd56883a 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
`); + 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(`
Un attimo…
`); + 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(``); + 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 d86b7071..d43f8a07 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 e338a70e..7fd59da8 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/); +});