diff --git a/CLAUDE.md b/CLAUDE.md index 6313543..cdb5f38 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1210,6 +1210,14 @@ The split follows what kind of data it is: ### Eine Projektliste (tab `board`, labelled "Projekte") +- **Avatar-Stapel je Person, nicht je Rolle** (Projektliste und Titelzeile + des Projektbereichs, Eriks Wunsch): `beteiligteJePerson()` (`js/utils.js`) + fasst die Rollen einer Person zusammen; `beteiligtenAvatarHtml()` zeichnet + bei mehreren Rollen einen Kreis aus senkrechten Streifen in den + Rollenfarben (`linear-gradient(to right …)`, `ROLLEN_FARBE`, immer volle + Farbe — Haupt/Unterstützung nur im Tooltip, + `.board-avatar.ist-mehrfach`), der Tooltip nennt alle Rollen. + The former `Projekte` tab is gone; `renderBoard()` is the single project list. It kept everything that concerns the LIST and gave up everything that concerns a single project's data: @@ -1226,9 +1234,12 @@ a single project's data: - All four groups from `GROUP_ORDER` are pre-seeded even when empty, so a group emptied by dragging stays a drop target (a bug already fixed once in the old Projekte tab). -- `.board-row-actions` at the end of each row: "Öffnen" (Projektbereich) and - "Löschen" (`delProjectFromList()`), hidden until hover/focus so the list - stays calm. "+ Projekt" sits in the list's title row now (it was only in the +- `.board-row-actions` at the end of each row: a "⋯" button + (`oeffneProjektMenue()` in `board.js`, Eriks Wunsch; same `.user-kebab`/ + `.user-menu` styling as the Benutzerverwaltung) with "Öffnen" + (Projektbereich) and "Löschen" — the latter confirms inside the menu and + then calls `delProjectFromList(id, true)` without `confirm()`. Hidden until + hover/focus so the list stays calm. "+ Projekt" sits in the list's title row now (it was only in the sidebar before); the sidebar entry stays and opens the same mask. - `js/projects/projects.js` no longer renders anything by default: `renderProjects()` returns immediately unless `#projectsList` exists. Every @@ -3540,7 +3551,15 @@ Zuständigen, Meilensteine eine Raute (`--meilenstein`, eigenes Token). **Jeder Block ist einklappbar** (`dashProjUmschalten()`, „Alle einklappen/aufklappen“ in der Abschnittsüberschrift); eingeklappt zeigt der Kopf „N überfällig / N offen“. Der Zustand liegt je `projectUid` in `localStorage` -(`projuebersicht-dash-zu`) — reiner UI-Zustand. **Rechts ein Monatskalender** +(`projuebersicht-dash-zu`) — reiner UI-Zustand. **Reihenfolge der Projekte** (Eriks Wunsch): wie in der Projektliste +(`GROUP_ORDER`, dann `p.order`; `dashSortieren()`), nicht mehr nach dem +dringendsten Termin. Jedes Konto kann sie am Griff vorn im Projektkopf +umziehen (oder Griff fokussieren + Pfeiltasten); gespeichert je Konto am +Server (`users.dashboard_reihenfolge`, JSON-Liste von `projectUid`s, +Migration in `migrateUsersDepartment()`, `POST /dashboard-reihenfolge`, +geliefert als `req.user.dashboardReihenfolge` über `/whoami`). Projekte, die +darin fehlen, folgen dahinter in Listenreihenfolge; „Wie Projektliste“ setzt +zurück. **Rechts ein Monatskalender** (`renderDashKalender()`, KW-Spalte, Feiertage aus `HOLIDAYS`, Punkte für eigene Aufgabe / Projektaufgabe / Meilenstein / überfällig / Abwesenheit, Klick auf einen Tag listet ihn), darunter Freigaben und Abwesenheiten. diff --git a/public/App.html b/public/App.html index cd464ac..80fd275 100644 --- a/public/App.html +++ b/public/App.html @@ -38,7 +38,7 @@ - + @@ -1935,7 +1935,7 @@ - + - + @@ -1977,7 +1977,7 @@ invalidateHonorarConfig(), damit eine Aenderung der Anteile auch seinen Zwischenspeicher verwirft. --> - + diff --git a/public/js/auth.js b/public/js/auth.js index 51ce62b..2601f44 100644 --- a/public/js/auth.js +++ b/public/js/auth.js @@ -301,7 +301,16 @@ async function checkAuthAndInit(onAuthenticated){ document.getElementById('loginPassword').select(); return; } + // Das vollständige Konto (Rechte, Rollenname, Gewerk, Rundgang …) + // liefert nur /whoami — die Antwort von /login enthält nur das Nötigste. + // Ohne diesen Abruf fehlten nach dem Anmelden alle Einträge der + // Seitenleiste, die ein Recht brauchen, bis man die Seite neu lud. currentUser = d.user; + try { + const w = await fetch(baseUrl() + '/whoami'); + const wd = await w.json(); + if(wd && wd.user) currentUser = wd.user; + } catch(e){ /* Rückfall auf d.user */ } document.getElementById('loginPassword').value = ''; afterAuthenticated(onAuthenticated); } catch(err){ diff --git a/public/js/board/board.js b/public/js/board/board.js index 5ac7396..624f86f 100644 --- a/public/js/board/board.js +++ b/public/js/board/board.js @@ -113,6 +113,96 @@ function roleClass(role){ // hier bearbeitbar (nurAnsicht). const BOARD_OPTS = { nurAnsicht: true, nurBeauftragteLph: true }; +const PROJEKT_PUNKTE_SVG = ''; + +// ---------- Zeilenmenü der Projektliste ("⋯") ---------- +// Öffnen und Löschen. Löschen fragt im Menü selbst nach (wie die +// Benutzerverwaltung), nicht über confirm(). Das Menü hängt an mit +// position: fixed und klappt nach oben, wenn unten kein Platz ist. +let projektMenue = null; // { el, knopf, zeile } + +function schliesseProjektMenue(fokusZurueck){ + if(!projektMenue) return; + const { el, knopf, zeile } = projektMenue; + projektMenue = null; + el.remove(); + knopf.setAttribute('aria-expanded', 'false'); + if(zeile) zeile.classList.remove('is-menu-open'); + if(fokusZurueck && document.contains(knopf)) knopf.focus(); +} + +function projektMenuePositionieren(){ + if(!projektMenue) return; + const { el, knopf } = projektMenue; + const r = knopf.getBoundingClientRect(); + const m = el.getBoundingClientRect(); + const nachOben = (window.innerHeight - r.bottom) < m.height + 12 && r.top > m.height + 12; + el.classList.toggle('nach-oben', nachOben); + el.style.top = (nachOben ? r.top - m.height - 6 : r.bottom + 6) + 'px'; + el.style.left = Math.max(8, Math.min(r.right - m.width, window.innerWidth - m.width - 8)) + 'px'; +} + +function oeffneProjektMenue(event, pid, locked){ + event.stopPropagation(); + const knopf = event.currentTarget; + if(projektMenue && projektMenue.knopf === knopf){ schliesseProjektMenue(true); return; } + schliesseProjektMenue(false); + const w = currentWeek(); + const p = w && w.projects.find(x => x.id === pid); + const bez = p ? ((p.nr ? p.nr + ' ' : '') + (p.name || '')).trim() : ''; + const darfLoeschen = !locked && darfProjektlisteAendern(); + + const el = document.createElement('div'); + el.className = 'user-menu'; + el.setAttribute('role', 'menu'); + document.body.appendChild(el); + const zeile = knopf.closest('.board-row'); + projektMenue = { el, knopf, zeile }; + knopf.setAttribute('aria-expanded', 'true'); + if(zeile) zeile.classList.add('is-menu-open'); + + const eintraege = () => { + el.innerHTML = `` + + (darfLoeschen ? `
` : ''); + projektMenuePositionieren(); + }; + eintraege(); + + el.addEventListener('click', e => { + e.stopPropagation(); + const b = e.target.closest('button'); + if(!b) return; + if(b.dataset.zurueck){ eintraege(); return; } + if(b.dataset.aktion === 'oeffnen'){ schliesseProjektMenue(false); openProjectPlan(pid); return; } + if(b.dataset.aktion === 'loeschen'){ + el.innerHTML = `

Projekt ${bez ? '' + esc(bez) + ' ' : ''}löschen? Ein Administrator kann es 30 Tage lang wiederherstellen.

+
+
`; + projektMenuePositionieren(); + el.querySelector('[data-aktion="endgueltig"]').focus(); + return; + } + if(b.dataset.aktion === 'endgueltig'){ schliesseProjektMenue(false); delProjectFromList(pid, true); } + }); + + if(event.detail === 0){ const erstes = el.querySelector('button'); if(erstes) erstes.focus(); } +} + +document.addEventListener('click', () => schliesseProjektMenue(false)); +document.addEventListener('keydown', e => { + if(!projektMenue) return; + const ziele = Array.from(projektMenue.el.querySelectorAll('button')); + const i = ziele.indexOf(document.activeElement); + if(e.key === 'Escape'){ e.preventDefault(); schliesseProjektMenue(true); } + else if(e.key === 'ArrowDown'){ e.preventDefault(); if(ziele.length) ziele[(i + 1) % ziele.length].focus(); } + else if(e.key === 'ArrowUp'){ e.preventDefault(); if(ziele.length) ziele[(i - 1 + ziele.length) % ziele.length].focus(); } + else if(e.key === 'Tab') schliesseProjektMenue(false); +}); +window.addEventListener('resize', () => schliesseProjektMenue(false)); +window.addEventListener('scroll', () => schliesseProjektMenue(false), true); + function renderBoardRow(p, allPeople, locked, weekLabel, opts){ const isOpen = !!boardRowOpen[p.id]; const tags = Array.isArray(p.tags) ? p.tags : []; @@ -123,11 +213,10 @@ function renderBoardRow(p, allPeople, locked, weekLabel, opts){ // Avatar-Stapel der eingeklappten Zeile: nur tatsächlich zugeordnete // Personen (Zustand 1 = Haupt oder 2 = optional), volle Rollen-Matrix nur // im aufgeklappten Detailpanel (siehe renderBoardDrawer). - const stackHtml = allPeople.map(entry=>{ - const state = p.assign[assignKey(entry.person.id, entry.role)] || 0; - if(state === 0) return ''; - return `${esc(entry.person.name)}`; - }).join(''); + // Eine Person mit mehreren Rollen erscheint nur einmal (beteiligteJePerson()). + const stackHtml = beteiligteJePerson(allPeople.map(entry => ({ + person: entry.person, role: entry.role, state: p.assign[assignKey(entry.person.id, entry.role)] || 0 + }))).map(b => beteiligtenAvatarHtml(b, roleClass(b.role))).join(''); const aktLp = lpActiveList(p)[0]; const aktPct = aktLp && p.lph[aktLp] !== undefined && p.lph[aktLp] !== '' ? p.lph[aktLp] : null; @@ -155,9 +244,12 @@ function renderBoardRow(p, allPeople, locked, weekLabel, opts){ // Listenoperationen am Zeilenende (aus dem früheren Projekte-Tab). Das // Bearbeiten der Projektangaben führt in den Projektbereich, wo sie ihren // einen Ort haben — hier steht nur, was die LISTE betrifft. + // Öffnen/Löschen hinter einem Drei-Punkte-Menü (Eriks Wunsch), gebaut wie + // das Zeilenmenü der Benutzerverwaltung (.user-kebab/.user-menu). html += `
- - ${locked || !darfProjektlisteAendern() ? '' : ``} +
`; html += ``; // board-row-main diff --git a/public/js/dashboard/dashboard.js b/public/js/dashboard/dashboard.js index fce0cda..77ab14b 100644 --- a/public/js/dashboard/dashboard.js +++ b/public/js/dashboard/dashboard.js @@ -130,7 +130,9 @@ async function renderDashboard(){ html += dashAbschnitt('Meine Projekte', daten.projekte.length, null, renderDashProjekte(daten), daten.projekte.length > 1 - ? '' + + '' : ''); html += ''; @@ -214,12 +216,9 @@ function dashDaten(){ relevant.sort((a, b) => (a.datum || '9999').localeCompare(b.datum || '9999')); projekte.push({ p, rollen, termine: relevant, alleTermine: termine }); }); - // Projekte mit dem dringendsten Termin zuerst, Projekte ohne Termin am Ende. - projekte.sort((a, b) => { - const da = (a.termine.find(t => t.datum) || {}).datum || '9999'; - const db_ = (b.termine.find(t => t.datum) || {}).datum || '9999'; - return da.localeCompare(db_) || String(a.p.nr || '').localeCompare(String(b.p.nr || '')); - }); + // Reihenfolge wie in der Projektliste (Gruppe, dann Position darin) — + // oder die eigene, auf dem Dashboard gezogene (dashSortieren()). + dashSortieren(projekte); // Kalendereinträge: alle datierten Termine dieser Projekte (auch vergangene // Meilensteine — beim Zurückblättern sollen sie da sein). @@ -273,6 +272,109 @@ function dashZuSchreiben(set){ } function dashProjKey(p){ return p.projectUid || p.nr || p.id; } +// ---------- Reihenfolge der Projekte ---------- +// Grundlage ist die Reihenfolge der Projektliste (GROUP_ORDER, dann p.order). +// Jedes Konto kann sie auf dem Dashboard durch Ziehen am Griff ändern; die +// eigene Reihenfolge liegt am Server (users.dashboard_reihenfolge, über +// currentUser.dashboardReihenfolge), gilt also auf jedem Gerät. Projekte, +// die darin noch fehlen (neu zugeordnet), stehen nach ihrer Stelle in der +// Projektliste dahinter. +function dashListenRang(p){ + const g = GROUP_ORDER.indexOf(p.group); + return (g === -1 ? 999 : g) * 1e6 + (Number(p.order) || 0); +} +function dashEigeneReihenfolge(){ + return (currentUser && Array.isArray(currentUser.dashboardReihenfolge)) ? currentUser.dashboardReihenfolge : []; +} +function dashSortieren(projekte){ + const eigen = dashEigeneReihenfolge(); + const pos = new Map(eigen.map((k, i) => [k, i])); + projekte.sort((a, b) => { + const ia = pos.has(dashProjKey(a.p)) ? pos.get(dashProjKey(a.p)) : Infinity; + const ib = pos.has(dashProjKey(b.p)) ? pos.get(dashProjKey(b.p)) : Infinity; + if(ia !== ib) return ia - ib; + return dashListenRang(a.p) - dashListenRang(b.p); + }); +} +function dashReihenfolgeSpeichern(liste){ + if(currentUser) currentUser.dashboardReihenfolge = liste; + fetch(baseUrl() + '/dashboard-reihenfolge', { + method: 'POST', + headers: { 'Content-Type': 'application/json; charset=utf-8' }, + body: JSON.stringify({ reihenfolge: liste }) + }).then(r => { if(!r.ok) throw new Error(r.status); }) + .catch(() => alert('Die Reihenfolge konnte nicht gespeichert werden.')); +} +// dashReihenfolgeZuruecksetzen: wieder wie in der Projektliste. +function dashReihenfolgeZuruecksetzen(){ + dashReihenfolgeSpeichern([]); + renderDashboard(); +} + +// Ziehen: Der Block wird nur ziehbar, solange der Griff gedrückt ist — +// sonst ließe sich Text in den Terminen nicht mehr markieren. +let dashGezogen = null; +function dashGriffDruck(griff){ + const block = griff.closest('.dash-proj'); + if(block) block.draggable = true; +} +document.addEventListener('mouseup', () => { + if(dashGezogen) return; + document.querySelectorAll('.dash-proj[draggable="true"]').forEach(b => { b.draggable = false; }); +}); +document.addEventListener('dragstart', e => { + const block = e.target && e.target.closest && e.target.closest('.dash-proj[draggable="true"]'); + if(!block) return; + dashGezogen = block; + block.classList.add('ist-gezogen'); + e.dataTransfer.effectAllowed = 'move'; + try { e.dataTransfer.setData('text/plain', block.dataset.key || ''); } catch(err){ /* egal */ } +}); +document.addEventListener('dragover', e => { + if(!dashGezogen) return; + const liste = dashGezogen.parentElement; + if(!liste || !liste.contains(e.target)) return; + e.preventDefault(); + const andere = [...liste.querySelectorAll('.dash-proj:not(.ist-gezogen)')]; + const danach = andere.find(b => { const r = b.getBoundingClientRect(); return e.clientY < r.top + r.height / 2; }); + if(danach) liste.insertBefore(dashGezogen, danach); else liste.appendChild(dashGezogen); +}); +document.addEventListener('drop', e => { if(dashGezogen) e.preventDefault(); }); +document.addEventListener('dragend', () => { + if(!dashGezogen) return; + const block = dashGezogen; + dashGezogen = null; + block.classList.remove('ist-gezogen'); + block.draggable = false; + const neu = [...block.parentElement.querySelectorAll('.dash-proj')].map(b => b.dataset.key); + const alt = dashEigeneReihenfolge(); + // Schon bekannte, gerade nicht angezeigte Projekte (z. B. vorübergehend + // ohne Zuordnung) behalten ihren Platz am Ende. + const liste = neu.concat(alt.filter(k => !neu.includes(k))); + if(JSON.stringify(liste) !== JSON.stringify(alt)) dashReihenfolgeSpeichern(liste); + const reset = document.getElementById('dashReihenfolgeReset'); + if(reset) reset.hidden = false; +}); + +// dashVerschieben: Tastatur-Alternative zum Ziehen (Griff fokussiert, +// Pfeil hoch/runter). +function dashGriffTaste(e, griff){ + if(e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return; + e.preventDefault(); + const block = griff.closest('.dash-proj'); + const liste = block && block.parentElement; + if(!liste) return; + if(e.key === 'ArrowUp' && block.previousElementSibling) liste.insertBefore(block, block.previousElementSibling); + else if(e.key === 'ArrowDown' && block.nextElementSibling) liste.insertBefore(block.nextElementSibling, block); + else return; + griff.focus(); + const neu = [...liste.querySelectorAll('.dash-proj')].map(b => b.dataset.key); + const alt = dashEigeneReihenfolge(); + dashReihenfolgeSpeichern(neu.concat(alt.filter(k => !neu.includes(k)))); + const reset = document.getElementById('dashReihenfolgeReset'); + if(reset) reset.hidden = false; +} + // dashProjUmschalten: klappt einen Projektblock auf oder zu — direkt am DOM, // ohne das Dashboard neu zu laden. Parameter: knopf (der Kopf-Button). function dashProjUmschalten(knopf){ @@ -316,7 +418,7 @@ function renderDashProjekte(daten){ if(!daten.projekte.length) return dashLeer('Sie sind in der aktuellen Sitzung keinem Projekt zugeordnet und haben keine offenen Aufgaben.'); const heute = heuteIso(); const zu = dashZuLesen(); - return daten.projekte.map(x => { + return '
' + daten.projekte.map(x => { const p = x.p; const key = dashProjKey(p); const istZu = zu.has(key); @@ -347,6 +449,7 @@ function renderDashProjekte(daten){ + '' + kommend.length + ' offen'; return '
' + '
' + + (daten.projekte.length > 1 ? '' + icon('grip') + '' : '') + '
' + '
'; - }).join(''); + }).join('') + '
'; } // renderDashTermin: eine Terminzeile im Projektblock und in der Tagesliste diff --git a/public/js/plan/plan-settings.js b/public/js/plan/plan-settings.js index c7ba049..ef83f96 100644 --- a/public/js/plan/plan-settings.js +++ b/public/js/plan/plan-settings.js @@ -329,6 +329,10 @@ window.renderPlanSettings = function(){ const p = app.project; if(!p) return; + // Kürzel in der Titelleiste mitziehen — sonst zeigte sie eine geänderte + // Rolle erst nach dem Neuladen (renderPlanPage() baut sie nur beim Öffnen). + const kopfTeam = document.getElementById('planPeopleList'); + if(kopfTeam) kopfTeam.innerHTML = app.renderProjectAssignees(); const locked = app.stammGesperrt(); const sandbox = app.isSandboxPlan(); diff --git a/public/js/plan/plan-state.js b/public/js/plan/plan-state.js index c822c0d..36f2f96 100644 --- a/public/js/plan/plan-state.js +++ b/public/js/plan/plan-state.js @@ -313,10 +313,8 @@ window.PlanApp = { function renderProjectAssignees(){ const assignees = projectAssignees(app.project, app.week); if(assignees.length === 0) return ''; - return assignees.map(a => { - const title = (a.person.fullName || a.person.name) + (a.role ? ' · ' + a.role : ''); - return `${esc(a.person.name)}`; - }).join(''); + // Eine Person mit mehreren Rollen nur einmal (beteiligteJePerson(), utils.js). + return beteiligteJePerson(assignees).map(b => beteiligtenAvatarHtml(b, planRoleClass(b.role))).join(''); } // savePlan: merkt die Sitzung des Projektbereichs als geändert vor und diff --git a/public/js/projects/projects.js b/public/js/projects/projects.js index 3af0496..b24e235 100644 --- a/public/js/projects/projects.js +++ b/public/js/projects/projects.js @@ -30,7 +30,7 @@ // durchgesetzt). Der Server legt das Projekt beim Speichern in den // Papierkorb, aus dem es ein Administrator 30 Tage lang zurückholen kann // (Admin → Papierkorb, js/admin/admin.js). -function delProjectFromList(id){ +function delProjectFromList(id, ohneRueckfrage){ if(guardWeekLocked()) return; if(!darfProjektlisteAendern()){ alert('Projekte löschen dürfen nur Projektleiter und Administratoren.'); @@ -38,7 +38,7 @@ function delProjectFromList(id){ } const p = currentWeek().projects.find(x => x.id === id); const bez = p ? ((p.nr ? p.nr + ' ' : '') + (p.name || '')).trim() : ''; - if(!confirm('Projekt ' + (bez ? '„' + bez + '" ' : '') + 'löschen?\n\nEin Administrator kann es 30 Tage lang wiederherstellen.')) return; + if(!ohneRueckfrage && !confirm('Projekt ' + (bez ? '„' + bez + '" ' : '') + 'löschen?\n\nEin Administrator kann es 30 Tage lang wiederherstellen.')) return; const w = currentWeek(); w.projects = w.projects.filter(p=>p.id!==id); save(); renderProjects(); renderBoard(); diff --git a/public/js/utils.js b/public/js/utils.js index 3f79178..d0298f5 100644 --- a/public/js/utils.js +++ b/public/js/utils.js @@ -87,6 +87,54 @@ function weekSortKey(label){ // Persistente ID fuer eine Person aus dem Kuerzel, damit Anwesenheit und // Zuordnungen auch nach einem Neuladen stabil bleiben. // Parameter: name (String). Rückgabe: String wie 'p_ab'. +// beteiligteJePerson: fasst die Zuordnungen eines Projekts ({person, role, +// state}; state 1 = Haupt, 2 = Unterstützung) zu EINEM Eintrag je Person +// zusammen — wer Planung und Projektleitung macht, erscheint im +// Avatar-Stapel nur einmal (Eriks Wunsch). Die Farbe folgt der wichtigsten +// Rolle (Haupt vor Unterstützung, dann Reihenfolge ROLES), der Tooltip nennt +// alle. Rückgabe: [{person, role, state, title}] in der Reihenfolge des +// ersten Auftretens. +function beteiligteJePerson(eintraege){ + const jePerson = new Map(); + (eintraege || []).forEach(e => { + if(!e || !e.person || !(e.state > 0)) return; + const key = e.person.id; + if(!jePerson.has(key)) jePerson.set(key, { person: e.person, rollen: [] }); + jePerson.get(key).rollen.push({ role: e.role, state: Number(e.state) }); + }); + const rang = r => (r.state === 1 ? 0 : 10) + Math.max(0, ROLES.indexOf(r.role)); + return Array.from(jePerson.values()).map(({ person, rollen }) => { + const haupt = rollen.slice().sort((a, b) => rang(a) - rang(b))[0]; + const liste = rollen.slice().sort((a, b) => ROLES.indexOf(a.role) - ROLES.indexOf(b.role)) + .map(r => r.role + (r.state === 2 ? ' (Unterstützung)' : '')).join(', '); + const sortiert = rollen.slice().sort((a, b) => ROLES.indexOf(a.role) - ROLES.indexOf(b.role)); + return { person, role: haupt.role, state: haupt.state, rollen: sortiert, title: (person.fullName || person.name) + ' · ' + liste }; + }); +} + +// ROLLEN_FARBE: dieselben Farben wie .board-avatar.role-* in styles.css. +const ROLLEN_FARBE = { 'Planung': 'var(--green)', 'Projektleitung': 'var(--red)', 'Bauüberwachung': 'var(--accent)' }; + +// beteiligtenAvatarHtml: Kürzel-Kreis eines Eintrags aus beteiligteJePerson(). +// Bei mehreren Rollen (Eriks Wunsch) ist der Kreis in Segmente geteilt — je +// Rolle eines in ihrer Farbe. Parameter: b, klasse +// (Rollen-Klasse, z. B. roleClass(b.role)). Rückgabe: HTML-String. +function beteiligtenAvatarHtml(b, klasse){ + const rollen = (b.rollen || []).filter(r => ROLLEN_FARBE[r.role]); + if(rollen.length < 2){ + return `${esc(b.person.name)}`; + } + const teil = 100 / rollen.length; + const stopps = rollen.map((r, i) => { + // Immer die volle Rollenfarbe — eine blassere Abstufung für + // Unterstützung wurde im Dunkelmodus zu einem eigenen, dunklen Ton. + // Ob Haupt oder Unterstützung, steht im Tooltip. + const farbe = ROLLEN_FARBE[r.role]; + return `${farbe} ${(i * teil).toFixed(2)}% ${((i + 1) * teil).toFixed(2)}%`; + }).join(', '); + return `${esc(b.person.name)}`; +} + function personIdForName(name){ return 'p_' + String(name).trim().toLowerCase().replace(/[^a-z0-9]/g,'_'); } diff --git a/public/styles.css b/public/styles.css index 6681c63..15c4998 100644 --- a/public/styles.css +++ b/public/styles.css @@ -4802,6 +4802,7 @@ tr.is-menu-open { background: var(--surface-alt); } } .board-row:hover .board-row-actions, .board-row.open .board-row-actions, +.board-row.is-menu-open .board-row-actions, .board-row-actions:focus-within{ opacity: 1; } /* Der Ziehgriff steht vor dem Aufklapp-Pfeil. */ @@ -6172,3 +6173,29 @@ html.glas .abw-tl-wer { background: var(--glas-deckend, var(--surface)); } /* Verdeckte Abwesenheit einer anderen Person: nur „abwesend“, neutral, nicht änderbar */ .abw-verdeckt-zeile { --abw-c: var(--text-subtle); } .abw-verdeckt-zeile .board-row-main { cursor: default; } + +/* ---------- Meine Aufgaben: eigene Reihenfolge der Projekte ---------- + Griff vorn im Projektkopf (dashGriffDruck()/dashGriffTaste(), + js/dashboard/dashboard.js). */ +.dash-proj-griff { + display: inline-flex; align-items: center; margin-right: -4px; + color: var(--text-subtle); cursor: grab; border-radius: var(--r-sm); +} +.dash-proj-griff:hover { color: var(--text); } +.dash-proj-griff:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.dash-proj-griff svg { width: 14px; height: 14px; } +.dash-proj.ist-gezogen { opacity: .5; } +#dashReihenfolgeReset:not([hidden]) + #dashAlleKnopf { margin-left: 10px; } + +/* Kürzel-Kreis einer Person mit mehreren Rollen (beteiligtenAvatarHtml(), + js/utils.js): Segmente in den Rollenfarben, weiße Schrift mit Schatten. */ +.board-avatar.ist-mehrfach { + /* Das Verlaufsbild beginnt an der Außenkante des Rands und wiederholt sich + nicht — sonst kachelt es in den (durchsichtigen) Rand hinein, und links + und rechts erscheint je ein schmaler Streifen der jeweils anderen Farbe. */ + background-color: transparent; + background-origin: border-box; + background-repeat: no-repeat; + color: #fff; border-color: transparent; + text-shadow: 0 0 2px rgba(0,0,0,.55), 0 0 1px rgba(0,0,0,.6); +} diff --git a/server.js b/server.js index 52b5335..4326da8 100644 --- a/server.js +++ b/server.js @@ -3035,6 +3035,13 @@ function migrateUsersDepartment(){ db.exec("ALTER TABLE users ADD COLUMN einfuehrung_am TEXT NOT NULL DEFAULT ''"); console.log('Benutzer-Tabelle um einfuehrung_am ergänzt.'); } + // dashboard_reihenfolge: eigene Reihenfolge der Projekte auf "Meine + // Aufgaben" (JSON-Liste von Projekt-Schlüsseln, meist project_uid); leer = + // wie in der Projektliste. Je Konto, damit sie auf jedem Gerät gilt. + if(!cols.includes('dashboard_reihenfolge')){ + db.exec("ALTER TABLE users ADD COLUMN dashboard_reihenfolge TEXT NOT NULL DEFAULT ''"); + console.log('Benutzer-Tabelle um dashboard_reihenfolge ergänzt.'); + } if(!cols.includes('department')){ db.exec("ALTER TABLE users ADD COLUMN department TEXT NOT NULL DEFAULT ''"); console.log('Benutzer-Tabelle um department ergänzt.'); @@ -3652,7 +3659,7 @@ function attachUser(req, res, next){ u.display_name AS display_name, u.email AS email, u.role AS role, u.person_id AS person_id, u.active AS active, u.must_change_password AS must_change_password, u.department AS department, - u.einfuehrung_am AS einfuehrung_am + u.einfuehrung_am AS einfuehrung_am, u.dashboard_reihenfolge AS dashboard_reihenfolge FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ? `).get(token); @@ -3670,7 +3677,9 @@ function attachUser(req, res, next){ // jedes Mal manuell das eigene Gewerk wählen muss. department: row.department || '', // einfuehrungFaellig: Rundgang noch nicht abgeschlossen (js/einfuehrung.js) - einfuehrungFaellig: !row.einfuehrung_am + einfuehrungFaellig: !row.einfuehrung_am, + // dashboardReihenfolge: eigene Projektreihenfolge auf "Meine Aufgaben" + dashboardReihenfolge: (() => { try { const a = JSON.parse(row.dashboard_reihenfolge || '[]'); return Array.isArray(a) ? a : []; } catch(e){ return []; } })() }; // last_seen_at höchstens einmal je Minute und Sitzung schreiben. Vorher // schrieb JEDE Anfrage — auch jede der rund 60 JS/CSS-Dateien eines @@ -3849,6 +3858,19 @@ app.post('/einfuehrung', requireAuth, (req, res) => { } catch(err){ console.error('Einführung speichern fehlgeschlagen:', err); res.status(500).json({ ok: false, error: String(err) }); } }); +// POST /dashboard-reihenfolge {reihenfolge:[schlüssel]} — eigene Reihenfolge +// der Projekte auf "Meine Aufgaben"; leere Liste = wie in der Projektliste. +// Nur das eigene Konto. +app.post('/dashboard-reihenfolge', requireAuth, (req, res) => { + try { + const liste = (req.body || {}).reihenfolge; + if(!Array.isArray(liste)) return res.status(400).json({ ok: false, error: 'reihenfolge muss eine Liste sein.' }); + const sauber = [...new Set(liste.map(x => String(x || '').slice(0, 100)).filter(Boolean))].slice(0, 500); + db.prepare('UPDATE users SET dashboard_reihenfolge = ? WHERE id = ?').run(sauber.length ? JSON.stringify(sauber) : '', req.user.id); + res.json({ ok: true, reihenfolge: sauber }); + } catch(err){ console.error('Dashboard-Reihenfolge speichern fehlgeschlagen:', err); res.status(500).json({ ok: false, error: String(err) }); } +}); + // ---------- Rollen & Rechte: Routen ---------- // GET liefert Katalog und Rollen (mit Anzahl Konten) — jedes angemeldete // Konto mit Grundzugang, weil die Benutzerverwaltung die Liste für ihre