From 78724c7952751846eb7da782fe0672a4fced5310 Mon Sep 17 00:00:00 2001 From: simone castellan Date: Fri, 13 Nov 2026 17:11:00 +0100 Subject: [PATCH] =?UTF-8?q?feat(orchestre):=20V1/b=20=E2=80=94=20=C2=ABCer?= =?UTF-8?q?ca=20musicista=C2=BB=20dal=20pannello=20dell'elemento,=20con=20?= =?UTF-8?q?deep=20link=20al=20matching?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La riga Organico dell'editor mostra «Cerca musicista →» quando la postazione ha un posto da coprire (scoperto, sostituito, annullato). Il link apre il matching della produzione già sul ruolo della postazione (?item=); la scheda risolve postazione→ruolo con roleForItem() e dice da dove arriva. Nessuna migrazione. Test: orcCercaLink, roleForItem, guardia sulla pagina; sabotaggi presi. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Af3zn7Y33PA6ZKEsSnzq22 --- app.js | 10 +++++++++- app/index.html | 1 + index.template.html | 10 +++++++++- orchestre/src/domain/staffing.js | 11 +++++++++++ orchestre/src/pages/produzione.js | 12 +++++++++++- orchestre/test/pages.test.mjs | 8 ++++++++ orchestre/test/staffing.test.mjs | 12 +++++++++++- src/styles.css | 1 + test/engines.test.mjs | 15 +++++++++++++++ 9 files changed, 76 insertions(+), 4 deletions(-) diff --git a/app.js b/app.js index 5928fc08..d3aa9943 100644 --- a/app.js +++ b/app.js @@ -39782,6 +39782,13 @@ function orcSeatsRedraw(){ if(g){ if(typeof setSvgInner==="function") setSvgInner(g, m); else g.innerHTML=m; } if(typeof renderProps==="function") renderProps(); } +/* «Cerca musicista» (V1/b): apre il matching di Orchestre già sul ruolo di questa postazione. Solo se c'è un posto da coprire. */ +function orcCercaLink(productionId, it, rows){ + if(!productionId || !it || !it.id || !rows || !rows.length) return null; + var open=rows.some(function(r){ var st=String(r&&r.slot_status||"open"); return st==="open"||st==="replaced"||st==="cancelled"; }); + if(!open) return null; + return "/orchestre/admin/produzioni/scheda/?id="+encodeURIComponent(productionId)+"&t=matching&item="+encodeURIComponent(it.id); +} /* la riga «Organico» nel pannello dell'elemento: cosa dice Orchestre di questa postazione */ function renderOrcRow(it){ var row=document.getElementById("pOrcRow"); if(!row) return; @@ -39792,7 +39799,8 @@ function renderOrcRow(it){ var prod=rows?rows[0]:orcSeats.productions[0]; var h='
Organico '+esc(String(prod.production_title||""))+'
'; if(rows){ rows.forEach(function(r){ var who=String(r.musician_name||"").trim(); h+='
'+esc((r.role_name?r.role_name+" \u00b7 ":"")+((who && r.seat_no!=null)?"posto "+r.seat_no+" \u00b7 ":"")+orcSeatLine(r))+'
'; }); - h+='Apri l’organico →'; } + var cerca=orcCercaLink(prod.production_id, it, rows); + h+='Apri l’organico →'+(cerca?'Cerca musicista →':''); } else { h+='
Postazione non ancora nell’organico
Importa le postazioni →'; } row.innerHTML=h; } diff --git a/app/index.html b/app/index.html index 1e49a20d..fc54233a 100644 --- a/app/index.html +++ b/app/index.html @@ -1506,6 +1506,7 @@ .orc-row .orc-hd{font-weight:600;margin-bottom:4px}.orc-row .orc-prod{font-weight:400;color:var(--text-3,#6b7280);margin-left:4px} .orc-row .orc-line{padding:2px 0}.orc-row .orc-line.ok{color:#0d9488}.orc-row .orc-line.wait{color:#2563eb}.orc-row .orc-line.open{color:#b45309}.orc-row .orc-line.off{color:var(--text-3,#6b7280)} .orc-row .orc-link{display:inline-block;margin-top:6px;color:#0d9488;font-weight:600;text-decoration:none;min-height:24px} +.orc-row .orc-link + .orc-link{margin-left:14px} .lbl.sub{font-weight:400;fill:#6b7280;font-size:12px;stroke-width:2.6} /* POSTI NUMERATI (06/10): il numero nella sedia, la fila ai capi, la legenda. Non sono .lbl: «nomi nascosti» e la passata anti-collisione del PDF non li toccano — il numero è il posto, non un nome. */ diff --git a/index.template.html b/index.template.html index e5388a15..8a0cfe61 100644 --- a/index.template.html +++ b/index.template.html @@ -42037,6 +42037,13 @@

Esporta PDF in scala

if(g){ if(typeof setSvgInner==="function") setSvgInner(g, m); else g.innerHTML=m; } if(typeof renderProps==="function") renderProps(); } +/* «Cerca musicista» (V1/b): apre il matching di Orchestre già sul ruolo di questa postazione. Solo se c'è un posto da coprire. */ +function orcCercaLink(productionId, it, rows){ + if(!productionId || !it || !it.id || !rows || !rows.length) return null; + var open=rows.some(function(r){ var st=String(r&&r.slot_status||"open"); return st==="open"||st==="replaced"||st==="cancelled"; }); + if(!open) return null; + return "/orchestre/admin/produzioni/scheda/?id="+encodeURIComponent(productionId)+"&t=matching&item="+encodeURIComponent(it.id); +} /* la riga «Organico» nel pannello dell'elemento: cosa dice Orchestre di questa postazione */ function renderOrcRow(it){ var row=document.getElementById("pOrcRow"); if(!row) return; @@ -42047,7 +42054,8 @@

Esporta PDF in scala

var prod=rows?rows[0]:orcSeats.productions[0]; var h='
Organico '+esc(String(prod.production_title||""))+'
'; if(rows){ rows.forEach(function(r){ var who=String(r.musician_name||"").trim(); h+='
'+esc((r.role_name?r.role_name+" \u00b7 ":"")+((who && r.seat_no!=null)?"posto "+r.seat_no+" \u00b7 ":"")+orcSeatLine(r))+'
'; }); - h+='Apri l’organico →'; } + var cerca=orcCercaLink(prod.production_id, it, rows); + h+='Apri l’organico →'+(cerca?'Cerca musicista →':''); } else { h+='
Postazione non ancora nell’organico
Importa le postazioni →'; } row.innerHTML=h; } diff --git a/orchestre/src/domain/staffing.js b/orchestre/src/domain/staffing.js index ac964bf6..1f362c36 100644 --- a/orchestre/src/domain/staffing.js +++ b/orchestre/src/domain/staffing.js @@ -84,6 +84,17 @@ export function groupStaffing(rows) { return out; } +/* Dal disegno all'organico (V1/b): la postazione `itemId` dell'editor a quale ruolo e a quali posti corrisponde. + Restituisce { role, section, slots, target } o null: `target` è il posto da cercare (il primo non coperto, altrimenti il primo). */ +export function roleForItem(sections, itemId) { + if (!itemId) return null; + for (const section of sections || []) for (const role of section.roles) { + const slots = role.slots.filter((x) => x.item_id === itemId); + if (slots.length) return { role, section, slots, target: slots.find((x) => x.status === "open" || x.status === "replaced" || x.status === "cancelled") || slots[0] }; + } + return null; +} + /* Conteggi per il riepilogo: posti, confermati, scoperti. */ export function staffingCounts(sections) { let seats = 0, filled = 0, open = 0; diff --git a/orchestre/src/pages/produzione.js b/orchestre/src/pages/produzione.js index 9fd73931..04e00577 100644 --- a/orchestre/src/pages/produzione.js +++ b/orchestre/src/pages/produzione.js @@ -4,7 +4,7 @@ import { BASE } from "../config.js"; import { esc, el, toast, confirm, setState, errMsg, fmtDate, fmtDateTime, avvia } from "../ui.js"; import { requireStaff, mountTopbar } from "../auth.js"; import { tabs, REP_KIND } from "../nav.js"; -import { PROD_STATUS, PROD_STATUS_PILL, PROD_KIND, DATE_KIND, PART, SLOT_STATUS, SLOT_PILL, EVENT, INV_STATUS, INV_PILL, INV_EVENT, TEMPLATES, templateSeats, groupStaffing, staffingCounts, suggestedStatus } from "../domain/staffing.js"; +import { roleForItem, PROD_STATUS, PROD_STATUS_PILL, PROD_KIND, DATE_KIND, PART, SLOT_STATUS, SLOT_PILL, EVENT, INV_STATUS, INV_PILL, INV_EVENT, TEMPLATES, templateSeats, groupStaffing, staffingCounts, suggestedStatus } from "../domain/staffing.js"; import * as api from "../api/productions.js"; import * as match from "../api/matching.js"; import * as inv from "../api/invitations.js"; @@ -377,6 +377,9 @@ async function paintMatching() { const roles = sections.flatMap((s) => s.roles.map((r) => ({ ...r, section: s.name, open: r.slots.filter((x) => x.status === "open").length }))); panel.innerHTML = ""; if (!roles.length) { panel.appendChild(el(`
Prima definisci l'organico: il matching lavora su un ruolo alla volta.
`)); return; } + /* deep link dall'editor: ?item= sceglie il ruolo e mette in evidenza il posto (una volta sola) */ + const fromItem = q.get("item") ? roleForItem(sections, q.get("item")) : null; + if (fromItem) mRole = fromItem.role.id; if (!mRole || !roles.find((r) => r.id === mRole)) mRole = (roles.find((r) => r.open > 0) || roles[0]).id; const head = el(`
@@ -384,6 +387,13 @@ async function paintMatching() {

In cima chi va bene per il ruolo, in ordine; accanto a ognuno il perché. Chi non si può convocare resta in fondo, con il motivo. Ogni calcolo resta salvato: la convocazione citerà questa proposta.

`); head.querySelector("#mRole").value = mRole; + if (fromItem) { + const t = fromItem.target, lab = t.item_label || fromItem.role.name; + const note = el(`

`); + note.textContent = "Dalla postazione «" + lab + "» del palco: posto " + t.seat_no + " di «" + fromItem.role.name + "» (" + (SLOT_STATUS[t.status] || t.status).toLowerCase() + (t.musician_name ? ", ora " + t.musician_name : "") + ")."; + head.appendChild(note); + q.delete("item"); history.replaceState(null, "", "?id=" + p.id + "&t=matching&role=" + mRole); + } head.querySelector("#mRole").onchange = (e) => { mRole = e.target.value; history.replaceState(null, "", "?id=" + p.id + "&t=matching&role=" + mRole); loadLastRun(); }; head.querySelector("#mGo").onclick = compute; panel.appendChild(head); diff --git a/orchestre/test/pages.test.mjs b/orchestre/test/pages.test.mjs index afa855de..e338a70e 100644 --- a/orchestre/test/pages.test.mjs +++ b/orchestre/test/pages.test.mjs @@ -787,3 +787,11 @@ test("se il server non risponde la pagina lo dice, e Orchestre non dipende dalle assert.match(wf, /- name: Convocazioni Orchestre \(orc-notify\)\n(?:\s*#[^\n]*\n)*\s+if: always\(\)\n/, "il passo di Orchestre gira comunque"); }); + +/* V1/b: «Cerca musicista» dall'editor porta il matching sul ruolo della postazione (?item=), una volta sola. */ +test("la scheda produzione risolve ?item= in ruolo e posto, e lo consuma", () => { + const prod = readFileSync(join(root, "orchestre/src/pages/produzione.js"), "utf8"); + assert.match(prod, /const fromItem = q\.get\("item"\) \? roleForItem\(sections, q\.get\("item"\)\) : null;\s*\n\s*if \(fromItem\) mRole = fromItem\.role\.id;/, "il ruolo viene dalla postazione"); + 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"); +}); diff --git a/orchestre/test/staffing.test.mjs b/orchestre/test/staffing.test.mjs index e5c00a7b..cc9f26af 100644 --- a/orchestre/test/staffing.test.mjs +++ b/orchestre/test/staffing.test.mjs @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; -import { TEMPLATES, templateSeats, groupStaffing, staffingCounts, suggestedStatus, PROD_STATUS } from "../src/domain/staffing.js"; +import { TEMPLATES, templateSeats, groupStaffing, roleForItem, staffingCounts, suggestedStatus, PROD_STATUS } from "../src/domain/staffing.js"; const root = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); @@ -54,3 +54,13 @@ test("suggestedStatus non tocca gli stati terminali e segue i posti", () => { assert.equal(suggestedStatus({ seats: 3, filled: 0, open: 3 }, "done"), "done"); assert.equal(suggestedStatus({ seats: 3, filled: 3, open: 0 }, "cancelled"), "cancelled"); }); + +test("roleForItem: dalla postazione dell'editor al ruolo e al posto da cercare", () => { + const sec = [{ name: "Archi", roles: [ + { id: "r1", name: "Violini primi", slots: [{ id: "s1", seat_no: 1, status: "confirmed", item_id: "A" }, { id: "s2", seat_no: 2, status: "open", item_id: "A" }, { id: "s3", seat_no: 3, status: "open", item_id: null }] }, + { id: "r2", name: "Viole", slots: [{ id: "s4", seat_no: 1, status: "confirmed", item_id: "B" }] }] }]; + const a = roleForItem(sec, "A"); + assert.equal(a.role.id, "r1"); assert.equal(a.slots.length, 2); assert.equal(a.target.id, "s2", "il primo posto scoperto"); + assert.equal(roleForItem(sec, "B").target.id, "s4", "tutti coperti: il primo"); + assert.equal(roleForItem(sec, "zzz"), null); assert.equal(roleForItem(sec, ""), null); assert.equal(roleForItem(null, "A"), null); +}); diff --git a/src/styles.css b/src/styles.css index 86dc1a6c..b936af7f 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1428,6 +1428,7 @@ svg.names-hidden .orc-name{display:none} .orc-row .orc-hd{font-weight:600;margin-bottom:4px}.orc-row .orc-prod{font-weight:400;color:var(--text-3,#6b7280);margin-left:4px} .orc-row .orc-line{padding:2px 0}.orc-row .orc-line.ok{color:#0d9488}.orc-row .orc-line.wait{color:#2563eb}.orc-row .orc-line.open{color:#b45309}.orc-row .orc-line.off{color:var(--text-3,#6b7280)} .orc-row .orc-link{display:inline-block;margin-top:6px;color:#0d9488;font-weight:600;text-decoration:none;min-height:24px} +.orc-row .orc-link + .orc-link{margin-left:14px} .lbl.sub{font-weight:400;fill:#6b7280;font-size:12px;stroke-width:2.6} /* POSTI NUMERATI (06/10): il numero nella sedia, la fila ai capi, la legenda. Non sono .lbl: «nomi nascosti» e la passata anti-collisione del PDF non li toccano — il numero è il posto, non un nome. */ diff --git a/test/engines.test.mjs b/test/engines.test.mjs index ee271cad..7dae517d 100644 --- a/test/engines.test.mjs +++ b/test/engines.test.mjs @@ -25871,6 +25871,21 @@ t("il layer layOrcSeats sta in sceneMarkup ma non nell'export né nel documento" ok(A.window.__scenePrint !== true, "il flag di stampa è tornato spento dopo l'export"); A.orcSeats.pid = null; A.orcSeats.byItem = {}; }); +t("«Cerca musicista» (V1/b): link al matching di Orchestre sulla postazione, solo se c'è un posto da coprire", () => { + const it = { id: "itm-1" }; + const prod = "11111111-1111-4111-8111-111111111111"; + eq(A.orcCercaLink(prod, it, [{ slot_status: "open" }]), "/orchestre/admin/produzioni/scheda/?id=" + prod + "&t=matching&item=itm-1"); + ok(A.orcCercaLink(prod, it, [{ slot_status: "replaced" }]) !== null, "sostituito = da coprire"); + ok(A.orcCercaLink(prod, it, [{ slot_status: "confirmed" }, { slot_status: "open" }]) !== null, "due posti: basta uno scoperto"); + eq(A.orcCercaLink(prod, it, [{ slot_status: "confirmed" }]), null); + eq(A.orcCercaLink(prod, it, [{ slot_status: "invited" }]), null); + eq(A.orcCercaLink(prod, it, []), null); eq(A.orcCercaLink(prod, it, null), null); + eq(A.orcCercaLink("", it, [{ slot_status: "open" }]), null); eq(A.orcCercaLink(prod, { id: "" }, [{ slot_status: "open" }]), null); + ok(A.orcCercaLink(prod, { id: "a&b=c" }, [{ slot_status: "open" }]).indexOf("item=a%26b%3Dc") > -1, "l'id è codificato"); + const row = appjs.slice(appjs.indexOf("function renderOrcRow("), appjs.indexOf("function deleteSelGuarded(")); + ok(/orcCercaLink\(prod\.production_id, it, rows\)/.test(row) && row.indexOf("Cerca musicista") > -1, "la riga Organico la mostra"); + ok(row.indexOf("Cerca musicista") < row.indexOf("Importa le postazioni"), "e solo nel ramo con le postazioni già nell'organico"); +}); t("eliminare una postazione con una persona sopra passa SEMPRE dalla guardia, e l'annuncio arriva dopo", () => { ok(appjs.indexOf('getElementById("pDel").addEventListener("click", deleteSelGuarded)') > -1, "bottone dell'elemento"); /* il collaudo del 10/09 ha trovato questa scappatoia: «Elimina» sulla selezione multipla cancellava