Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51dfbe8cd8 | ||
|
|
e7243f6f26 |
No files matched your search
@@ -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,45 @@ 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.
|
||||||
|
- **Admin-Karte** (`loadAdminTeams()`): Statuszeile (eingerichtet + Host
|
||||||
|
bzw. „noch nicht eingerichtet“), breites mehrzeiliges Adressfeld
|
||||||
|
(`teamsFeldPruefen()`: Speichern erst bei https ohne Leerzeichen; bei
|
||||||
|
hinterlegter Adresse erst nach „Adresse ändern“ sichtbar), Schalter für
|
||||||
|
die Benachrichtigungen (gesperrt ohne Adresse), „Testnachricht an mich
|
||||||
|
senden“, „Anbindung entfernen“. Stile: Block „Admin → Teams“ am Ende von
|
||||||
|
`styles.css`.
|
||||||
|
- **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`)
|
||||||
|
|
||||||
|
|||||||
+56
-22
@@ -38,8 +38,8 @@
|
|||||||
<link rel="icon" type="image/png" href="/logo.png">
|
<link rel="icon" type="image/png" href="/logo.png">
|
||||||
<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=111">
|
||||||
<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=23"></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>
|
||||||
|
|||||||
+56
-12
@@ -1113,22 +1113,66 @@ async function loadAdminTeams(){
|
|||||||
try { d = await fetch(baseUrl() + '/admin/teams').then(r => r.json()); }
|
try { d = await fetch(baseUrl() + '/admin/teams').then(r => r.json()); }
|
||||||
catch(e){ el.innerHTML = '<span class="settings-warn">Konnte nicht geladen werden.</span>'; return; }
|
catch(e){ el.innerHTML = '<span class="settings-warn">Konnte nicht geladen werden.</span>'; return; }
|
||||||
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; }
|
||||||
|
// Ist schon eine Adresse hinterlegt, bleibt das Eingabefeld zu, bis man
|
||||||
|
// „Ändern“ wählt — die Adresse selbst kommt ohnehin nie zurück.
|
||||||
|
const feldOffen = !d.konfiguriert;
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<div class="settings-kv-row"><span class="settings-kv-key">Status</span>
|
<div class="teams-status${d.konfiguriert ? ' ist-an' : ''}">
|
||||||
<span>${d.konfiguriert ? '<span class="sitzung-chip ist-geoeffnet">Verbunden</span> <span class="hint" style="margin:0">' + esc(d.host) + '</span>'
|
<span class="teams-punkt" aria-hidden="true"></span>
|
||||||
: '<span class="sitzung-chip ist-gesperrt">Nicht eingerichtet</span>'}</span></div>
|
<span class="teams-status-text">${d.konfiguriert
|
||||||
<label class="settings-field"><span>${d.konfiguriert ? 'Neue Adresse (ersetzt die bisherige)' : 'Adresse aus dem Teams-Workflow'}</span>
|
? '<b>Eingerichtet</b><span>Workflow bei ' + esc(d.host) + '</span>'
|
||||||
<input type="password" id="teamsWebhook" placeholder="https://…" autocomplete="off"></label>
|
: '<b>Noch nicht eingerichtet</b><span>Adresse aus dem Teams-Workflow unten einfügen</span>'}</span>
|
||||||
<label class="settings-glas-schalter"><input type="checkbox" id="teamsNeueAufgaben" ${d.neueAufgaben ? 'checked' : ''}
|
${d.konfiguriert ? '<button type="button" class="teams-text-knopf" onclick="teamsFeldZeigen()">Adresse ändern</button>' : ''}
|
||||||
onchange="teamsSpeichern({ neueAufgaben: this.checked })"> Neue Aufgaben melden</label>
|
</div>
|
||||||
<div class="kalender-aktionen" style="margin-top:10px">
|
|
||||||
<button type="button" onclick="teamsSpeichern({ webhook: document.getElementById('teamsWebhook').value })">Adresse speichern</button>
|
<div class="teams-feld" id="teamsFeld"${feldOffen ? '' : ' hidden'}>
|
||||||
<button type="button" class="secondary" id="teamsTestKnopf" onclick="teamsTesten()" ${d.konfiguriert ? '' : 'disabled'}>Testnachricht senden</button>
|
<label for="teamsWebhook">${d.konfiguriert ? 'Neue Workflow-Adresse' : 'Workflow-Adresse'}</label>
|
||||||
${d.konfiguriert ? '<button type="button" class="secondary" onclick="teamsEntfernen()">Entfernen</button>' : ''}
|
<textarea id="teamsWebhook" rows="3" spellcheck="false" autocomplete="off"
|
||||||
|
placeholder="https://… (beim Auslöser des Teams-Workflows kopieren und hier einfügen)"
|
||||||
|
oninput="teamsFeldPruefen()"></textarea>
|
||||||
|
<div class="teams-feld-fuss">
|
||||||
|
<span class="teams-feld-hinweis" id="teamsFeldHinweis">Bleibt geheim: Nach dem Speichern wird die Adresse nicht mehr angezeigt.</span>
|
||||||
|
${d.konfiguriert ? '<button type="button" class="secondary small" onclick="loadAdminTeams()">Abbrechen</button>' : ''}
|
||||||
|
<button type="button" class="small" id="teamsSpeichernKnopf" disabled onclick="teamsSpeichern({ webhook: document.getElementById('teamsWebhook').value })">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="teams-schalter">
|
||||||
|
<input type="checkbox" id="teamsNeueAufgaben" ${d.neueAufgaben ? 'checked' : ''} ${d.konfiguriert ? '' : 'disabled'}
|
||||||
|
onchange="teamsSpeichern({ neueAufgaben: this.checked })">
|
||||||
|
<span class="teams-schalter-bahn" aria-hidden="true"></span>
|
||||||
|
<span class="teams-schalter-text"><b>Zuständige über neue Aufgaben benachrichtigen</b>
|
||||||
|
<span>Persönliche Nachricht mit Link zur Aufgabe</span></span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="teams-aktionen">
|
||||||
|
<button type="button" class="secondary small" id="teamsTestKnopf" onclick="teamsTesten()" ${d.konfiguriert ? '' : 'disabled'}>Testnachricht an mich senden</button>
|
||||||
|
${d.konfiguriert ? '<button type="button" class="teams-text-knopf ist-gefahr" onclick="teamsEntfernen()">Anbindung entfernen</button>' : ''}
|
||||||
</div>
|
</div>
|
||||||
<p class="field-error" id="teamsFehler" hidden></p>`;
|
<p class="field-error" id="teamsFehler" hidden></p>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// teamsFeldZeigen: blendet das Eingabefeld für eine neue Adresse ein.
|
||||||
|
function teamsFeldZeigen(){
|
||||||
|
const f = document.getElementById('teamsFeld');
|
||||||
|
if(f){ f.hidden = false; document.getElementById('teamsWebhook').focus(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// teamsFeldPruefen: Speichern erst bei einer https-Adresse; Hinweis passt sich an.
|
||||||
|
function teamsFeldPruefen(){
|
||||||
|
const wert = (document.getElementById('teamsWebhook').value || '').trim();
|
||||||
|
const ok = /^https:\/\/\S+$/.test(wert);
|
||||||
|
const knopf = document.getElementById('teamsSpeichernKnopf');
|
||||||
|
const hinweis = document.getElementById('teamsFeldHinweis');
|
||||||
|
if(knopf) knopf.disabled = !ok;
|
||||||
|
if(hinweis){
|
||||||
|
hinweis.classList.toggle('ist-fehler', !!wert && !ok);
|
||||||
|
hinweis.textContent = !wert ? 'Bleibt geheim: Nach dem Speichern wird die Adresse nicht mehr angezeigt.'
|
||||||
|
: ok ? 'Sieht gut aus — jetzt speichern und danach eine Testnachricht senden.'
|
||||||
|
: 'Die Adresse muss mit https:// beginnen und darf keine Leerzeichen enthalten.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function teamsSpeichern(patch){
|
async function teamsSpeichern(patch){
|
||||||
if('webhook' in patch && !String(patch.webhook || '').trim()){ alert('Bitte die Adresse aus dem Teams-Workflow einfügen.'); return; }
|
if('webhook' in patch && !String(patch.webhook || '').trim()){ alert('Bitte die Adresse aus dem Teams-Workflow einfügen.'); return; }
|
||||||
const r = await fetch(baseUrl() + '/admin/teams', {
|
const r = await fetch(baseUrl() + '/admin/teams', {
|
||||||
@@ -1156,7 +1200,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
@@ -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
@@ -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; }
|
||||||
@@ -6232,3 +6232,60 @@ button.verlauf-weitere { white-space: nowrap; }
|
|||||||
.kalender-anleitung summary { cursor: pointer; color: var(--accent); }
|
.kalender-anleitung summary { cursor: pointer; color: var(--accent); }
|
||||||
.kalender-anleitung ol { margin: 8px 0; padding-left: 20px; display: grid; gap: 6px; }
|
.kalender-anleitung ol { margin: 8px 0; padding-left: 20px; display: grid; gap: 6px; }
|
||||||
.kalender-aktionen { display: flex; gap: 8px; }
|
.kalender-aktionen { display: flex; gap: 8px; }
|
||||||
|
|
||||||
|
/* ---------- Admin → Teams (loadAdminTeams(), js/admin/admin.js) ----------
|
||||||
|
Eigene Klassen statt .settings-field: das Adressfeld ist lang (≈ 300
|
||||||
|
Zeichen) und braucht eine breite, mehrzeilige Fläche. */
|
||||||
|
#adminTeamsInhalt { display: grid; gap: 14px; margin: 12px 0 6px; }
|
||||||
|
.teams-status {
|
||||||
|
display: flex; align-items: center; gap: 12px; padding: 12px 14px;
|
||||||
|
border: 1px solid var(--line); border-radius: var(--r-lg); background: var(--surface-sunk);
|
||||||
|
}
|
||||||
|
.teams-punkt { width: 10px; height: 10px; border-radius: 50%; background: var(--text-subtle); flex: none; }
|
||||||
|
.teams-status.ist-an .teams-punkt { background: var(--green); box-shadow: 0 0 0 4px color-mix(in srgb, var(--green) 20%, transparent); }
|
||||||
|
.teams-status-text { display: grid; gap: 2px; flex: 1; min-width: 0; }
|
||||||
|
.teams-status-text b { font-size: 13.5px; }
|
||||||
|
.teams-status-text span { font-size: 12px; color: var(--text-muted); overflow-wrap: anywhere; }
|
||||||
|
button.teams-text-knopf, button.teams-text-knopf:hover, button.teams-text-knopf:active {
|
||||||
|
all: unset; cursor: pointer; font-size: 12.5px; color: var(--accent); white-space: nowrap; padding: 4px 2px;
|
||||||
|
}
|
||||||
|
button.teams-text-knopf:hover { text-decoration: underline; }
|
||||||
|
button.teams-text-knopf:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
|
||||||
|
button.teams-text-knopf.ist-gefahr { color: var(--red); }
|
||||||
|
|
||||||
|
.teams-feld[hidden] { display: none; }
|
||||||
|
.teams-feld { display: grid; gap: 6px; }
|
||||||
|
.teams-feld label { font-size: 12px; font-weight: 600; color: var(--text-muted); }
|
||||||
|
.teams-feld textarea {
|
||||||
|
width: 100%; box-sizing: border-box; resize: vertical; min-height: 64px;
|
||||||
|
padding: 10px 12px; border: 1px solid var(--line) !important; border-radius: var(--r-md) !important;
|
||||||
|
background: var(--surface) !important; color: var(--text); box-shadow: none !important;
|
||||||
|
font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; word-break: break-all;
|
||||||
|
}
|
||||||
|
.teams-feld textarea:focus { outline: none; border-color: var(--accent) !important; box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent) !important; }
|
||||||
|
.teams-feld-fuss { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.teams-feld-hinweis { flex: 1; min-width: 200px; font-size: 12px; color: var(--text-subtle); }
|
||||||
|
.teams-feld-hinweis.ist-fehler { color: var(--red); }
|
||||||
|
|
||||||
|
/* Schalter statt nackter Checkbox */
|
||||||
|
.teams-schalter { display: flex; align-items: center; gap: 12px; cursor: pointer; }
|
||||||
|
.teams-schalter input { position: absolute; opacity: 0; width: 1px; height: 1px; }
|
||||||
|
.teams-schalter-bahn {
|
||||||
|
flex: none; width: 38px; height: 22px; border-radius: 11px; background: var(--line); position: relative;
|
||||||
|
transition: background var(--dur, 150ms);
|
||||||
|
}
|
||||||
|
.teams-schalter-bahn::after {
|
||||||
|
content: ''; position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; border-radius: 50%;
|
||||||
|
background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.3); transition: transform var(--dur, 150ms) var(--ease-out, ease);
|
||||||
|
}
|
||||||
|
.teams-schalter input:checked + .teams-schalter-bahn { background: var(--accent); }
|
||||||
|
.teams-schalter input:checked + .teams-schalter-bahn::after { transform: translateX(16px); }
|
||||||
|
.teams-schalter input:focus-visible + .teams-schalter-bahn { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
.teams-schalter input:disabled + .teams-schalter-bahn { opacity: .45; }
|
||||||
|
.teams-schalter:has(input:disabled) { cursor: default; }
|
||||||
|
.teams-schalter-text { display: grid; gap: 2px; }
|
||||||
|
.teams-schalter-text b { font-size: 13px; font-weight: 600; }
|
||||||
|
.teams-schalter-text span { font-size: 12px; color: var(--text-muted); }
|
||||||
|
.teams-schalter:has(input:disabled) .teams-schalter-text { opacity: .55; }
|
||||||
|
|
||||||
|
.teams-aktionen { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
|
||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user