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(`
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