diff --git a/.vch/PLATFORM.md b/.vch/PLATFORM.md index fd642e0..76ec235 100644 --- a/.vch/PLATFORM.md +++ b/.vch/PLATFORM.md @@ -11,15 +11,97 @@ prüft, das du gar nicht gepusht hast. ## 1. Zwei Maschinen, nicht eine - **Hier (DEV):** ein Debian-LXC. Nutzer `vchuser`, Heimat `/home/vchuser`, Projekte unter - `/home/vchuser/projects/`. code-server auf Port 8080, Claude Code als `claude`, - Git gegen Gitea vorkonfiguriert. -- **Dort (PROD):** eine **andere** Maschine. VCH klont dein Repository dorthin und startet es. + `/home/vchuser/projects/`. `vchuser` hat `sudo` ohne Passwort und ist in der + Docker-Gruppe. Vorhanden: Node 20 (npm; pnpm und yarn sind NICHT installiert — `corepack + enable` holt sie, wenn dein Projekt sie braucht), Python samt venv, Docker, tmux, git, + code-server auf Port 8080, Claude Code als `claude`, OpenCode als `opencode`; Git gegen Gitea + vorkonfiguriert. Das Terminal läuft in tmux — ein Dev-Server im Vordergrund blockiert die + Sitzung, starte ihn in einem eigenen tmux-Fenster oder mit `&`. +- **Dort (PROD):** eine **andere** Maschine mit demselben Werkzeugsatz. VCH klont dein + Repository dorthin und startet es. Der Build läuft dort mit **gelöschtem `NODE_ENV`**, damit + devDependencies installiert werden; ein `NODE_ENV=production` aus `.env.example` gilt erst + zur Laufzeit. Daraus folgt die wichtigste Regel überhaupt: **VCH deployt und auditiert deinen gepushten Commit, nie dein Arbeitsverzeichnis.** Nicht committete Änderungen existieren für Deploy, Audit und Veröffentlichung nicht — auch dann nicht, wenn hier alles läuft. -## 2. Vorschau im Browser läuft über einen Proxy +## 2. Wegweiser — welcher Weg in welcher Lage + +Bevor du baust: such deine Lage, nimm den Weg, kenn die Folgen. Die Abschnitte dahinter +erklären das Wie. + +**Eine Datenbank, Schlüssel in `.env.example`.** Der Mensch stellt sie im Projektarbeitsplatz +bereit, VCH trägt die Werte in genau deine Schlüssel ein (Abschnitt 8). Folgen: Datenbank und +Benutzer heißen `app`, sofern beim Anlegen nichts anderes gewählt wurde; Migrationen laufen in +deinem `build` oder `start`, VCH führt keine aus; DB-Zeilen im Env-Editor werden gestrichen, +solange die Datenbank läuft; erst der nächste Deploy trägt die Werte ein. + + +**Zwei Datenbanken — oder Redis, Mongo, Elasticsearch.** Compose mit eigenen Diensten +(Abschnitt 13). Folgen: ein Compose mit eigener Datenbank deployt **nicht blau-grün, sondern +in place** — kurze Auszeit beim Umschalten, das Audit läuft als Torprüfung vor dem Start; VCH +sichert diese Datenbank nicht (kein Backup über die Karte); jeder Datenbankdienst braucht ein +Named Volume; den DB-Port **nicht** als festen Host-Port veröffentlichen. An der verwalteten +Datenbank landen zwei deklarierte Verbindungen (`DATABASE_URL` und `ANALYTICS_DATABASE_URL`) +auf **derselben** — deshalb Compose. + + +**Nur Dateien oder SQLite.** Keine Datenbank; alles, was bleiben soll, nach `VCH_DATA_DIR` +(Abschnitt 9). Folgen: `HISTORY_DB=/pfad.sqlite` ist bewusst keine Verbindung und wird nicht +überschrieben; ein Pfad relativ zum Release ist beim nächsten Deploy weg. + + +**Eine Datenbank zum Entwickeln, hier auf DEV.** VCH stellt hier keine bereit. Starte selbst +eine — Docker ist da, `vchuser` ist in der Docker-Gruppe: +`docker run -d --name dev-db -p 127.0.0.1:5432:5432 -e POSTGRES_PASSWORD=dev postgres:16`. +Lege eine lokale `.env` an, deklariere die Schlüssel in `.env.example`. Folgen: die Bindung an +`127.0.0.1` ist Pflicht — ohne Adresse vor dem Port lauscht die Datenbank für jeden im LAN; die +`.env` gehört nicht ins Repository; für PROD trägt VCH die verwaltete Datenbank ein. + + +**Ein zweiter Prozess neben der App** (Worker, Cron, Queue). Compose (Abschnitt 13). Folgen: +systemd startet genau **einen** Befehl, den aus `start`; im Compose läuft jeder Dienst mit, +Dienste hinter `profiles:` bleiben aus. + + +**Reines Frontend ohne Server.** Statisch ausliefern, irgendetwas muss auf `$PORT` lauschen +(Abschnitt 6.2, „Statische Seite"). Folgen: relative Pfade und Vite `base: './'` (Abschnitt 3), +sonst bricht die Vorschau hinter dem Pfad-Proxy. + + +**Code in einem Unterordner.** `cd` in `build` und `start` (Abschnitt 6.2, „Monorepo"), die +`.env.example` in den Ordner, der sie liest. Folgen: jede Beispieldatei bestimmt ihr eigenes +`.env`-Ziel (Abschnitt 7). + + +**Die Vorschau zeigt nichts, der Port wird aber erkannt.** Dein Server lauscht auf `127.0.0.1`. +Lausche auf `0.0.0.0`: `vite --host`, `next dev -H 0.0.0.0`, `HOST=0.0.0.0`. Folgen: die +Porterkennung sieht auch lokale Lauscher, der Proxy erreicht sie nicht — ein erkannter Port +beweist keine erreichbare Vorschau. + + +**Ein Geheimnis wird gebraucht** (API-Schlüssel, Token). In `.env.example` **leer** deklarieren +(`API_KEY=`); den Wert hinterlegt der Mensch im Env-Editor (Abschnitt 7). Folgen: leer ohne +Vorgabe blockiert den Deploy, bis der Wert da ist — gewollt; die Prüfung `secrets` findet +eingecheckte Werte (Abschnitt 11). + + +**Der Deploy schlägt fehl.** Du siehst weder das Deploy-Log noch Logs der PROD-Maschine — lass +dir den Fehlertext aus dem Projektarbeitsplatz geben. Dann prüfen: lauscht die App auf `PORT`, +antwortet der `health`-Pfad innerhalb von 30 Sekunden, liest sie ihre Werte aus der Umgebung? +Folgen: im Deploy-Log stehen die letzten 50 Logzeilen der App — logge nach stdout, nicht in +Dateien. + + +**Dem Projekt ist eine Integration zugeordnet.** Die Zugangsdaten liegen in +`.vch-integrations.env` im Projektstamm: `_API_URL`, `_USER`, `_PASSWORD`, +`_TOKEN`. Folgen: die Datei liegt **nur hier auf DEV**, im Klartext, wird nicht deployt +und ist nicht ignoriert — trag sie in `.gitignore` ein und committe sie nie; für PROD dieselben +Schlüssel in `.env.example` deklarieren, die Werte in den Env-Editor. + + +## 3. Vorschau im Browser läuft über einen Proxy Deine Entwicklungs-Server sind nicht direkt erreichbar. VCH erkennt lauschende Ports und stellt zwei Wege bereit: @@ -39,14 +121,14 @@ Rahmenwerke: **Vite** `base: './'` · **Create React App** `"homepage": "."` in `package.json` · **Next.js** Vorgabe belassen (Subdomain) oder `basePath` setzen (Pfad-Proxy) · **Express** `express.static` genügt, solange das HTML relativ verweist. -## 3. Dateien, die VCH im Projektstamm liest +## 4. Dateien, die VCH im Projektstamm liest | Datei | Pflicht | Wozu | |---|---|---| | `README.md` | ja | Audit-Prüfung `readme` — blockiert die Veröffentlichung | | `.vch-description` | ja | Audit-Prüfung `description`; der Text steht später im Showcase | -| `.env.example` | wenn die App Umgebungsvariablen liest | die **Deklaration** aller Variablen (Abschnitt 6) | -| `.vch/deploy.yaml` | wenn die Autoerkennung nicht reicht | wie gebaut und gestartet wird (Abschnitt 5) | +| `.env.example` | wenn die App Umgebungsvariablen liest | die **Deklaration** aller Variablen (Abschnitt 7) | +| `.vch/deploy.yaml` | wenn die Autoerkennung nicht reicht | wie gebaut und gestartet wird (Abschnitt 6) | | `CLAUDE.md` | — | dein Projektwissen; VCH fasst sie nicht mehr an | `README.md` muss beschreiben, **was** das Projekt tut, wie man es installiert, wie man es @@ -56,7 +138,13 @@ startet und worauf es aufbaut. Die unveränderte Vorlage besteht die Prüfung ni für wen. Kein Technologie-Stapel, keine Installationsanleitung — die stehen im README. Halte beide aktuell, wenn sich Zweck oder Aufbau ändern. -## 4. Der Port gehört VCH +`.vch-description` legt VCH beim Anlegen als **Platzhalter** an, der die Prüfung nicht besteht — +dass die Datei existiert, heißt nicht, dass sie fertig ist. + +**VCH-eigen, nicht anfassen:** `.vch/PLATFORM.md`, `~/.vch_env`, `~/.config/vch/` und +`/etc/profile.d/vch-opencode.sh`. Wer „die Shell-Umgebung aufräumt", kappt sich den AI-Server. + +## 5. Der Port gehört VCH VCH weist jedem Projekt auf der PROD-Maschine einen festen Port zu (Bereich `13000–13999`) und hält ihn über alle Deploys hinweg stabil. Du wählst ihn nicht und stimmst dich mit @@ -73,13 +161,13 @@ Ein fest verdrahteter Port läuft hier und scheitert dort am Healthcheck. `PORT` `VCH_DATA_DIR` sind **VCH-eigene** Schlüssel: setzt du sie selbst — in `.env.example`, im Env-Editor oder im Compose — wird dein Wert verworfen. -## 5. `.vch/deploy.yaml` — die vollständige Referenz +## 6. `.vch/deploy.yaml` — die vollständige Referenz -Ohne diese Datei versucht VCH, das Projekt selbst zu erkennen (Abschnitt 5.3). Reicht das +Ohne diese Datei versucht VCH, das Projekt selbst zu erkennen (Abschnitt 6.3). Reicht das nicht, bricht der Deploy ab **und legt dir eine fertige Vorlage vor**, die du nur noch committen musst. -### 5.1 Alle Felder +### 6.1 Alle Felder | Feld | Typ | Vorgabe | Bedeutung | |---|---|---|---| @@ -94,7 +182,7 @@ committen musst. Unbekannte Felder und Tippfehler in `runtime` brechen den Deploy mit einer benannten Fehlermeldung ab — sie werden nicht still ignoriert. -### 5.2 Beispiele +### 6.2 Beispiele Node mit eigenem Build (systemd): @@ -145,7 +233,7 @@ runtime: docker compose: docker/compose.yaml ``` -### 5.3 Was ohne die Datei passiert +### 6.3 Was ohne die Datei passiert In dieser Reihenfolge: @@ -155,13 +243,13 @@ In dieser Reihenfolge: App-Dienst), wird als Entwicklungs-Beiwerk erkannt und **nicht** deployt — VCH sucht weiter nach der Sprache. 4. Eine Compose-Datei mit App-Dienst, die `${VCH_PORT}` **nicht** veröffentlicht → Abbruch - mit Vorlage (Abschnitt 10). + mit Vorlage (Abschnitt 13). 5. `package.json` im Stamm: `build`-Skript → Build, `start`-Skript bzw. `main` → Start. Paketmanager wird am Lockfile erkannt (`pnpm`/`yarn`/`npm`). 6. Alles andere — Python, PHP/Laravel, Go, reines Frontend ohne `start`, Code nur in Unterordnern — endet mit einer passenden Vorlage zum Committen. -## 6. Umgebungsvariablen: `.env.example` ist die Deklaration +## 7. Umgebungsvariablen: `.env.example` ist die Deklaration VCH liest keine Umgebungsvariablen aus deinem Code. **Was in einer Beispieldatei steht, existiert; alles andere nicht.** @@ -200,12 +288,25 @@ und jeden Treffer in die `.env.example` schreiben. du passende `ARG`-Zeilen im Dockerfile. Alle übrigen Werte kommen erst zur Laufzeit über `--env-file` und landen nie im Image. -## 7. Datenbank +## 8. Datenbank Deklariert deine App Verbindungsvariablen, bietet VCH eine verwaltete Datenbank an und trägt die Werte **automatisch** in genau die Schlüssel ein, die du deklariert hast. Du musst deine Variablennamen also nicht ändern — VCH erkennt sie. +**Was du bekommst:** genau **eine** verwaltete Datenbank je Projekt — Postgres 16 oder MySQL 8 +als eigener Container auf der PROD-Maschine, Host `127.0.0.1`, Port aus `15000–15999`, nicht +aus dem LAN erreichbar. Datenbank und Benutzer heißen `app`; der Mensch kann beim Anlegen unter +„Erweitert" eigene Werte wählen (Kleinbuchstaben, Ziffern, Unterstrich, Buchstabe vorne; +reservierte Namen und das Präfix `pg_` sind gesperrt) — **nur beim Anlegen**, +danach nicht mehr. Das Passwort wird erzeugt oder gewählt und beim Deploy in deine Schlüssel +eingetragen; deklarierst du keine erkennbaren Schlüssel, zeigt die Karte im Projektarbeitsplatz +die Verbindung samt Passwort zum Kopieren. Auf DEV gibt es diese Datenbank nicht (Wegweiser). +Migrationen macht deine App im `build` oder `start`. Backup: der Abzug liegt im Projekt-Backup, +vor dem Löschen zieht VCH einen Pflicht-Abzug. Solange die Datenbank läuft, werden DB-Zeilen im +Env-Editor gestrichen — dein eigener Wert hält nicht. Deklarierst du **zwei** Verbindungen, +landen beide auf dieser einen Datenbank: dann Compose (Wegweiser). + **Erkannt wird** (Groß-/Kleinschreibung egal): - Verbindungszeichenketten: `DATABASE_URL`, `DB_URL`, jeder Name auf `_DSN` @@ -234,7 +335,7 @@ verliert seine Daten beim nächsten Neubau, und VCH warnt nur. Eine Compose-Datei, die **nur** eine Datenbank startet, ist Entwicklungs-Beiwerk: sie läuft in Produktion nicht mit. Nimm dort die verwaltete Datenbank. -## 8. Dauerhafte Daten: `VCH_DATA_DIR` +## 9. Dauerhafte Daten: `VCH_DATA_DIR` Jedes Release liegt in einem eigenen Verzeichnis und verschwindet wieder. Was überleben soll — SQLite-Datei, Uploads, erzeugte Dateien — gehört in das Verzeichnis aus **`VCH_DATA_DIR`**. @@ -247,25 +348,25 @@ VCH setzt die Variable bei allen Betriebsarten (bei Docker als Einhängepunkt `/ sie zur Laufzeit**, schreibe den Pfad nirgends fest und trage sie nicht in `.env`-Dateien ein. Ein Verzeichnis relativ zum Release (`./data`) ist beim nächsten Deploy weg. -Der Prüfling während des Audits (Abschnitt 9) bekommt diesen Speicher **nicht** — er läuft +Der Prüfling während des Audits (Abschnitt 10) bekommt diesen Speicher **nicht** — er läuft gegen einen leeren. Das ist Absicht: zwei Prozesse dürfen nicht gleichzeitig auf dieselbe SQLite-Datei schreiben. -## 9. Wie ein Deploy abläuft +## 10. Wie ein Deploy abläuft ``` Klonen → Bauen → Healthcheck (Prüfling) → Audit → Umschalten → Live ``` 1. **Klonen.** VCH klont den Deploy-Branch auf die PROD-Maschine — genau den gepushten Stand. -2. **Torprüfung.** Fehlt ein blockierender Wert aus `.env.example` (Abschnitt 6), endet es hier, +2. **Torprüfung.** Fehlt ein blockierender Wert aus `.env.example` (Abschnitt 7), endet es hier, bevor irgendetwas läuft. 3. **Bauen** nach `.vch/deploy.yaml` bzw. Autoerkennung. 4. **Healthcheck.** Der neue Stand startet als **Prüfling** auf einem freien Port aus dem Bereich `14000–14999`, neben der weiterhin laufenden alten Fassung. VCH ruft dort deinen `health`-Pfad auf: rund 30 Sekunden lang, alle 1,5 s ein Versuch, Erfolg beim ersten 2xx/3xx. Bis dahin muss die App antworten. -5. **Audit** (Abschnitt 10) gegen den deployten Commit. +5. **Audit** (Abschnitt 11) gegen den deployten Commit. 6. **Umschalten.** Erst wenn das Audit besteht, wird der Prüfling zur laufenden Fassung auf dem stabilen Port. **Ein durchgefallenes Audit heißt: der alte Stand bleibt live.** @@ -276,7 +377,7 @@ Projektarbeitsplatz per Rollback zurückholen. **Auto-Deploy:** ist er eingeschaltet, löst jeder Push auf den konfigurierten Deploy-Branch einen Deploy aus. Pushes auf andere Branches werden ignoriert. -## 10. Audit — was geprüft wird +## 11. Audit — was geprüft wird Das Audit läuft nicht hier, sondern in einem eigenen Runner gegen einen **sauberen Checkout des deployten Commits**. Nicht Gepushtes wird nicht geprüft — und ein `.gitignore`-Eintrag @@ -299,7 +400,7 @@ macht eine Datei für das Audit unsichtbar. - `secrets` sucht eingecheckte Zugangsdaten. Echte Werte gehören nie ins Repository: in die `.env.example` nur Beispiele, die echten Werte in den Env-Editor von VCH. -## 11. Veröffentlichen +## 12. Veröffentlichen - **Intern erreichbar** ist dein Projekt, sobald ein Deploy läuft — ohne Audit, ohne weiteren Schritt, unter `http://:`. @@ -309,7 +410,7 @@ macht eine Datei für das Audit unsichtbar. - Im **Showcase** erscheinen veröffentlichte Projekte mit bestandenem Audit und einer eingecheckten `.vch-description`. -## 12. Compose — die vier Regeln, an denen es sonst scheitert +## 13. Compose — die vier Regeln, an denen es sonst scheitert 1. **Der App-Dienst muss seinen Port als `ports: ["${VCH_PORT}:"]` veröffentlichen.** VCH setzt `VCH_PORT` und kann so einen Prüfling neben deiner laufenden Fassung starten. diff --git a/README.md b/README.md index 651f40c..fdaed0b 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,16 @@ auf „Anmelden". Die Anmeldung gilt 30 Tage im jeweiligen Browser; nach fünf f wartet der Server eine Minute. Ohne `EDIT_PASSWORD` darf jeder alles, wie bei einem Werkzeug für eine einzelne Person. +## Tauschanfragen + +Wer an seinem Tag nicht kann, klickt den Tag im Kalender an, wählt seinen Namen, optional die +Person und den Tag für den Tausch, und schreibt eine Notiz. Dafür ist keine Anmeldung nötig; +pro Adresse sind zehn Anfragen je Stunde möglich. Der Tag bekommt im Kalender eine Markierung. +Bearbeiter sehen alle Anfragen unter „Anfragen" mit Zähler in der Navigation und entscheiden: +„Ausführen" tauscht wie gewünscht (über Kreuz oder Übernahme des Tages), „Tag öffnen" lässt +manuell wählen, „Erledigt" und „Ablehnen" schließen die Anfrage ohne Änderung. Nach einem +Tausch den Zeitraum erneut nach Nextcloud schreiben. + ## Nextcloud 1. Umgebungsvariablen setzen und App neu starten. diff --git a/public/app.js b/public/app.js index 7089d7f..467a979 100644 --- a/public/app.js +++ b/public/app.js @@ -51,7 +51,7 @@ async function api(path, { method = 'GET', body, raw } = {}) { } // ---------- Anmeldung zum Bearbeiten ---------- -const EDITOR_VIEWS = ['personen', 'abwesenheiten', 'einstellungen']; +const EDITOR_VIEWS = ['personen', 'abwesenheiten', 'anfragen', 'einstellungen']; function isEditor() { return !state.data || state.data.auth?.editor !== false; } function applyAuthUI() { const a = state.data?.auth || { protected: false, editor: true }; @@ -62,6 +62,11 @@ function applyAuthUI() { for (const link of $$('.topbar nav a')) link.hidden = !a.editor && EDITOR_VIEWS.includes(link.dataset.view); $('#open-plan').hidden = !a.editor; $('#open-nextcloud').hidden = !a.editor; + $('#open-request').hidden = a.editor; + const open = (state.data?.requests || []).filter((r) => r.status === 'offen').length; + const badge = $('#requests-count'); + badge.hidden = !a.editor || open === 0; + badge.textContent = open; if (!a.editor && EDITOR_VIEWS.includes(location.hash.slice(1))) location.hash = '#kalender'; } function openLogin() { @@ -139,7 +144,7 @@ function avatarEl(p, cls = '') { // ---------- Navigation ---------- function showView(name) { - const known = ['kalender', 'personen', 'abwesenheiten', 'einstellungen']; + const known = ['kalender', 'personen', 'abwesenheiten', 'anfragen', 'einstellungen']; if (!known.includes(name)) name = 'kalender'; if (!isEditor() && EDITOR_VIEWS.includes(name)) name = 'kalender'; if (name === 'abwesenheiten' && state.data) prepareAbsenceImport(); @@ -170,10 +175,12 @@ function renderCalendar() { if (cur.getDay() === 0 || cur.getDay() === 6) continue; const date = iso(cur); const outside = cur.getMonth() !== d.getMonth(); - const editable = !outside && isEditor(); + const assigned = assignments[date] && !assignments[date].off && !holidayFor(date) && (assignments[date].main || assignments[date].co); + const editable = !outside && (isEditor() || Boolean(assigned)); const cell = document.createElement(editable ? 'button' : 'div'); cell.className = 'day'; if (editable) { cell.type = 'button'; cell.dataset.date = date; } + if (!isEditor() && assigned) cell.classList.add('requestable'); if (outside) cell.classList.add('outside'); if (date === today) cell.classList.add('today'); if (date < today) cell.classList.add('past'); @@ -183,6 +190,8 @@ function renderCalendar() { numText.textContent = cur.getDate(); num.appendChild(numText); cell.appendChild(num); + const hasRequest = !outside && (state.data.requestDates || []).includes(date); + if (hasRequest) { const m = document.createElement('span'); m.className = 'req-mark'; m.title = 'Tauschanfrage offen'; num.appendChild(m); } if (outside) { grid.appendChild(cell); continue; } const hol = holidayFor(date); @@ -212,7 +221,7 @@ function renderCalendar() { pair.appendChild(slotEl(co, 'co', date)); cell.appendChild(pair); const absentNote = (p) => (p && isAbsent(p.id, date) ? ' (abwesend)' : ''); - cell.setAttribute('aria-label', `${fmtLong(date)}: Haupt ${fullName(main)}${absentNote(main)}, Neben ${fullName(co)}${absentNote(co)}${a.locked ? ', fixiert' : ''}`); + cell.setAttribute('aria-label', `${fmtLong(date)}: Haupt ${fullName(main)}${absentNote(main)}, Neben ${fullName(co)}${absentNote(co)}${a.locked ? ', fixiert' : ''}${hasRequest ? ', Tauschanfrage offen' : ''}`); } else { cell.setAttribute('aria-label', `${fmtLong(date)}, noch nicht ausgelost`); } @@ -288,9 +297,128 @@ $('#next-month').addEventListener('click', () => { state.month = new Date(state. $('#today-month').addEventListener('click', () => { state.month = startOfMonth(new Date()); renderCalendar(); }); $('#cal-grid').addEventListener('click', (e) => { const cell = e.target.closest('.day[data-date]'); - if (cell) openDayDialog(cell.dataset.date); + if (!cell) return; + if (isEditor()) openDayDialog(cell.dataset.date); else openRequestDialog(cell.dataset.date); }); +// ---------- Tauschanfrage (für alle) ---------- +const requestDialog = $('#request-dialog'); +const requestForm = $('#request-form'); +function fillRequestPersons(date) { + const f = requestForm.elements; + const a = state.data.assignments[date] || {}; + const active = state.data.persons.filter((p) => p.active !== false && (p.canMain || p.canCo)).sort((x, y) => fullName(x).localeCompare(fullName(y))); + const assigned = [a.main, a.co].map(personById).filter(Boolean); + f.personId.textContent = ''; + f.personId.appendChild(new Option('– bitte wählen –', '')); + if (assigned.length) { + const g = document.createElement('optgroup'); + g.label = 'An diesem Tag eingeteilt'; + for (const p of assigned) g.appendChild(new Option(fullName(p), p.id)); + f.personId.appendChild(g); + } + const g2 = document.createElement('optgroup'); + g2.label = 'Alle'; + for (const p of active) g2.appendChild(new Option(fullName(p), p.id)); + f.personId.appendChild(g2); + const current = f.partnerId.value; + f.partnerId.textContent = ''; + f.partnerId.appendChild(new Option('– noch offen –', '')); + for (const p of active) f.partnerId.appendChild(new Option(fullName(p), p.id)); + f.partnerId.value = current || ''; + $('#request-info').textContent = assigned.length + ? `${fmtLong(date)}: ${assigned.map((p, i) => `${i === 0 && a.main === p.id ? 'Haupt' : 'Neben'} ${fullName(p)}`).join(', ')}` + : `${fmtLong(date)}: kein Meeting geplant`; +} +function openRequestDialog(date) { + const f = requestForm.elements; + requestForm.reset(); + f.date.value = date || ''; + fillRequestPersons(f.date.value); + $('#request-result').hidden = true; + $('#request-submit').disabled = false; + requestDialog.showModal(); +} +$('#open-request').addEventListener('click', () => openRequestDialog('')); +$('#request-close').addEventListener('click', () => requestDialog.close()); +requestForm.elements.date.addEventListener('change', () => fillRequestPersons(requestForm.elements.date.value)); +requestForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const f = requestForm.elements; + const out = $('#request-result'); + $('#request-submit').disabled = true; + try { + await api('api/requests', { method: 'POST', body: { personId: f.personId.value, date: f.date.value, partnerId: f.partnerId.value || null, partnerDate: f.partnerDate.value || null, note: f.note.value } }); + out.className = 'result'; + out.textContent = 'Danke, die Anfrage ist bei der Planung angekommen. Der Tag ist im Kalender markiert, bis sie bearbeitet ist.'; + out.hidden = false; + $('#request-close').textContent = 'Schließen'; + await load(); + } catch (err) { + out.className = 'result error'; + out.textContent = err.message; + out.hidden = false; + $('#request-submit').disabled = false; + } +}); +requestDialog.addEventListener('close', () => { $('#request-close').textContent = 'Abbrechen'; }); + +// ---------- Anfragen bearbeiten (nur Bearbeiter) ---------- +function requestRow(r) { + const li = document.createElement('li'); + const p = personById(r.personId); + li.appendChild(avatarEl(p, 'plain small')); + const grow = document.createElement('div'); + grow.className = 'grow'; + const strong = document.createElement('strong'); + strong.textContent = `${fullName(p)} kann am ${fmtDate(r.date)} nicht (${r.role === 'main' ? 'Haupt' : 'Neben'}moderation)`; + grow.appendChild(strong); + const meta = document.createElement('div'); + meta.className = 'request-meta'; + const partner = r.partnerId ? personById(r.partnerId) : null; + const parts = []; + if (partner) parts.push(`Wunsch: Tausch mit ${fullName(partner)}${r.partnerDate ? ` am ${fmtDate(r.partnerDate)}` : ' (übernimmt den Tag)'}`); + else parts.push('Kein Wunschpartner genannt'); + parts.push(`gestellt am ${new Date(r.createdAt).toLocaleDateString('de-DE')}`); + if (r.status !== 'offen') parts.push(r.status === 'erledigt' ? 'erledigt' : 'abgelehnt'); + meta.textContent = parts.join(' · '); + grow.appendChild(meta); + if (r.note) { const n = document.createElement('div'); n.className = 'request-note'; n.textContent = `„${r.note}"`; grow.appendChild(n); } + li.appendChild(grow); + const actions = document.createElement('div'); + actions.className = 'request-actions'; + const btn = (label, cls, fn) => { const b = document.createElement('button'); b.type = 'button'; b.className = cls; b.textContent = label; b.addEventListener('click', fn); actions.appendChild(b); }; + if (r.status === 'offen') { + if (r.partnerId) btn('Ausführen', 'primary', () => run(async () => { + const res = await api(`api/requests/${r.id}/execute`, { method: 'POST' }); + return res; + }, 'Tausch ausgeführt. Nextcloud danach neu schreiben, damit die Termine stimmen.')); + btn('Tag öffnen', '', () => { location.hash = '#kalender'; state.month = new Date(r.date + 'T12:00:00'); renderCalendar(); openDayDialog(r.date); }); + btn('Erledigt', 'ghost', () => run(() => api(`api/requests/${r.id}`, { method: 'PUT', body: { status: 'erledigt' } }), 'Als erledigt markiert.')); + btn('Ablehnen', 'ghost', () => run(() => api(`api/requests/${r.id}`, { method: 'PUT', body: { status: 'abgelehnt' } }), 'Anfrage abgelehnt.')); + } else { + btn('Wieder öffnen', 'ghost', () => run(() => api(`api/requests/${r.id}`, { method: 'PUT', body: { status: 'offen' } }))); + btn('Löschen', 'ghost', () => run(() => api(`api/requests/${r.id}`, { method: 'DELETE' }))); + } + li.appendChild(actions); + return li; +} +function renderRequests() { + const all = state.data.requests || []; + const open = all.filter((r) => r.status === 'offen').sort((a, b) => a.date.localeCompare(b.date)); + const done = all.filter((r) => r.status !== 'offen').sort((a, b) => (b.resolvedAt || '').localeCompare(a.resolvedAt || '')); + const list = $('#request-list'); + list.textContent = ''; + if (open.length === 0) list.innerHTML = '
  • Keine offenen Anfragen.
  • '; + for (const r of open) list.appendChild(requestRow(r)); + const doneList = $('#request-list-done'); + doneList.textContent = ''; + for (const r of done) doneList.appendChild(requestRow(r)); + doneList.hidden = !$('#requests-show-done').checked || done.length === 0; + $('#requests-toggle').hidden = done.length === 0; +} +$('#requests-show-done').addEventListener('change', renderRequests); + // ---------- Tag-Dialog ---------- const dayDialog = $('#day-dialog'); const dayForm = $('#day-form'); @@ -1014,6 +1142,7 @@ function renderAll() { renderPersons(); renderPhotoField(); renderAbsences(); + renderRequests(); renderSettings(); if (!$('#view-abwesenheiten').hidden) prepareAbsenceImport(); } else { diff --git a/public/index.html b/public/index.html index c21dcd8..9f8b3bc 100644 --- a/public/index.html +++ b/public/index.html @@ -16,6 +16,7 @@ Kalender Personen Abwesenheiten + Anfragen Einstellungen @@ -32,6 +33,7 @@
    + ICS-Datei @@ -49,6 +51,7 @@ N Nebenmoderation fixiert unbesetzt + Tauschanfrage offen

    Einsätze in diesem Monat

    @@ -145,6 +148,15 @@
      + + +