1 Commits
Author SHA1 Message Date
erikandClaude Opus 5.5 e7243f6f26 Externe Personen bei Aufgaben, Aufwand-Feld, Teams persönlich
- Aufgaben auch Kollegen außerhalb des Projekts zuweisen
  (Abschnitt „Externe Personen“ im Aufgaben-Panel)
- Aufwand-Feld neu gestaltet: −/+, Schnellwahl, Umrechnung in Arbeitstage
- Teams: persönliche Nachricht an die Zuständigen (Flow-Bot) statt Kanal

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 09:36:06 +01:00
6 changed files with 304 additions and 101 deletions

No files matched your search

+47 -26
View File
@@ -1521,6 +1521,25 @@ step into a project explicit. Everything lives in `public/js/auftrag.js`:
system entirely — `loadAmpelColor()` returns `var(--red)` etc., so this system entirely — `loadAmpelColor()` returns `var(--red)` etc., so this
view now also follows the app-wide dark-mode palette like everywhere view now also follows the app-wide dark-mode palette like everywhere
else. else.
- **Aufwand im Aufgaben-Panel** (Eriks Wunsch „schöner darstellen“):
Kapsel mit −/+ (±1 h) und Einheit „Std.“, Schnellwahl 2 h / ½ Tag /
1 Tag / 2 Tage / 1 Woche (Tage über `APP_SETTINGS.arbeitsstundenProTag`;
erneuter Klick leert), darunter die Umrechnung in Arbeitstage
(`todoAufwandAendern()`/`todoAufwandSetzen()`/`todoAufwandInfo()`,
`plan-modals.js`). Feld-id `todoEditAufwand` und Speichern unverändert.
Das Eingabefeld ist per `!important` durchsichtig, weil die Glas-Regeln
für Eingabefelder ihm sonst eine eigene Fläche geben.
- **„Externe Personen“ im Aufgaben-Panel** (Eriks Wunsch): unter „Wer“
stehen oben die Projektbeteiligten als Chips, darunter der Abschnitt
„Externe Personen“ — Kollegen mit Konto, die dem Projekt NICHT
zugeordnet sind, über eine Auswahl nach Gewerk (`todoExterneSetzen()`,
`todoExternHinzufuegen()`/`todoExternEntfernen()` in `plan-modals.js`;
Quelle: Teilnehmerkreis der Sitzung ohne die Projektbeteiligten). Sie
landen ganz normal in `t.assignees` — damit zählen sie zu ihrer
Auslastung, erscheinen unter „Meine Aufgaben“, bekommen Glocke und
Teams-Nachricht. Wer einer Aufgabe zugewiesen ist, aber (nicht mehr) im
Projekt, erscheint beim Öffnen automatisch dort. Keine Daten­änderung
am Projekt: die Person wird NICHT unter „Beteiligte“ eingetragen.
- **Aufgabe und Meilenstein als Seitenpanel** (Eriks Wahl „B“): - **Aufgabe und Meilenstein als Seitenpanel** (Eriks Wahl „B“):
`#todoModal` und `#milestoneModal` sind `.modal-bg.als-panel` — rechts am `#todoModal` und `#milestoneModal` sind `.modal-bg.als-panel` — rechts am
Rand, volle Höhe, Hintergrund nur leicht abgedunkelt, damit der Rand, volle Höhe, Hintergrund nur leicht abgedunkelt, damit der
@@ -4210,36 +4229,38 @@ kopieren, „In Outlook öffnen“ (`webcal:`-Link), Anleitung, „Neue Adresse
Microsoft-Server aus dem Internet ab — dafür muss der Server von außen Microsoft-Server aus dem Internet ab — dafür muss der Server von außen
erreichbar sein. Das klassische Outlook am PC holt es im Firmennetz direkt. erreichbar sein. Das klassische Outlook am PC holt es im Firmennetz direkt.
## Teams: Nachricht bei neuen Aufgaben ## Teams: persönliche Nachricht bei neuen Aufgaben
Eriks Wunsch: Wird jemandem eine Aufgabe zugewiesen, erscheint in einem Eriks Wunsch („nur persönlich“): Wird jemandem eine Aufgabe zugewiesen,
Teams-Kanal eine Karte mit Link direkt auf die Aufgabe. bekommt GENAU DIESE Person in Teams eine Nachricht im Chat mit dem Flow-Bot,
- **Einrichten:** In Teams im Kanal *Workflows* → „Beim Empfang einer mit Link direkt auf die Aufgabe. Kein Kanal.
Webhook-Anfrage in einem Kanal posten“; die erzeugte https-Adresse unter - **Einrichten in Teams** (Anleitung aufklappbar unter **Admin → Teams**):
**Admin → Teams** eintragen (`loadAdminTeams()`, `js/admin/admin.js`, leerer Workflow, Auslöser „Bei Empfang einer Teams-Webhookanforderung“
Recht **`a.teams`**, neu im Katalog „Admin-Bereich“), „Testnachricht (Jeder), Aktion „Karte in einem Chat oder Kanal veröffentlichen“ als
senden“. Anleitung steht aufklappbar in der Karte. Flow-Bot im Chat mit Flow-Bot, Empfänger `triggerBody()?['empfaenger']`,
- **Speicher:** Tabelle `integrationen` (schluessel/wert; `teams_webhook`, Karte `string(triggerBody()?['karte'])`. Die Auslöser-Adresse trägt ein
Admin in der App ein (`loadAdminTeams()`, Recht **`a.teams`**). Der
Workflow läuft unter dem Konto, das ihn anlegt (besser Sammelkonto/IT).
- **Was die App schickt:** je neu zuständiger Person `{empfaenger, karte}` —
`empfaenger` = `users.email` (muss dem Teams-Anmeldenamen entsprechen),
`karte` = Adaptive Card (`teamsKarte()`: „Neue Aufgabe für Sie: …“,
Projekt, Fällig/Zeitraum, Aufwand, Zugewiesen von, bei mehreren auch
Zuständig, Knopf „Aufgabe öffnen“). Ohne Konto/E-Mail keine Nachricht
(nur ein Log-Eintrag). Testnachricht geht an die E-Mail des Admins.
- **Speicher:** Tabelle `integrationen` (`teams_webhook`,
`teams_neue_aufgaben`), bewusst NICHT `app_settings` (das darf jedes `teams_neue_aufgaben`), bewusst NICHT `app_settings` (das darf jedes
Konto lesen) und nicht in `IMPORT_TABLES`. Die Adresse geht nie an den Konto lesen) und nicht in `IMPORT_TABLES`; die Adresse geht nie an den
Browser zurück — `GET /admin/teams` liefert nur `konfiguriert`/`host`. Browser zurück. Routen `GET`/`PUT /admin/teams`, `POST /admin/teams/test`.
`PUT /admin/teams {webhook?, neueAufgaben?}` (nur https), `POST
/admin/teams/test` (Fehler als `{ok:false}` mit HTTP 200).
- **Auslöser:** `neueZuweisungenMelden()` (aus `/save-week` und - **Auslöser:** `neueZuweisungenMelden()` (aus `/save-week` und
`/save-projects`, jetzt mit `req`) sammelt je Aufgabe die NEU Zuständigen `/save-projects`, mit `req`) sammelt je Aufgabe die NEU Zuständigen —
— auch Personen ohne Konto, nicht wer sich selbst zuweist, keine nicht wer sich selbst zuweist, keine Phasenbalken, nie die Sandbox —,
Phasenbalken, nie die Sandbox — und `teamsNeueAufgaben()` schickt je `teamsNeueAufgaben()` sendet im Hintergrund; Fehler nur im Log.
Aufgabe EINE Adaptive Card (`teamsKarte()`: Titel, Projekt, Zuständig,
Fällig/Zeitraum, Aufwand, Zugewiesen von, Knopf „Aufgabe öffnen“). Im
Hintergrund; Fehler nur im Log, das Speichern scheitert nie daran.
- **Link** (`aufgabenLink()`): `/Projektplan/<Name>?weekLabel=…&projNr=… - **Link** (`aufgabenLink()`): `/Projektplan/<Name>?weekLabel=…&projNr=…
&aufgabe=<todoId>#aufgaben`, Host aus der speichernden Anfrage (= Adresse &aufgabe=<todoId>#aufgaben`, Host aus der speichernden Anfrage (nur im
der App im Büro; funktioniert nur im Büronetz/VPN). Im Browser liest Büronetz/VPN erreichbar). `plan-state.js` (`startAufgabe`) öffnet die
`plan-state.js` `aufgabe` beim Laden (`startAufgabe`): öffnet die jüngste Sitzung, falls der Link eine ältere nennt, und dort das
JÜNGSTE Sitzung, falls der Link eine ältere nennt (Aufgaben-ids bleiben Aufgaben-Panel; die Adresszeile wird nachgezogen.
beim Sitzungswechsel gleich), springt nach „Aufgaben“ und öffnet das - Braucht Internetzugang nach außen.
Aufgaben-Panel; Adresszeile wird nachgezogen.
- Braucht Internetzugang nach außen (Microsoft-Server).
## Sitzungen nach Bedarf laden (`js/store.js`) ## Sitzungen nach Bedarf laden (`js/store.js`)
+55 -21
View File
@@ -39,7 +39,7 @@
<link rel="alternate icon" href="/logo.ico"> <link rel="alternate icon" href="/logo.ico">
<link rel="apple-touch-icon" href="/logo.png"> <link rel="apple-touch-icon" href="/logo.png">
<link rel="stylesheet" href="/styles.css?v=110"> <link rel="stylesheet" href="/styles.css?v=110">
<link rel="stylesheet" href="/plan-styles.css?v=45"> <link rel="stylesheet" href="/plan-styles.css?v=48">
<!-- Glas-Darstellung (public/glas.css, als letztes Stylesheet): greift nur mit <!-- Glas-Darstellung (public/glas.css, als letztes Stylesheet): greift nur mit
der Klasse html.glas. Gesetzt wird sie hier, vor dem ersten Zeichnen, der Klasse html.glas. Gesetzt wird sie hier, vor dem ersten Zeichnen,
damit die Seite nicht erst ohne Glas aufblitzt; abschaltbar je Browser damit die Seite nicht erst ohne Glas aufblitzt; abschaltbar je Browser
@@ -679,23 +679,31 @@
<!-- Teams-Anbindung (loadAdminTeams(), js/admin/admin.js; Server: /admin/teams*) --> <!-- Teams-Anbindung (loadAdminTeams(), js/admin/admin.js; Server: /admin/teams*) -->
<section class="settings-card" id="adminTeamsCard" data-admin-section="teams" style="display:none"> <section class="settings-card" id="adminTeamsCard" data-admin-section="teams" style="display:none">
<h3 class="settings-card-title">Teams <span class="settings-scope">firmenweit · nur Administratoren</span></h3> <h3 class="settings-card-title">Teams <span class="settings-scope">firmenweit · nur Administratoren</span></h3>
<p class="hint">Bekommt jemand eine Aufgabe zugewiesen, erscheint in einem Teams-Kanal eine <p class="hint">Bekommt jemand eine Aufgabe zugewiesen, erhält genau diese Person in Teams
Nachricht mit Link direkt auf die Aufgabe im Projekt.</p> eine persönliche Nachricht (Chat mit dem Flow-Bot) mit Link direkt auf die Aufgabe.
Gefunden wird sie über die E-Mail-Adresse ihres Kontos — die muss ihrem Teams-Anmeldenamen
entsprechen. Wer kein Konto oder keine E-Mail hat, bekommt nichts.</p>
<div id="adminTeamsInhalt"></div> <div id="adminTeamsInhalt"></div>
<details class="kalender-anleitung"> <details class="kalender-anleitung">
<summary>So richten Sie die Adresse in Teams ein</summary> <summary>So richten Sie den Workflow in Teams ein</summary>
<ol> <ol>
<li>In Teams den gewünschten Kanal öffnen, oben bei den drei Punkten <li>In Teams die App <i>Workflows</i> öffnen (oder im Browser make.powerautomate.com) und
<i>Workflows</i> wählen.</li> einen <b>leeren</b> Workflow anlegen.</li>
<li>Die Vorlage <i>„Beim Empfang einer Webhook-Anfrage in einem Kanal posten“</i> <li><b>Auslöser:</b> „Bei Empfang einer Teams-Webhookanforderung“, bei
auswählen, einen Namen vergeben (z. B. „Projektübersicht“) und bestätigen.</li> <i>Wer kann den Flow auslösen</i> „Jeder“ wählen.</li>
<li>Teams zeigt am Ende eine lange Adresse (beginnt mit https://). Diese kopieren, <li><b>Aktion:</b> „Karte in einem Chat oder Kanal veröffentlichen“ —
<i>Posten als</i>: Flow-Bot, <i>Posten in</i>: Chat mit Flow-Bot,
<i>Empfänger</i>: als Ausdruck <code>triggerBody()?['empfaenger']</code>,
<i>Adaptive Karte</i>: als Ausdruck <code>string(triggerBody()?['karte'])</code>.</li>
<li>Speichern. Beim Auslöser steht jetzt eine lange Adresse (https://…) — kopieren,
hier einfügen und speichern.</li> hier einfügen und speichern.</li>
<li>„Testnachricht senden“ — im Kanal sollte eine Karte erscheinen.</li> <li>„Testnachricht senden“ — die Nachricht kommt an Sie selbst.</li>
</ol> </ol>
<p class="hint">Der Server braucht dafür Internetzugang nach außen. Der Link in der <p class="hint">Der Workflow läuft unter dem Konto, das ihn anlegt. Am besten legt ihn ein
Nachricht führt auf die Adresse, unter der die App im Büro aufgerufen wird — er Sammelkonto oder die IT an — sonst hört er auf, wenn dieses Konto gesperrt wird.
funktioniert also nur im Büronetz (bzw. per VPN).</p> Der Server braucht Internetzugang nach außen; der Link in der Nachricht funktioniert
nur im Büronetz (bzw. per VPN). Die Bezeichnungen in Teams können je nach Version
leicht abweichen.</p>
</details> </details>
</section> </section>
<section class="settings-card" id="adminBetriebCard" data-admin-section="betrieb" style="display:none"> <section class="settings-card" id="adminBetriebCard" data-admin-section="betrieb" style="display:none">
@@ -1602,12 +1610,38 @@
<section class="pd-abs" id="todoEditWerAbs"> <section class="pd-abs" id="todoEditWerAbs">
<h3>Wer</h3> <h3>Wer</h3>
<div id="todoEditAssignees" class="person-chips"></div> <div id="todoEditAssignees" class="person-chips"></div>
<label class="pd-aufwand" for="todoEditAufwand"> <!-- Externe Personen: Kollegen mit Konto, die dem Projekt NICHT
<span>Aufwand</span> zugeordnet sind (Eriks Wunsch). todoExternHinzufuegen()/
<input type="number" id="todoEditAufwand" min="0" step="0.5" placeholder="0" inputmode="decimal"> todoExternEntfernen(), js/plan/plan-modals.js -->
<span>Stunden, optional</span> <div class="pd-externe">
</label> <div class="pd-externe-kopf">Externe Personen <span class="hint">nicht im Projekt</span></div>
<p class="hint pd-hint">Zählt zur Auslastung der Zuständigen, verteilt auf den Zeitraum der Aufgabe.</p> <div id="todoEditExterne" class="person-chips"></div>
<select id="todoEditExterneWahl" aria-label="Externe Person hinzufügen" onchange="todoExternHinzufuegen(this.value)"></select>
</div>
<!-- Aufwand: −/+, Schnellwahl, Umrechnung in Arbeitstage
(todoAufwandAendern()/todoAufwandSetzen()/todoAufwandInfo(), plan-modals.js) -->
<div class="pd-aufwand">
<div class="pd-aufwand-kopf">
<label for="todoEditAufwand">Aufwand</label>
<span>optional · zählt zur Auslastung der Zuständigen</span>
</div>
<div class="pd-aufwand-zeile">
<div class="pd-stepper">
<button type="button" class="pd-stepper-knopf" onclick="todoAufwandAendern(-1)" aria-label="Eine Stunde weniger">−</button>
<input type="number" id="todoEditAufwand" min="0" step="0.5" placeholder="0" inputmode="decimal" oninput="todoAufwandInfo()">
<span class="pd-einheit">Std.</span>
<button type="button" class="pd-stepper-knopf" onclick="todoAufwandAendern(1)" aria-label="Eine Stunde mehr">+</button>
</div>
<div class="pd-aufwand-schnell" id="todoAufwandSchnell" role="group" aria-label="Schnellwahl">
<button type="button" data-h="2" onclick="todoAufwandSetzen(2)">2 h</button>
<button type="button" data-h="4" onclick="todoAufwandSetzen(4)">½ Tag</button>
<button type="button" data-tage="1" onclick="todoAufwandSetzen('tag', 1)">1 Tag</button>
<button type="button" data-tage="2" onclick="todoAufwandSetzen('tag', 2)">2 Tage</button>
<button type="button" data-tage="5" onclick="todoAufwandSetzen('tag', 5)">1 Woche</button>
</div>
</div>
<span class="pd-aufwand-info" id="todoAufwandInfoText"></span>
</div>
</section> </section>
<section class="pd-abs"> <section class="pd-abs">
<h3>Stand</h3> <h3>Stand</h3>
@@ -1983,7 +2017,7 @@
Tab-Ordner, keine neue Abhängigkeit nach vorn. --> Tab-Ordner, keine neue Abhängigkeit nach vorn. -->
<script src="/js/wand/wand.js?v=21"></script> <script src="/js/wand/wand.js?v=21"></script>
<script src="/js/settings/settings.js?v=14"></script> <script src="/js/settings/settings.js?v=14"></script>
<script src="/js/admin/admin.js?v=21"></script> <script src="/js/admin/admin.js?v=22"></script>
<script src="/js/admin-users.js?v=10"></script> <script src="/js/admin-users.js?v=10"></script>
<script src="/js/admin/rollen.js?v=1"></script> <script src="/js/admin/rollen.js?v=1"></script>
<script src="/js/einfuehrung.js?v=2"></script> <script src="/js/einfuehrung.js?v=2"></script>
@@ -2002,7 +2036,7 @@
<script src="/js/plan/plan-build-gantt.js?v=11"></script> <script src="/js/plan/plan-build-gantt.js?v=11"></script>
<script src="/js/plan/plan-gantt.js?v=21"></script> <script src="/js/plan/plan-gantt.js?v=21"></script>
<script src="/js/plan/plan-drag.js?v=8"></script> <script src="/js/plan/plan-drag.js?v=8"></script>
<script src="/js/plan/plan-modals.js?v=15"></script> <script src="/js/plan/plan-modals.js?v=18"></script>
<script src="/js/plan/plan-tasks.js?v=4"></script> <script src="/js/plan/plan-tasks.js?v=4"></script>
<script src="/js/plan/plan-phasen.js?v=2"></script> <script src="/js/plan/plan-phasen.js?v=2"></script>
<script src="/js/plan/plan-files.js?v=4"></script> <script src="/js/plan/plan-files.js?v=4"></script>
+3 -3
View File
@@ -1115,12 +1115,12 @@ async function loadAdminTeams(){
if(!d || !d.ok){ el.innerHTML = '<span class="settings-warn">Keine Berechtigung.</span>'; return; } if(!d || !d.ok){ el.innerHTML = '<span class="settings-warn">Keine Berechtigung.</span>'; return; }
el.innerHTML = ` el.innerHTML = `
<div class="settings-kv-row"><span class="settings-kv-key">Status</span> <div class="settings-kv-row"><span class="settings-kv-key">Status</span>
<span>${d.konfiguriert ? '<span class="sitzung-chip ist-geoeffnet">Verbunden</span> <span class="hint" style="margin:0">' + esc(d.host) + '</span>' <span>${d.konfiguriert ? '<span class="sitzung-chip ist-geoeffnet">Eingerichtet</span> <span class="hint" style="margin:0">' + esc(d.host) + '</span>'
: '<span class="sitzung-chip ist-gesperrt">Nicht eingerichtet</span>'}</span></div> : '<span class="sitzung-chip ist-gesperrt">Nicht eingerichtet</span>'}</span></div>
<label class="settings-field"><span>${d.konfiguriert ? 'Neue Adresse (ersetzt die bisherige)' : 'Adresse aus dem Teams-Workflow'}</span> <label class="settings-field"><span>${d.konfiguriert ? 'Neue Adresse (ersetzt die bisherige)' : 'Adresse aus dem Teams-Workflow'}</span>
<input type="password" id="teamsWebhook" placeholder="https://…" autocomplete="off"></label> <input type="password" id="teamsWebhook" placeholder="https://…" autocomplete="off"></label>
<label class="settings-glas-schalter"><input type="checkbox" id="teamsNeueAufgaben" ${d.neueAufgaben ? 'checked' : ''} <label class="settings-glas-schalter"><input type="checkbox" id="teamsNeueAufgaben" ${d.neueAufgaben ? 'checked' : ''}
onchange="teamsSpeichern({ neueAufgaben: this.checked })"> Neue Aufgaben melden</label> onchange="teamsSpeichern({ neueAufgaben: this.checked })"> Zuständige über neue Aufgaben benachrichtigen</label>
<div class="kalender-aktionen" style="margin-top:10px"> <div class="kalender-aktionen" style="margin-top:10px">
<button type="button" onclick="teamsSpeichern({ webhook: document.getElementById('teamsWebhook').value })">Adresse speichern</button> <button type="button" onclick="teamsSpeichern({ webhook: document.getElementById('teamsWebhook').value })">Adresse speichern</button>
<button type="button" class="secondary" id="teamsTestKnopf" onclick="teamsTesten()" ${d.konfiguriert ? '' : 'disabled'}>Testnachricht senden</button> <button type="button" class="secondary" id="teamsTestKnopf" onclick="teamsTesten()" ${d.konfiguriert ? '' : 'disabled'}>Testnachricht senden</button>
@@ -1156,7 +1156,7 @@ async function teamsTesten(){
try { try {
const d = await fetch(baseUrl() + '/admin/teams/test', { method: 'POST' }).then(r => r.json()); const d = await fetch(baseUrl() + '/admin/teams/test', { method: 'POST' }).then(r => r.json());
if(fehler){ fehler.hidden = d.ok; fehler.textContent = d.ok ? '' : 'Nicht gesendet: ' + (d.error || 'unbekannter Fehler'); } if(fehler){ fehler.hidden = d.ok; fehler.textContent = d.ok ? '' : 'Nicht gesendet: ' + (d.error || 'unbekannter Fehler'); }
if(d.ok) alert('Testnachricht gesendet — bitte im Teams-Kanal nachsehen.'); if(d.ok) alert('Testnachricht gesendet an ' + (d.an || 'Sie') + ' — bitte im Teams-Chat mit dem Flow-Bot nachsehen.');
} catch(e){ } catch(e){
if(fehler){ fehler.hidden = false; fehler.textContent = 'Der Server ist nicht erreichbar.'; } if(fehler){ fehler.hidden = false; fehler.textContent = 'Der Server ist nicht erreichbar.'; }
} finally { } finally {
+111 -30
View File
@@ -488,6 +488,104 @@
// Anzeige und Dauer-Badge, Status-Segment, Zuständigen-Chips und die // Anzeige und Dauer-Badge, Status-Segment, Zuständigen-Chips und die
// Vorgänger-Liste. "Löschen" erscheint nur beim Bearbeiten, nicht beim // Vorgänger-Liste. "Löschen" erscheint nur beim Bearbeiten, nicht beim
// Anlegen. // Anlegen.
// ---------- Aufwand: −/+, Schnellwahl, Umrechnung ----------
// Ein Arbeitstag = APP_SETTINGS.arbeitsstundenProTag (Admin → Termine).
function stundenProTag(){ return Number(APP_SETTINGS && APP_SETTINGS.arbeitsstundenProTag) || 8; }
function aufwandLesen(){
const el = document.getElementById('todoEditAufwand');
return el ? Math.max(0, parseFloat(String(el.value).replace(',', '.')) || 0) : 0;
}
function aufwandSchreiben(h){
const el = document.getElementById('todoEditAufwand');
if(!el) return;
const w = Math.max(0, Math.round(h * 2) / 2);
el.value = w > 0 ? String(w) : '';
todoAufwandInfo();
}
window.todoAufwandAendern = function(delta){ aufwandSchreiben(aufwandLesen() + delta); };
// todoAufwandSetzen(h) oder todoAufwandSetzen('tag', n): fester Wert; ein
// zweiter Klick auf den gewählten Wert leert das Feld.
window.todoAufwandSetzen = function(a, n){
const h = a === 'tag' ? n * stundenProTag() : Number(a);
aufwandSchreiben(aufwandLesen() === h ? 0 : h);
};
// todoAufwandInfo: Umrechnung in Arbeitstage und Markierung der Schnellwahl.
window.todoAufwandInfo = function(){
const h = aufwandLesen();
const tag = stundenProTag();
const info = document.getElementById('todoAufwandInfoText');
if(info){
const tage = h / tag;
const zahl = x => (Math.round(x * 10) / 10).toLocaleString('de-DE');
info.textContent = h > 0 ? '≈ ' + zahl(tage) + (Math.abs(tage - 1) < 0.05 ? ' Arbeitstag' : ' Arbeitstage') + ' (' + zahl(tag) + ' h je Tag)' : 'Ohne Aufwand zählt die Aufgabe nicht zur Auslastung.';
}
document.querySelectorAll('#todoAufwandSchnell button').forEach(b => {
const wert = b.dataset.tage ? Number(b.dataset.tage) * tag : Number(b.dataset.h);
b.setAttribute('aria-pressed', String(h > 0 && Math.abs(wert - h) < 0.01));
});
};
// ---------- Zuständige: Projekt und „Externe Personen“ ----------
// todoProjektPersonen: die dem Projekt zugeordneten Personen (je einmal).
function todoProjektPersonen(){
const gesehen = new Set();
const liste = [];
projectAssignees(app.project, app.week).forEach(a => {
if(gesehen.has(a.person.id)) return;
gesehen.add(a.person.id);
liste.push(a.person);
});
return liste;
}
function todoPersonById(id){
return ((app.week && app.week.people) || []).find(x => x.id === id) || { id, name: id, fullName: id };
}
function todoPersonChip(person, gedrueckt, extern){
const short = person.name || initialsForName(person.fullName || '') || '?';
const full = person.fullName || person.name || '';
const klick = extern ? `todoExternEntfernen('${esc(person.id)}')` : 'toggleTodoAssignee(this)';
const titel = extern ? ' title="Nicht im Projekt — Klick entfernt die Person"' : '';
return `<button type="button" class="person-chip${extern ? ' ist-fremd' : ''}"${titel} data-person-id="${esc(person.id)}" aria-pressed="${gedrueckt ? 'true' : 'false'}" onclick="${klick}">`
+ `<span class="person-chip-ini">${esc(short)}</span>${esc(full)}${extern ? ' <span class="person-chip-x" aria-hidden="true">×</span>' : ''}</button>`;
}
// todoExterneSetzen: zeichnet die gewählten externen Personen und die
// Auswahl der übrigen Kollegen (Teilnehmerkreis der Sitzung ohne Projekt
// und ohne bereits gewählte), nach Gewerk gruppiert.
function todoExterneSetzen(ids){
const box = document.getElementById('todoEditExterne');
const wahl = document.getElementById('todoEditExterneWahl');
if(!box || !wahl) return;
box.innerHTML = ids.map(id => todoPersonChip(todoPersonById(id), true, true)).join('');
const projektIds = new Set(todoProjektPersonen().map(p => p.id));
const frei = ((app.week && app.week.people) || [])
.filter(p => p && p.id && !projektIds.has(p.id) && !ids.includes(p.id))
.sort((a, b) => String(a.fullName || a.name).localeCompare(String(b.fullName || b.name), 'de'));
const gruppen = new Map();
frei.forEach(p => {
const g = (typeof personGewerk === 'function' && personGewerk(p)) || 'Ohne Gewerk';
if(!gruppen.has(g)) gruppen.set(g, []);
gruppen.get(g).push(p);
});
const reihenfolge = [...(typeof DEPARTMENTS !== 'undefined' ? Object.keys(DEPARTMENTS) : []), 'Ohne Gewerk'];
const schluessel = [...gruppen.keys()].sort((a, b) => reihenfolge.indexOf(a) - reihenfolge.indexOf(b));
wahl.innerHTML = `<option value="">${frei.length ? '+ Person hinzufügen…' : 'Keine weiteren Kollegen'}</option>`
+ schluessel.map(g => `<optgroup label="${esc(g)}">${gruppen.get(g).map(p =>
`<option value="${esc(p.id)}">${esc(p.fullName || p.name)}${p.name && p.fullName ? ' (' + esc(p.name) + ')' : ''}</option>`).join('')}</optgroup>`).join('');
wahl.disabled = !frei.length;
}
function todoExterneIds(){
return Array.from(document.querySelectorAll('#todoEditExterne .person-chip')).map(b => b.dataset.personId);
}
window.todoExternHinzufuegen = function(id){
if(!id) return;
const ids = todoExterneIds();
if(!ids.includes(id)) ids.push(id);
todoExterneSetzen(ids);
};
window.todoExternEntfernen = function(id){
todoExterneSetzen(todoExterneIds().filter(x => x !== id));
};
window.openTodoModal = function(index, prefill){ window.openTodoModal = function(index, prefill){
if(app.isLocked()) return; if(app.isLocked()) return;
const plan = app.ensurePlanLocal(); const plan = app.ensurePlanLocal();
@@ -544,6 +642,7 @@
const aufwandWert = isNew ? Number(pre.aufwand) : Number(currentTodo.aufwand); const aufwandWert = isNew ? Number(pre.aufwand) : Number(currentTodo.aufwand);
aufwandEl.value = aufwandWert > 0 ? String(aufwandWert) : ''; aufwandEl.value = aufwandWert > 0 ? String(aufwandWert) : '';
} }
todoAufwandInfo();
todoPhasenModusSetzen(isNew ? !!pre.phase : !!currentTodo.lph, isNew ? null : currentTodo, isNew, isNew ? pre.lph : null); todoPhasenModusSetzen(isNew ? !!pre.phase : !!currentTodo.lph, isNew ? null : currentTodo, isNew, isNew ? pre.lph : null);
const storedProgress = isNew ? 0 : (parseInt(currentTodo.progress, 10) || 0); const storedProgress = isNew ? 0 : (parseInt(currentTodo.progress, 10) || 0);
@@ -594,39 +693,20 @@
const assigneesDiv = document.getElementById('todoEditAssignees'); const assigneesDiv = document.getElementById('todoEditAssignees');
if(assigneesDiv){ if(assigneesDiv){
const assignees = projectAssignees(app.project, app.week);
const currentAssignees = currentTodo const currentAssignees = currentTodo
? (Array.isArray(currentTodo.assignees) ? currentTodo.assignees : []) ? (Array.isArray(currentTodo.assignees) ? currentTodo.assignees : [])
: (Array.isArray(pre.assignees) ? pre.assignees : []); : (Array.isArray(pre.assignees) ? pre.assignees : []);
const seenPeople = new Set(); const projektLeute = todoProjektPersonen();
const uniquePeople = []; const projektIds = new Set(projektLeute.map(p => p.id));
assignees.forEach(a => {
if(!seenPeople.has(a.person.id)){
seenPeople.add(a.person.id);
uniquePeople.push(a.person);
}
});
// Bereits Zuständige, die (nicht mehr) dem Projekt zugeordnet sind —
// etwa über "Aufgabe aus Kommentar" aus dem ganzen Teilnehmerkreis
// gewählt — ebenfalls als Chip zeigen. Ohne sie gingen sie beim
// Speichern stillschweigend verloren, weil saveTodoEdit() nur die
// gedrückten Chips liest.
currentAssignees.forEach(id => {
if(seenPeople.has(id)) return;
seenPeople.add(id);
const p = ((app.week && app.week.people) || []).find(x => x.id === id) || { id, name: id, fullName: id };
uniquePeople.push(Object.assign({}, p, { nichtImProjekt: true }));
});
// Chips statt Checkbox-Reihe: Kürzel + Name, dieselbe Lesart wie die // Chips statt Checkbox-Reihe: Kürzel + Name, dieselbe Lesart wie die
// Personenspalten im Projektboard. // Personenspalten im Projektboard.
assigneesDiv.innerHTML = uniquePeople.map(person => { assigneesDiv.innerHTML = projektLeute.map(person =>
const pressed = currentAssignees.includes(person.id) ? 'true' : 'false'; todoPersonChip(person, currentAssignees.includes(person.id), false)
const short = person.name || initialsForName(person.fullName || '') || '?'; ).join('') || '<span class="hint">Keine Projektzuordnungen im Board.</span>';
const full = person.fullName || person.name || ''; // Zuständige außerhalb des Projekts stehen unter „Externe Personen“ —
const fremd = person.nichtImProjekt ? ' title="Nicht (mehr) dem Projekt zugeordnet"' : ''; // auch solche, die das Projekt inzwischen verlassen haben (sonst gingen
return `<button type="button" class="person-chip${person.nichtImProjekt ? ' ist-fremd' : ''}"${fremd} data-person-id="${esc(person.id)}" aria-pressed="${pressed}" onclick="toggleTodoAssignee(this)">` // sie beim Speichern stillschweigend verloren).
+ `<span class="person-chip-ini">${esc(short)}</span>${esc(full)}</button>`; todoExterneSetzen(currentAssignees.filter(id => !projektIds.has(id)));
}).join('') || '<span class="hint">Keine Projektzuordnungen im Board.</span>';
} }
const deleteBtn = document.getElementById('todoEditDelete'); const deleteBtn = document.getElementById('todoEditDelete');
@@ -664,7 +744,7 @@
function todoFormSnapshot(){ function todoFormSnapshot(){
const read = id => { const el = document.getElementById(id); return el ? el.value : ''; }; const read = id => { const el = document.getElementById(id); return el ? el.value : ''; };
const deps = Array.from(document.querySelectorAll('#todoEditDependsOn input[type="checkbox"]:checked')).map(cb => cb.value); const deps = Array.from(document.querySelectorAll('#todoEditDependsOn input[type="checkbox"]:checked')).map(cb => cb.value);
const people = Array.from(document.querySelectorAll('#todoEditAssignees .person-chip[aria-pressed="true"]')).map(b => b.dataset.personId); const people = Array.from(document.querySelectorAll('#todoEditAssignees .person-chip[aria-pressed="true"], #todoEditExterne .person-chip')).map(b => b.dataset.personId);
const gantt = document.getElementById('todoEditShowInGantt'); const gantt = document.getElementById('todoEditShowInGantt');
return JSON.stringify([read('todoEditTitle'), read('todoEditStart'), read('todoEditEnd'), return JSON.stringify([read('todoEditTitle'), read('todoEditStart'), read('todoEditEnd'),
currentTodoEditStatus(), read('todoEditProgress'), deps, people, currentTodoEditStatus(), read('todoEditProgress'), deps, people,
@@ -727,7 +807,8 @@
const dependsOn = dependsOnList ? Array.from(dependsOnList.querySelectorAll('input[type="checkbox"]:checked')).map(cb => cb.value).filter(Boolean) : []; const dependsOn = dependsOnList ? Array.from(dependsOnList.querySelectorAll('input[type="checkbox"]:checked')).map(cb => cb.value).filter(Boolean) : [];
const assigneesDiv = document.getElementById('todoEditAssignees'); const assigneesDiv = document.getElementById('todoEditAssignees');
const assignees = assigneesDiv ? Array.from(assigneesDiv.querySelectorAll('.person-chip[aria-pressed="true"]')).map(btn => btn.dataset.personId) : []; const assignees = (assigneesDiv ? Array.from(assigneesDiv.querySelectorAll('.person-chip[aria-pressed="true"]')).map(btn => btn.dataset.personId) : [])
.concat(todoExterneIds());
const showInGanttEl = document.getElementById('todoEditShowInGantt'); const showInGanttEl = document.getElementById('todoEditShowInGantt');
const showInGantt = showInGanttEl ? showInGanttEl.checked : true; const showInGantt = showInGanttEl ? showInGanttEl.checked : true;
+55 -5
View File
@@ -1522,11 +1522,51 @@ html[data-theme="dark"] .modal-bg.als-panel { background: rgba(0,0,0,.32); }
.pd-loeschen:hover { background: color-mix(in srgb, var(--red) 10%, transparent); } .pd-loeschen:hover { background: color-mix(in srgb, var(--red) 10%, transparent); }
.pd-loeschen[hidden] { display: none; } .pd-loeschen[hidden] { display: none; }
/* Aufwand einer Aufgabe in Stunden (zählt zur Auslastung). */ /* Aufwand einer Aufgabe in Stunden (zählt zur Auslastung). */
.pd-aufwand { display: flex; align-items: center; gap: 10px; font-size: 13px; font-weight: 400; margin-top: 4px; } /* Aufwand im Aufgaben-Panel: Kopf, Stepper (−, Zahl, „Std.“, +),
.pd-aufwand > span:first-child { font-weight: 600; color: var(--text-muted); font-size: 12px; } Schnellwahl als kleine Kapseln, Umrechnung in Arbeitstage darunter.
.pd-aufwand > span:last-child { color: var(--text-muted); font-size: 12px; } Knöpfe tragen eigene Klassen bzw. stehen in eigenen Containern, damit die
.pd-aufwand input { width: 96px; padding: 7px 10px; border: 1px solid var(--line); border-radius: var(--r-md); font: inherit; font-size: 13px; color: var(--text); background: var(--surface); text-align: right; } allgemeine (Glas-)Knopfregel sie nicht akzentblau färbt. */
.pd-aufwand input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(0,113,227,.18); } .pd-aufwand { display: grid; gap: 8px; margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line-soft); }
.pd-aufwand-kopf { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
.pd-aufwand-kopf label { font-size: 12px; font-weight: 600; color: var(--text-muted); }
.pd-aufwand-kopf span { font-size: 11.5px; color: var(--text-subtle); }
.pd-aufwand-zeile { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.pd-stepper {
display: inline-flex; align-items: center; height: 34px; border: 1px solid var(--line);
border-radius: var(--r-pill); background: var(--surface-sunk); overflow: hidden;
transition: border-color var(--dur, 150ms), box-shadow var(--dur, 150ms);
}
.pd-stepper:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); }
.pd-stepper .pd-stepper-knopf {
all: unset; box-sizing: border-box; width: 32px; height: 100%; display: grid; place-items: center;
font-size: 16px; color: var(--text-muted); cursor: pointer;
}
.pd-stepper .pd-stepper-knopf:hover { background: var(--seg-hover); color: var(--text); }
.pd-stepper .pd-stepper-knopf:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.pd-stepper input {
width: 52px; border: 0; background: transparent; padding: 0; text-align: right;
font: inherit; font-size: 15px; font-weight: 600; color: var(--text); font-variant-numeric: tabular-nums;
-moz-appearance: textfield; appearance: textfield;
}
.pd-stepper input::-webkit-outer-spin-button, .pd-stepper input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.pd-stepper input:focus { outline: none; }
/* Glas-/Dialog-Regeln für Eingabefelder geben dem Feld sonst eine eigene
Fläche mitten im Stepper — hier bewusst durchsichtig, der Rahmen ist die Kapsel. */
.pd-stepper input#todoEditAufwand { background: transparent !important; border: 0 !important; border-radius: 0 !important; box-shadow: none !important; }
.pd-einheit { font-size: 12px; color: var(--text-muted); padding: 0 6px 0 4px; }
.pd-aufwand-schnell { display: flex; gap: 6px; flex-wrap: wrap; }
.pd-aufwand-schnell button {
all: unset; box-sizing: border-box; padding: 5px 11px; border-radius: var(--r-pill); cursor: pointer;
font-size: 12px; color: var(--text-muted); border: 1px solid var(--line); background: transparent;
transition: background var(--dur, 150ms), color var(--dur, 150ms), border-color var(--dur, 150ms);
}
.pd-aufwand-schnell button:hover { background: var(--seg-hover); color: var(--text); }
.pd-aufwand-schnell button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.pd-aufwand-schnell button[aria-pressed="true"] {
background: var(--accent-soft); color: var(--accent); font-weight: 600;
border-color: color-mix(in srgb, var(--accent) 45%, transparent);
}
.pd-aufwand-info { font-size: 12px; color: var(--text-subtle); }
/* Terminplan aufgeräumt (Eriks Wahl "A", plan-gantt.js / plan-build-gantt.js): /* Terminplan aufgeräumt (Eriks Wahl "A", plan-gantt.js / plan-build-gantt.js):
eine Leiste statt zwei, Menüs "Mehr" und "+ Neu", Gliederung nach eine Leiste statt zwei, Menüs "Mehr" und "+ Neu", Gliederung nach
@@ -1809,3 +1849,13 @@ html[data-theme="dark"] { --gantt-phase: #a59ef2; }
.mail-auswahl-zeile { grid-template-columns: auto 1fr; } .mail-auswahl-zeile { grid-template-columns: auto 1fr; }
.mail-auswahl-art { grid-column: 2; justify-self: start; } .mail-auswahl-art { grid-column: 2; justify-self: start; }
} }
/* ---------- Aufgaben-Panel: „Externe Personen“ (Kollegen außerhalb des
Projekts, todoExterneSetzen() in plan-modals.js) ---------- */
.pd-externe { margin-top: 12px; padding-top: 10px; border-top: 1px dashed var(--line); display: grid; gap: 8px; }
.pd-externe-kopf { font-size: 12px; font-weight: 600; color: var(--text-muted); }
.pd-externe-kopf .hint { font-weight: 400; margin: 0 0 0 4px; }
#todoEditExterne:empty { display: none; }
#todoEditExterneWahl { max-width: 100%; }
.person-chip-x { margin-left: 4px; opacity: .6; }
.person-chip:hover .person-chip-x { opacity: 1; }
+33 -16
View File
@@ -4679,13 +4679,19 @@ app.post('/save-week', requireAuth, (req, res) => {
} }
}); });
// ---------- Teams: Nachricht bei neuen Aufgaben ---------- // ---------- Teams: persönliche Nachricht bei neuen Aufgaben ----------
// //
// Eriks Wunsch: Wird jemandem eine Aufgabe zugewiesen, erscheint in einem // Eriks Wunsch: Wird jemandem eine Aufgabe zugewiesen, bekommt GENAU DIESE
// Teams-Kanal eine Nachricht mit Link direkt auf die Aufgabe im Projekt. // Person in Teams eine Nachricht (Einzelchat mit dem Flow-Bot) mit Link
// Eingerichtet in Teams über „Workflows“ → „Beim Empfang einer // direkt auf die Aufgabe im Projekt — nicht der ganze Kanal (Eriks Wahl
// Webhook-Anfrage in einem Kanal posten“; die dort erzeugte Adresse trägt // „nur persönlich“). Dafür legt man in Teams einen Workflow an: Auslöser
// ein Admin unter Admin → Teams ein (Recht a.teams). Die Adresse ist ein // „Bei Empfang einer Teams-Webhookanforderung“, Aktion „Karte in einem Chat
// oder Kanal veröffentlichen“ als Flow-Bot im Chat, Empfänger
// triggerBody()?['empfaenger'], Karte string(triggerBody()?['karte']). Die
// Adresse des Auslösers trägt ein Admin unter Admin → Teams ein (Recht
// a.teams). Die App schickt je Person {empfaenger: E-Mail, karte: Adaptive
// Card}; Empfänger ist users.email (muss dem Teams-Anmeldenamen
// entsprechen). Personen ohne Konto oder E-Mail bekommen nichts. Die Adresse ist ein
// Geheimnis (wer sie kennt, kann in den Kanal schreiben) — sie steht in // Geheimnis (wer sie kennt, kann in den Kanal schreiben) — sie steht in
// `integrationen`, nicht in app_settings (das jedes Konto lesen darf), und // `integrationen`, nicht in app_settings (das jedes Konto lesen darf), und
// geht nie an den Browser zurück. Nicht in IMPORT_TABLES. // geht nie an den Browser zurück. Nicht in IMPORT_TABLES.
@@ -4707,17 +4713,16 @@ function integrationSchreiben(k, v){
.run(k, String(v)); .run(k, String(v));
} }
// teamsKarte: Adaptive Card im Umschlag, den der Teams-Workflow erwartet. // teamsKarte: die Adaptive Card selbst (der Workflow legt sie in den Chat).
function teamsKarte(titel, zeilen, fakten, link, linkText){ function teamsKarte(titel, zeilen, fakten, link, linkText){
const body = [{ type: 'TextBlock', text: titel, weight: 'Bolder', size: 'Medium', wrap: true }]; const body = [{ type: 'TextBlock', text: titel, weight: 'Bolder', size: 'Medium', wrap: true }];
zeilen.forEach(z => body.push({ type: 'TextBlock', text: z, wrap: true, spacing: 'Small' })); zeilen.forEach(z => body.push({ type: 'TextBlock', text: z, wrap: true, spacing: 'Small' }));
if(fakten.length) body.push({ type: 'FactSet', facts: fakten.map(([title, value]) => ({ title, value })) }); if(fakten.length) body.push({ type: 'FactSet', facts: fakten.map(([title, value]) => ({ title, value })) });
const content = { return {
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json', $schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
type: 'AdaptiveCard', version: '1.4', body, type: 'AdaptiveCard', version: '1.4', body,
actions: link ? [{ type: 'Action.OpenUrl', title: linkText || 'Öffnen', url: link }] : [] actions: link ? [{ type: 'Action.OpenUrl', title: linkText || 'Öffnen', url: link }] : []
}; };
return { type: 'message', attachments: [{ contentType: 'application/vnd.microsoft.card.adaptive', contentUrl: null, content }] };
} }
// teamsSenden: an die eingetragene Adresse schicken. Rückgabe: Promise — // teamsSenden: an die eingetragene Adresse schicken. Rückgabe: Promise —
@@ -4756,22 +4761,31 @@ function teamsDatum(iso){
// teamsNeueAufgaben: je Aufgabe mit neu Zuständigen EINE Nachricht. neu = // teamsNeueAufgaben: je Aufgabe mit neu Zuständigen EINE Nachricht. neu =
// Map todoId -> { p, t, personen:[ids] }. Läuft im Hintergrund. // Map todoId -> { p, t, personen:[ids] }. Läuft im Hintergrund.
// teamsNeueAufgaben: je neu zuständiger PERSON eine Nachricht an sie selbst.
// neu = Map todoId -> { p, t, personen:[person_ids] }. Läuft im Hintergrund.
function teamsNeueAufgaben(weekLabel, neu, user, req){ function teamsNeueAufgaben(weekLabel, neu, user, req){
if(!neu.size || integrationLesen('teams_neue_aufgaben') !== '1' || !integrationLesen('teams_webhook')) return; if(!neu.size || integrationLesen('teams_neue_aufgaben') !== '1' || !integrationLesen('teams_webhook')) return;
const namen = new Map(db.prepare('SELECT person_id, name, full_name FROM week_people WHERE week_label = ?').all(weekLabel) const namen = new Map(db.prepare('SELECT person_id, name, full_name FROM week_people WHERE week_label = ?').all(weekLabel)
.map(r => [r.person_id, r.full_name || r.name || r.person_id])); .map(r => [r.person_id, r.full_name || r.name || r.person_id]));
const konto = db.prepare(`SELECT email FROM users WHERE person_id = ? AND person_id != '' AND active = 1 AND email != '' AND ${sqlOhneWandtafel()}`);
const von = (user && (user.displayName || user.username)) || ''; const von = (user && (user.displayName || user.username)) || '';
neu.forEach(({ p, t, personen }) => { neu.forEach(({ p, t, personen }) => {
const wer = personen.map(id => namen.get(id) || id).join(', '); const wer = personen.map(id => namen.get(id) || id).join(', ');
const zeitraum = t.start && t.end && t.start !== t.end ? teamsDatum(t.start) + ' – ' + teamsDatum(t.end) : ''; const zeitraum = t.start && t.end && t.start !== t.end ? teamsDatum(t.start) + ' – ' + teamsDatum(t.end) : '';
const fakten = [['Projekt', [p.nr, p.name].filter(Boolean).join(' ')], ['Zuständig', wer]]; const fakten = [['Projekt', [p.nr, p.name].filter(Boolean).join(' ')]];
if(personen.length > 1) fakten.push(['Zuständig', wer]);
if(t.end) fakten.push(['Fällig', teamsDatum(t.end)]); if(t.end) fakten.push(['Fällig', teamsDatum(t.end)]);
if(zeitraum) fakten.push(['Zeitraum', zeitraum]); if(zeitraum) fakten.push(['Zeitraum', zeitraum]);
if(Number(t.aufwand) > 0) fakten.push(['Aufwand', String(t.aufwand).replace('.', ',') + ' h']); if(Number(t.aufwand) > 0) fakten.push(['Aufwand', String(t.aufwand).replace('.', ',') + ' h']);
if(von) fakten.push(['Zugewiesen von', von]); if(von) fakten.push(['Zugewiesen von', von]);
const karte = teamsKarte('Neue Aufgabe: ' + (t.title || 'Aufgabe'), [], fakten, const karte = teamsKarte('Neue Aufgabe für Sie: ' + (t.title || 'Aufgabe'), [], fakten,
aufgabenLink(req, weekLabel, p, t.id), 'Aufgabe öffnen'); aufgabenLink(req, weekLabel, p, t.id), 'Aufgabe öffnen');
teamsSenden(karte).catch(err => console.warn('Teams-Nachricht nicht gesendet:', err.message)); personen.forEach(pid => {
const k = konto.get(pid);
if(!k){ console.log('Teams: keine E-Mail für', namen.get(pid) || pid, '— keine Nachricht'); return; }
teamsSenden({ empfaenger: k.email, karte })
.catch(err => console.warn('Teams-Nachricht an', k.email, 'nicht gesendet:', err.message));
});
}); });
} }
@@ -4805,13 +4819,16 @@ app.put('/admin/teams', requireRecht('a.teams'), (req, res) => {
// POST /admin/teams/test — Testnachricht; Fehler als {ok:false, error} mit // POST /admin/teams/test — Testnachricht; Fehler als {ok:false, error} mit
// HTTP 200 (ein fehlgeschlagener Test ist ein erwartetes Ergebnis). // HTTP 200 (ein fehlgeschlagener Test ist ein erwartetes Ergebnis).
// Die Testnachricht geht an den Admin selbst (seine E-Mail aus dem Konto).
app.post('/admin/teams/test', requireRecht('a.teams'), async (req, res) => { app.post('/admin/teams/test', requireRecht('a.teams'), async (req, res) => {
try { try {
const email = (db.prepare('SELECT email FROM users WHERE id = ?').get(req.user.id) || {}).email || '';
if(!email) return res.json({ ok: false, error: 'Ihr Konto hat keine E-Mail-Adresse — die braucht Teams, um Sie zu finden (Einstellungen → Mein Konto).' });
const link = req.protocol + '://' + req.get('host') + '/'; const link = req.protocol + '://' + req.get('host') + '/';
await teamsSenden(teamsKarte('Projektübersicht ist mit Teams verbunden', await teamsSenden({ empfaenger: email, karte: teamsKarte('Projektübersicht ist mit Teams verbunden',
['Ab jetzt erscheinen hier neue Aufgaben mit einem Link direkt zur Aufgabe im Projekt.'], ['Ab jetzt bekommt jede Person hier eine Nachricht, wenn ihr eine Aufgabe zugewiesen wird — mit Link direkt zur Aufgabe.'],
[['Eingerichtet von', req.user.displayName || req.user.username]], link, 'Projektübersicht öffnen')); [['Eingerichtet von', req.user.displayName || req.user.username]], link, 'Projektübersicht öffnen') });
res.json({ ok: true }); res.json({ ok: true, an: email });
} catch(err){ } catch(err){
res.json({ ok: false, error: err.message }); res.json({ ok: false, error: err.message });
} }