Compare commits
7
Commits
57e201b8fc
...
db-branch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18d04e2c4a | ||
|
|
4fc5acfebb | ||
|
|
1c176ea621 | ||
|
|
91cc898ec7 | ||
|
|
4e083bee73 | ||
|
|
da64472251 | ||
|
|
115f0f5671 |
No files matched your search
@@ -0,0 +1,7 @@
|
|||||||
|
# SQLite-Datenbank (Laufzeitartefakt in sessions/)
|
||||||
|
sessions/*.db
|
||||||
|
sessions/*.db-wal
|
||||||
|
sessions/*.db-shm
|
||||||
|
|
||||||
|
# macOS-Artefakte
|
||||||
|
.DS_Store
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
# AGENTS.md
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Single-page app for tracking engineering projects across weekly sessions
|
|
||||||
("Sitzungen"). Vanilla JS/HTML/CSS frontend + Node.js/Express server. No build
|
|
||||||
step, no bundler, no tests. Data is persisted as semicolon-separated CSVs
|
|
||||||
under `sessions/`.
|
|
||||||
|
|
||||||
Two HTML entrypoints, both served statically by Express:
|
|
||||||
- `Test.html` — the main app (tabs: Projektboard, Auslastung, Projekte,
|
|
||||||
Projektbeteiligte). Served at `/`.
|
|
||||||
- `project-plan.html` — per-project Gantt/TODO planning view, opened in a new
|
|
||||||
tab via `openProjectPlan()` in `js/gantt.js`. Loads the shared helper modules
|
|
||||||
(`globals`, `utils`, `ui`, `date-picker`, `csv`, `server`, `gantt`) plus the
|
|
||||||
plan modules under `js/plan/` — NOT the full main module set.
|
|
||||||
|
|
||||||
All UI strings/comments/CSV content are German. Preserve German in
|
|
||||||
user-facing text.
|
|
||||||
|
|
||||||
## File structure
|
|
||||||
|
|
||||||
```
|
|
||||||
.
|
|
||||||
├── server.js # Express server + CSV API
|
|
||||||
├── package.json # deps: express, cors only
|
|
||||||
├── docker-compose.yml # at repo ROOT; build context ., dockerfile Docker/Dockerfile
|
|
||||||
├── Docker/Dockerfile # node:20-alpine
|
|
||||||
├── .dockerignore
|
|
||||||
├── .vscode/extensions.json
|
|
||||||
├── public/
|
|
||||||
│ ├── Test.html # main app (served at /)
|
|
||||||
│ ├── js/ # shared modules (classic scripts, global fns)
|
|
||||||
│ │ ├── globals.js # Konstanten (LPHS, ROLES, LPH-Farben, DEPARTMENTS) + db/currentDepartment
|
|
||||||
│ │ ├── utils.js # esc, uid, Datum-/Differenz-Helfer, personIdForName, weekSortKey-Quelle
|
|
||||||
│ │ ├── ui.js # LocalStorage-UI-State, Header-Collapse, Theme
|
|
||||||
│ │ ├── date-picker.js # Datums-/Bereichspicker-Overlays
|
|
||||||
│ │ ├── csv.js # CSV-Parser/Serializer + weekSortKey, filenameForWeek
|
|
||||||
│ │ ├── server.js # baseUrl, sendCsvToServer, deleteCsvFromServer, blobToDataUrl
|
|
||||||
│ │ ├── store.js # save/syncAllToServer, switchDepartment, load, seed
|
|
||||||
│ │ ├── weeks.js # Wochen-Auswahl, -Erzeugung, -Klonen, Auto-Lock, -Löschung
|
|
||||||
│ │ ├── gantt.js # GANTT-Diagramm (buildGanttHtml, openProjectPlan), geshared
|
|
||||||
│ │ ├── showmode.js # Show-Mode (Präsentationsmodus)
|
|
||||||
│ │ ├── tabs-auth.js # showTab, confirmPassword (SHA256-Passwortschutz)
|
|
||||||
│ │ ├── pdf.js # PDF-Export (pdfmake, A3 quer)
|
|
||||||
│ │ └── init.js # renderAll/initUiState + Seiteneinstieg (nur Test.html)
|
|
||||||
│ │ ├── board/ # tab-specific: Projektboard
|
|
||||||
│ │ │ └── board.js # Projektboard-Rendering, Status-Editor, Anwesenheit, Zuordnung
|
|
||||||
│ │ ├── people/ # tab-specific: Projektbeteiligte
|
|
||||||
│ │ │ └── people.js # Personen-Tab-Rendering + Drag-and-Drop + Abwesenheiten
|
|
||||||
│ │ ├── projects/ # tab-specific: Projekte
|
|
||||||
│ │ │ └── projects.js # Projekte-Tab-Rendering + Drag-and-Drop
|
|
||||||
│ │ ├── load/ # tab-specific: Auslastung
|
|
||||||
│ │ │ └── load.js # Auslastungs-Tab, LP-Modal, Neues-Projekt-Modal
|
|
||||||
│ ├── project-plan.html # Gantt/TODO planning view
|
|
||||||
│ ├── js/plan/ # planning modules (nur von project-plan.html geladen)
|
|
||||||
│ │ ├── plan-state.js # PlanApp-Zustand, Datenladepath, savePlan, Toasts
|
|
||||||
│ │ ├── plan-build-gantt.js # buildGanttHtml-Erweiterung (TODO- und Meilensteinzeilen)
|
|
||||||
│ │ ├── plan-gantt.js # renderPlanGantt, Abhängigkeitslinien, Zoom, Ressourcen-Load
|
|
||||||
│ │ ├── plan-drag.js # GANTT-Balken-Drag/Resize + TODO-/Meilenstein-Zeilen-Drag
|
|
||||||
│ │ ├── plan-modals.js # TODO-, Meilenstein- und Abwesenheits-Modals
|
|
||||||
│ │ └── plan-schedule.js # Kritischer Pfad, Auto-Schedule, Mail-Link
|
|
||||||
│ ├── styles.css
|
|
||||||
│ └── dein_bild.ico
|
|
||||||
└── sessions/ # data; bind-mounted into Docker container
|
|
||||||
├── ELT/ HLS/ ARCH/ # one folder per department
|
|
||||||
```
|
|
||||||
|
|
||||||
## Running
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
npm start # http://localhost:3003, or PORT=3004 if occupied
|
|
||||||
docker compose up --build # Docker variant (compose file is at repo root)
|
|
||||||
```
|
|
||||||
|
|
||||||
- PORT env var overrides default 3003 in `server.js`.
|
|
||||||
- Frontend must be served over HTTP: `baseUrl()` in `js/server.js` uses
|
|
||||||
`window.location.origin` or falls back to `http://localhost:3003` when opened
|
|
||||||
via `file://` (then only seed/demo data loads).
|
|
||||||
- Check `/health` returns `{ok:true}` after starting.
|
|
||||||
|
|
||||||
## Server API (`server.js`)
|
|
||||||
|
|
||||||
- `GET /` → `Test.html`; `GET /health`.
|
|
||||||
- Deep links per department + tab: `GET /<Dept>/<Tab>` → `Test.html`, e.g.
|
|
||||||
`/HLS/Auslastung` or `/ELT/Projektübersicht`. Tab slugs (`TAB_SLUGS` in
|
|
||||||
`js/globals.js`) are `Projektübersicht|Auslastung|Projekte|Projektbeteiligte`;
|
|
||||||
anything else returns 404. Exception: `GET /<Dept>/Projektplan(/<ProjName>)?`
|
|
||||||
(`PLAN_SLUG`) serves `project-plan.html`, the GANTT view, with
|
|
||||||
`weekLabel|projNr` as query params (the project name is a readable path
|
|
||||||
segment only; identification happens via weekLabel + projNr). The frontend
|
|
||||||
reads/writes these routes via `applyUrlRoute()`/`updateRouteUrl()` in
|
|
||||||
`js/ui.js` and all static assets in the HTML use **absolute** paths
|
|
||||||
(`/js/...`, `/styles.css`) so they resolve on nested routes.
|
|
||||||
- `GET /load-all?department=` → `{sessions:[{filename,content}], people}` with
|
|
||||||
`_people.csv` content in `people`; sessions sorted by filename.
|
|
||||||
- `POST /save-csv {filename, csv, department}` → writes UTF-8 BOM CSV.
|
|
||||||
- `POST /delete-csv {filename, department}`.
|
|
||||||
- Valid departments hard-coded as `ELT|HLS|ARCH` in `server.js`
|
|
||||||
(`VALID_DEPARTMENTS`); anything else silently falls back to `ELT`. Same set in
|
|
||||||
`DEPARTMENTS` in `js/globals.js`.
|
|
||||||
- `safeName()` strictly validates filenames (path traversal protection);
|
|
||||||
static assets get `Cache-Control: no-store`.
|
|
||||||
- JSON body limit is 2 MB.
|
|
||||||
|
|
||||||
## CSV format
|
|
||||||
|
|
||||||
- Delimiter `;`, UTF-8 with BOM. Hand-editing is possible but risky; the parser
|
|
||||||
reconstructs objects from the fixed column layout.
|
|
||||||
- Week files `<Label>.csv` (e.g. `KW_30___2026.csv` — `filenameForWeek()`
|
|
||||||
replaces non-word chars with `_`). Row layout is positional:
|
|
||||||
`WOCHE` (label, date, locked flag) → `PERSONEN` (`name|role|fullName` per
|
|
||||||
column) → header row → `ANWESENHEIT` → one row per project.
|
|
||||||
- `_people.csv` per department: `NAME;Rollen;Voller Name;E-Mail;Abwesenheiten`
|
|
||||||
(absences as JSON). People are global per department; new people are copied
|
|
||||||
into the current week. Historical weeks keep their own person copies, so
|
|
||||||
deleting a global person leaves old sessions unaffected no matter what.
|
|
||||||
- Don't change the column layout without updating both `weekToCsv()` and
|
|
||||||
`parseSessionCsv()` (`js/csv.js`), which must stay inverse of each other.
|
|
||||||
|
|
||||||
## Frontend architecture
|
|
||||||
|
|
||||||
- The frontend is split into small classic non-module `<script>` files under
|
|
||||||
`public/js/`; **all functions are globals on `window`**. Two HTML files include
|
|
||||||
them via `<script src>` tags; load order in the HTML defines dependencies,
|
|
||||||
since there is no bundler:
|
|
||||||
- `Test.html` loads `globals → utils → ui → date-picker → csv → server →
|
|
||||||
store → weeks` (shared modules), then the tab-specific folders `board →
|
|
||||||
people → projects → load`, finally `gantt → showmode → tabs-auth → pdf →
|
|
||||||
init`. See the exact list in `Test.html`.
|
|
||||||
- `project-plan.html` loads only `globals → utils → ui → date-picker → csv →
|
|
||||||
server → gantt` plus the `js/plan/*` modules. It must NOT load modules that
|
|
||||||
touch main-only page elements (`store`, `weeks`, `board`, …, `init`).
|
|
||||||
- Shared state object `window.PlanApp` (`js/plan/plan-state.js`) holds all
|
|
||||||
project-plan page state (week, project, zoom level, drag/`modal`-state),
|
|
||||||
`db`/`currentDepartment` live in `js/globals.js`.
|
|
||||||
- State: `let db = { weeks, currentWeek, globalPeople }` (in-memory only).
|
|
||||||
- Project IDs are regenerated on every CSV load, so projects are addressed
|
|
||||||
cross-page by `department` + week `label` + `projNr` (+ readable projName path
|
|
||||||
segment) via URL params: `openProjectPlan()` builds
|
|
||||||
`/HLS/Projektplan/<ProjName>?weekLabel=…&projNr=…`, `findProjectForPlan`
|
|
||||||
resolves them.
|
|
||||||
- Person IDs are stable across reloads via `personIdForName(name)` →
|
|
||||||
`p_<lowercased alnum>`.
|
|
||||||
- `save()` debounces 500 ms then `syncAllToServer()` writes **all** weeks plus
|
|
||||||
`_people.csv` — not just the changed week. Exception: `js/plan/plan-state.js`
|
|
||||||
`savePlan()` bypasses `save()` and writes only the current week's CSV directly
|
|
||||||
via `sendCsvToServer()`.
|
|
||||||
- Weeks are sorted by calendar week extracted from the label (`KW XX / YYYY`)
|
|
||||||
via `weekSortKey()`, not alphabetically/by filename.
|
|
||||||
- `applyAutoLock()` locks every week except the chronologically last one after
|
|
||||||
load/clone/new-week; `localStorage` UI state is not restored for locked tabs.
|
|
||||||
- `localStorage` keys: `projuebersicht_v1` (department only — tab is never
|
|
||||||
restored from storage; at startup the tab comes from the deep-link URL if
|
|
||||||
present, otherwise `board`), `projuebersicht-theme`, header-collapse key. Do
|
|
||||||
**not** store data in `localStorage`; it is UI state only.
|
|
||||||
- Deep-link URLs (`/<Dept>/<Tab>`) are shared via the address bar:
|
|
||||||
`updateRouteUrl()` rewrites the path via `history.replaceState` on tab and
|
|
||||||
department switches; protected tabs keep the URL but open the password modal.
|
|
||||||
|
|
||||||
## Auth / security quirk
|
|
||||||
|
|
||||||
- The `projects` and `people` tabs, week deletion, and week unlock are gated by
|
|
||||||
a password modal comparing `CryptoJS.SHA256(input)` to
|
|
||||||
`PROTECTED_PASSWORD_HASH` (`js/globals.js`, modal in `js/tabs-auth.js`).
|
|
||||||
Client-side only — not real security. CryptoJS and pdfmake (PDF export) are
|
|
||||||
loaded from `cdnjs.cloudflare.com` in `Test.html`, so those features need
|
|
||||||
internet access and won't work offline. `project-plan.html` loads neither.
|
|
||||||
- User-provided status HTML is sanitized on render via a small whitelist
|
|
||||||
(`sanitizeStatusHtml` in `js/board/board.js`) to mitigate XSS.
|
|
||||||
|
|
||||||
## Docker
|
|
||||||
|
|
||||||
- `docker compose up --build` from the repo root. Compose maps `3003:3003`,
|
|
||||||
bind-mounts `./sessions:/app/sessions`, `restart: unless-stopped`.
|
|
||||||
- Dockerfile: `node:20-alpine`, `npm ci --production`, runs as non-root `node`,
|
|
||||||
HEALTHCHECK on `/health`. `.dockerignore` excludes `Docker/*` (except
|
|
||||||
`Docker/Dockerfile`), `node_modules/`, `.git`, `npm-debug.log`.
|
|
||||||
|
|
||||||
## Conventions & verification
|
|
||||||
|
|
||||||
- Git repo; commits are German (e.g. "PDF Export von A4 auf A3 …"). Remote:
|
|
||||||
`http://192.168.3.187:3000/erik/projektuebersicht.git`.
|
|
||||||
- No linter, formatter, typecheck, or test suite. Manual verification only:
|
|
||||||
start server, open `http://localhost:3003`, exercises the relevant tab,
|
|
||||||
confirm CSV changes in `sessions/`.
|
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Single-page app for tracking engineering projects across weekly sessions
|
||||||
|
("Sitzungen"). Vanilla JS/HTML/CSS frontend + Node.js/Express server. No build
|
||||||
|
step, no bundler, no tests. Data is persisted in a SQLite database
|
||||||
|
(`node:sqlite`, needs Node >= 22.13) at `sessions/projektuebersicht.db`.
|
||||||
|
|
||||||
|
Two HTML entrypoints, both served statically by Express:
|
||||||
|
- `Test.html` — the main app (tabs: Projektboard, Auslastung, Projekte,
|
||||||
|
Projektbeteiligte). Served at `/`.
|
||||||
|
- `project-plan.html` — per-project Gantt/TODO planning view, opened in a new
|
||||||
|
tab via `openProjectPlan()` in `js/gantt.js`. Loads the shared helper modules
|
||||||
|
(`globals`, `utils`, `ui`, `date-picker`, `server`, `gantt`) plus the
|
||||||
|
plan modules under `js/plan/` — NOT the full main module set.
|
||||||
|
|
||||||
|
All UI strings/comments are German. Preserve German in
|
||||||
|
user-facing text.
|
||||||
|
|
||||||
|
## File structure
|
||||||
|
|
||||||
|
```
|
||||||
|
.
|
||||||
|
├── server.js # Express server + SQLite API
|
||||||
|
├── package.json # deps: express, cors only
|
||||||
|
├── docker-compose.yml # at repo ROOT; build context ., dockerfile Docker/Dockerfile
|
||||||
|
├── Docker/Dockerfile # node:22-alpine (nötig für node:sqlite)
|
||||||
|
├── .dockerignore
|
||||||
|
├── public/
|
||||||
|
│ ├── Test.html # main app (served at /)
|
||||||
|
│ ├── js/ # shared modules (classic scripts, global fns)
|
||||||
|
│ │ ├── globals.js # Konstanten (LPHS, ROLES, LPH-Farben, DEPARTMENTS) + db/currentDepartment
|
||||||
|
│ │ ├── utils.js # esc, uid, Datum-/Differenz-Helfer, personIdForName, peopleByRole, assignKey, weekSortKey
|
||||||
|
│ │ ├── ui.js # LocalStorage-UI-State, Header-Collapse, Theme
|
||||||
|
│ │ ├── date-picker.js # Datums-/Bereichspicker-Overlays
|
||||||
|
│ │ ├── server.js # baseUrl, sendWeekToServer, sendPeopleToServer, blobToDataUrl
|
||||||
|
│ │ ├── store.js # save/syncAllToServer, switchDepartment, load, seed
|
||||||
|
│ │ ├── weeks.js # Wochen-Auswahl, -Erzeugung, -Klonen, Auto-Lock, -Löschung
|
||||||
|
│ │ ├── gantt.js # GANTT-Diagramm (buildGanttHtml, openProjectPlan), geshared
|
||||||
|
│ │ ├── showmode.js # Show-Mode (Präsentationsmodus)
|
||||||
|
│ │ ├── tabs-auth.js # showTab, confirmPassword (SHA256-Passwortschutz)
|
||||||
|
│ │ ├── pdf.js # PDF-Export (pdfmake, A3 quer)
|
||||||
|
│ │ ├── csv.js # exportCSV (lädt ZIP von GET /export-csv)
|
||||||
|
│ │ ├── backup.js # downloadBackup (lädt SQLite-Snapshot von GET /backup)
|
||||||
|
│ │ ├── import.js # importDb (Backup-Wiederherstellung über POST /import, passwortgeschützt)
|
||||||
|
│ │ └── init.js # renderAll/initUiState + Seiteneinstieg (nur Test.html)
|
||||||
|
│ │ ├── board/ # tab-specific: Projektboard
|
||||||
|
│ │ │ └── board.js # Projektboard-Rendering, Status-Editor, Anwesenheit, Zuordnung
|
||||||
|
│ │ ├── people/ # tab-specific: Projektbeteiligte
|
||||||
|
│ │ │ └── people.js # Personen-Tab-Rendering + Drag-and-Drop + Abwesenheiten
|
||||||
|
│ │ ├── projects/ # tab-specific: Projekte
|
||||||
|
│ │ │ └── projects.js # Projekte-Tab-Rendering + Drag-and-Drop
|
||||||
|
│ │ ├── load/ # tab-specific: Auslastung
|
||||||
|
│ │ │ └── load.js # Auslastungs-Tab, LP-Modal, Neues-Projekt-Modal
|
||||||
|
│ ├── project-plan.html # Gantt/TODO planning view
|
||||||
|
│ ├── js/plan/ # planning modules (nur von project-plan.html geladen)
|
||||||
|
│ │ ├── plan-state.js # PlanApp-Zustand, Datenladepath, savePlan, Toasts
|
||||||
|
│ │ ├── plan-build-gantt.js # buildGanttHtml-Erweiterung (TODO- und Meilensteinzeilen)
|
||||||
|
│ │ ├── plan-gantt.js # renderPlanGantt, Abhängigkeitslinien, Zoom, Ressourcen-Load
|
||||||
|
│ │ ├── plan-drag.js # GANTT-Balken-Drag/Resize + TODO-/Meilenstein-Zeilen-Drag
|
||||||
|
│ │ ├── plan-modals.js # TODO-, Meilenstein- und Abwesenheits-Modals
|
||||||
|
│ │ └── plan-schedule.js # Kritischer Pfad, Auto-Schedule, Mail-Link
|
||||||
|
│ ├── styles.css
|
||||||
|
│ └── dein_bild.ico
|
||||||
|
└── sessions/ # SQLite-DB; bind-mounted into Docker container
|
||||||
|
└── projektuebersicht.db # node:sqlite database (WAL mode)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm start # http://localhost:3003, or PORT=3004 if occupied
|
||||||
|
docker compose up --build # Docker variant (compose file is at repo root)
|
||||||
|
```
|
||||||
|
|
||||||
|
- PORT env var overrides default 3003 in `server.js`.
|
||||||
|
- Frontend must be served over HTTP: `baseUrl()` in `js/server.js` uses
|
||||||
|
`window.location.origin` or falls back to `http://localhost:3003` when opened
|
||||||
|
via `file://` (then only seed/demo data loads).
|
||||||
|
- Check `/health` returns `{ok:true}` after starting.
|
||||||
|
|
||||||
|
## Server API (`server.js`)
|
||||||
|
|
||||||
|
- `GET /` → `Test.html`; `GET /health`.
|
||||||
|
- Deep links per department + tab: `GET /<Dept>/<Tab>` → `Test.html`, e.g.
|
||||||
|
`/HLS/Auslastung` or `/ELT/Projektübersicht`. Tab slugs (`TAB_SLUGS` in
|
||||||
|
`js/globals.js`) are `Projektübersicht|Auslastung|Projekte|Projektbeteiligte`;
|
||||||
|
anything else returns 404. Exception: `GET /<Dept>/Projektplan(/<ProjName>)?`
|
||||||
|
(`PLAN_SLUG`) serves `project-plan.html`, the GANTT view, with
|
||||||
|
`weekLabel|projNr` as query params (the project name is a readable path
|
||||||
|
segment only; identification happens via weekLabel + projNr). The frontend
|
||||||
|
reads/writes these routes via `applyUrlRoute()`/`updateRouteUrl()` in
|
||||||
|
`js/ui.js` and all static assets in the HTML use **absolute** paths
|
||||||
|
(`/js/...`, `/styles.css`) so they resolve on nested routes.
|
||||||
|
- `GET /load-all?department=` → `{weeks:[weekObj], people:[personObj]}` with the
|
||||||
|
global people list in `people`; weeks are full JSON week objects (id, label,
|
||||||
|
date, locked, updatedAt, people, attendance, projects), sorted by label.
|
||||||
|
- `POST /save-week {department, week, baseUpdatedAt}` → upserts one week (JSON
|
||||||
|
object, keyed by department + label). Used by `sendWeekToServer()`. The client
|
||||||
|
sends its local revision in `baseUpdatedAt` (the `updatedAt` value from
|
||||||
|
`load-all`); the server compares it to `weeks.updated_at` before writing and
|
||||||
|
answers `409 {ok:false, conflict:true, error, updatedAt}` if someone else
|
||||||
|
saved the week in the meantime. A save with a **non-empty** base for a week
|
||||||
|
the server no longer knows is also rejected with 409 ("wurde gelöscht") so a
|
||||||
|
deleted week can't silently resurrect; only new weeks (empty/missing base)
|
||||||
|
pass. On success it returns `{ok:true, updatedAt}` (the new revision), which
|
||||||
|
the client stores as the new sync base. Clients WITHOUT `baseUpdatedAt` (old
|
||||||
|
versions) always pass. The check runs synchronously in-process before the
|
||||||
|
write transaction (no race between check and write).
|
||||||
|
- `POST /save-people {department, people}` → replaces the department's global
|
||||||
|
people list (JSON array).
|
||||||
|
- `POST /delete-week {department, label}` → deletes the week; missing label
|
||||||
|
yields 400, missing week tolerates with `deleted:false`.
|
||||||
|
- `GET /export-csv?department=` → ZIP with one readable CSV per logical entity
|
||||||
|
(wochen, personen, sitzungsteilnehmer, projekte, zuordnungen,
|
||||||
|
leistungsphasen, aktuelle-leistungsphasen, aufwand-je-leistungsphase,
|
||||||
|
projektplaene, todos, meilensteine) for that department. CSV: UTF-8 with BOM,
|
||||||
|
CRLF, `;` separator by default (overridable via `?sep=`; German Excel opens
|
||||||
|
it directly). ZIP is built in pure Node (`zlib.deflateRawSync`, manual CRC32,
|
||||||
|
no external library). Used by `exportCSV()` in `js/csv.js`.
|
||||||
|
- `GET /backup` → consistent SQLite snapshot (`VACUUM INTO`, WAL-safe) as a
|
||||||
|
`.db` file download (temp file in `os.tmpdir()`, deleted after send). Used by
|
||||||
|
`downloadBackup()` in `js/backup.js`. Additionally, on server startup the
|
||||||
|
server writes a timestamped copy to `sessions/backups/` (also via
|
||||||
|
`VACUUM INTO`) and keeps only the `MAX_BACKUPS` newest ones (default 10).
|
||||||
|
- `POST /import` → restores the whole database from a `.db` backup file sent as
|
||||||
|
a raw binary body (`express.raw`, limit 100 MB). Validates the schema
|
||||||
|
(`pragma_table_info` on every table, exact column match vs. live DB), then
|
||||||
|
swaps all data via `ATTACH DATABASE …` + DELETE/INSERT-SELECT inside one
|
||||||
|
transaction (rollback on error). Immediately BEFORE the restore the server
|
||||||
|
saves a fresh auto-backup of the current state via `runAutoBackup()` (so a
|
||||||
|
restore can be undone), then performs the swap. Used by `importDb()` in
|
||||||
|
`js/import.js`, which is password-gated (`requestedAction` type `'importDb'`
|
||||||
|
in `js/tabs-auth.js`) like week deletion.
|
||||||
|
- Valid departments hard-coded as `ELT|HLS|ARCH` in `server.js`
|
||||||
|
(`VALID_DEPARTMENTS`); anything else silently falls back to `ELT`. Same set in
|
||||||
|
`DEPARTMENTS` in `js/globals.js`.
|
||||||
|
- Static assets get `Cache-Control: no-store`.
|
||||||
|
- JSON body limit is 2 MB.
|
||||||
|
|
||||||
|
## Persistence (SQLite)
|
||||||
|
|
||||||
|
- Data lives in `sessions/projektuebersicht.db` (`node:sqlite` `DatabaseSync`,
|
||||||
|
WAL mode, `PRAGMA foreign_keys = ON`, `PRAGMA busy_timeout = 5000` to avoid
|
||||||
|
`SQLITE_BUSY` under concurrent writers), created automatically. The schema is
|
||||||
|
**fully relational** (19 tables) instead of one big JSON document per week:
|
||||||
|
- `weeks` (department, label, week_id, date, locked, updated_at; PK
|
||||||
|
`(department,label)`) — `updated_at` is the revision timestamp for the
|
||||||
|
single-user conflict handling (see `/save-week` above)
|
||||||
|
- `week_people` / `week_people_roles` — person copies per week (`array_order`
|
||||||
|
keeps the list order, `role_order` the role array order)
|
||||||
|
- `attendance` — présence per week/person
|
||||||
|
- `projects` (`array_order` = position in the JSON array, `order_value` = the
|
||||||
|
`p.order` field, which is only unique *within a group*, not globally!)
|
||||||
|
- `project_lph`, `project_assign` (person_id|role keys split into columns),
|
||||||
|
`project_aufwand` (LP effort per role), `project_aktuelle_lp`
|
||||||
|
- `project_plan`, `plan_people`, `plan_todos` / `plan_todo_assignees` /
|
||||||
|
`plan_todo_deps`, `plan_milestones` (`position` column!) /
|
||||||
|
`plan_milestone_deps`
|
||||||
|
- `people` / `person_roles` / `person_absences` — global people per department
|
||||||
|
- The HTTP API stays JSON-shaped: `serializeWeek()` /
|
||||||
|
`serializeGlobalPeople()` split incoming week/people objects across the
|
||||||
|
tables inside a transaction, `loadWeeks()` / `loadGlobalPeople()`
|
||||||
|
(re)assemble them on read. `saveWeekRelational()` deletes the week's rows
|
||||||
|
first (`deleteWeekData()`, note `weeks` uses `label`, all child tables
|
||||||
|
`week_label`) and returns the new `updated_at` revision written by
|
||||||
|
`serializeWeek()`.
|
||||||
|
- Migration on every start (idempotent):
|
||||||
|
`migrateLegacySchema()` converts a DB from the old single-JSON-document format
|
||||||
|
(detected via a `data` column in `weeks`): it reads the documents, drops all
|
||||||
|
tables, recreates the relational schema and inserts everything in one
|
||||||
|
transaction. Afterwards `migrateWeeksUpdatedAt()` adds the `updated_at`
|
||||||
|
column to `weeks` (idempotent, `ALTER TABLE … ADD COLUMN`).
|
||||||
|
Note: legacy rows keep `updated_at = ''`, so the first client save with an
|
||||||
|
empty `baseUpdatedAt` matches and only then a real revision is written.
|
||||||
|
- The DB file sits in `sessions/`, so the existing Docker bind-mount
|
||||||
|
`./sessions:/app/sessions` keeps it persistent. The path is overridable via
|
||||||
|
the `DB_PATH` environment variable (used for tests), production default is
|
||||||
|
`sessions/projektuebersicht.db`.
|
||||||
|
- Project/person IDs are stable across reloads; only week-level fields are
|
||||||
|
queryable columns.
|
||||||
|
|
||||||
|
## Frontend architecture
|
||||||
|
|
||||||
|
- The frontend is split into small classic non-module `<script>` files under
|
||||||
|
`public/js/`; **all functions are globals on `window`**. Two HTML files include
|
||||||
|
them via `<script src>` tags; load order in the HTML defines dependencies,
|
||||||
|
since there is no bundler:
|
||||||
|
- `Test.html` loads `globals → utils → ui → date-picker → server →
|
||||||
|
store → weeks` (shared modules), then the tab-specific folders `board →
|
||||||
|
people → projects → load`, finally `gantt → showmode → tabs-auth → pdf →
|
||||||
|
init`. See the exact list in `Test.html`.
|
||||||
|
- `project-plan.html` loads only `globals → utils → ui → date-picker →
|
||||||
|
server → gantt` plus the `js/plan/*` modules. It must NOT load modules that
|
||||||
|
touch main-only page elements (`store`, `weeks`, `board`, …, `init`).
|
||||||
|
- Shared state object `window.PlanApp` (`js/plan/plan-state.js`) holds all
|
||||||
|
project-plan page state (week, project, zoom level, drag/`modal`-state),
|
||||||
|
`db`/`currentDepartment` live in `js/globals.js`.
|
||||||
|
- State: `let db = { weeks, currentWeek, globalPeople }` (in-memory only).
|
||||||
|
- Project/person IDs are stable across reloads because the server splits and
|
||||||
|
reassembles week objects deterministically (JSON IDs are kept in the DB
|
||||||
|
columns); projects are addressed cross-page by `department` + week `label` +
|
||||||
|
`projNr` (+ readable projName path segment) via URL params:
|
||||||
|
`openProjectPlan()` builds `/HLS/Projektplan/<ProjName>?weekLabel=…&projNr=…`,
|
||||||
|
`findProjectForPlan` resolves them. Person IDs follow
|
||||||
|
`personIdForName(name)` → `p_<lowercased alnum>`.
|
||||||
|
- `save()` debounces 500 ms then `syncAllToServer()` writes **only dirty weeks**
|
||||||
|
(`dirtyWeeks` – track `markWeekDirty()`) via `sendWeekToServer()`, plus the
|
||||||
|
people list `markPeopleDirty()` via `sendPeopleToServer()` if marked dirty.
|
||||||
|
An in-flight guard (`syncInFlight`/`syncQueued`) prevents overlapping syncs
|
||||||
|
from double-POSTing the same week with the same revision (which would look
|
||||||
|
like a spurious 409). On success the server-provided `updatedAt` revision is
|
||||||
|
adopted as the new sync base; weeks whose save failed (network errors) stay
|
||||||
|
dirty and are retried. A `409` conflict response drops the local changes for
|
||||||
|
that week and `handleWeekConflict()` reloads **only that week** from the
|
||||||
|
server (`reloadWeekFromServer()`), leaving all other dirty weeks and the
|
||||||
|
people list untouched. Exception: `js/plan/plan-state.js` `savePlan()`
|
||||||
|
bypasses `save()` and writes only the current week directly via
|
||||||
|
`sendWeekToServer()` (with the same in-flight/trailing-save guard), adopting
|
||||||
|
the returned `updatedAt` and showing a toast on conflict. `switchWeek()` (in
|
||||||
|
`js/weeks.js`) does NOT trigger a save – only actual edits do.
|
||||||
|
- `switchDepartment()` cancels a pending save debounce before switching, and
|
||||||
|
`load()` discards stale `/load-all` responses after a department switch
|
||||||
|
(frozen `requestedDepartment`), so late responses can never render or persist
|
||||||
|
data under the wrong department.
|
||||||
|
- Person drag-and-drop in the people tab: drag handle (`.drag-handle`) binds
|
||||||
|
`ondragstart`/`ondragend`, the `.people-sortable` container binds
|
||||||
|
`ondragover`/`ondrop`; insertion is computed by `getDragAfterElement(container,
|
||||||
|
y, selector)` (`js/projects/projects.js`, used with `.person-card` here and
|
||||||
|
`.person-list-item` in the projects tab). `reorderPeople()` updates both
|
||||||
|
`db.globalPeople` and the current week's person copy, then `save()`. No drag
|
||||||
|
handles are rendered on locked weeks.
|
||||||
|
- Weeks are sorted by calendar week extracted from the label (`KW XX / YYYY`)
|
||||||
|
via `weekSortKey()`, not alphabetically/by filename.
|
||||||
|
- `applyAutoLock()` locks every week except the chronologically last one after
|
||||||
|
load/clone/new-week; `localStorage` UI state is not restored for locked tabs.
|
||||||
|
- `localStorage` keys: `projuebersicht_v1` (department only — tab is never
|
||||||
|
restored from storage; at startup the tab comes from the deep-link URL if
|
||||||
|
present, otherwise `board`), `projuebersicht-theme`, header-collapse key. Do
|
||||||
|
**not** store data in `localStorage`; it is UI state only.
|
||||||
|
- Deep-link URLs (`/<Dept>/<Tab>`) are shared via the address bar:
|
||||||
|
`updateRouteUrl()` rewrites the path via `history.replaceState` on tab and
|
||||||
|
department switches; protected tabs keep the URL but open the password modal.
|
||||||
|
|
||||||
|
## Auth / security quirk
|
||||||
|
|
||||||
|
- The `projects` and `people` tabs, week deletion, and week unlock are gated by
|
||||||
|
a password modal comparing `CryptoJS.SHA256(input)` to
|
||||||
|
`PROTECTED_PASSWORD_HASH` (`js/globals.js`, modal in `js/tabs-auth.js`).
|
||||||
|
Client-side only — not real security. CryptoJS and pdfmake (PDF export) are
|
||||||
|
loaded from `cdnjs.cloudflare.com` in `Test.html`, so those features need
|
||||||
|
internet access and won't work offline. `project-plan.html` loads neither.
|
||||||
|
- User-provided status HTML is sanitized on render via a small whitelist
|
||||||
|
(`sanitizeStatusHtml` in `js/board/board.js`) to mitigate XSS.
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
- `docker compose up --build` from the repo root. Compose maps `3003:3003`,
|
||||||
|
bind-mounts `./sessions:/app/sessions`, `restart: unless-stopped`.
|
||||||
|
- Dockerfile: `node:22-alpine` (nötig für node:sqlite), `npm ci --production`,
|
||||||
|
runs as non-root `node`, HEALTHCHECK on `/health`. `.dockerignore` excludes
|
||||||
|
`Docker/*` (except `Docker/Dockerfile`), `node_modules/`, `.git`,
|
||||||
|
`npm-debug.log`.
|
||||||
|
|
||||||
|
## Conventions & verification
|
||||||
|
|
||||||
|
- Git repo; commits are German (e.g. "PDF Export von A4 auf A3 …"). Remote:
|
||||||
|
`http://192.168.3.187:3000/erik/projektuebersicht.git`.
|
||||||
|
- No linter, formatter, typecheck, or test suite. Manual verification only:
|
||||||
|
start server, open `http://localhost:3003`, exercises the relevant tab,
|
||||||
|
confirm SQLite changes (the DB file `sessions/projektuebersicht.db` grows).
|
||||||
+4
-2
@@ -1,5 +1,7 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
FROM node:20-alpine
|
# node:22-alpine, weil node:sqlite (SQLite-Persistenz in server.js) erst ab
|
||||||
|
# Node 22.13 (ohne Flag) stabil verfügbar ist.
|
||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -11,7 +13,7 @@ RUN npm ci --production
|
|||||||
COPY server.js ./
|
COPY server.js ./
|
||||||
COPY public ./public
|
COPY public ./public
|
||||||
|
|
||||||
# Sessions-Ordner fuer die CSV-Daten sicherstellen
|
# Sessions-Ordner fuer die SQLite-Datenbank sicherstellen
|
||||||
RUN mkdir -p /app/sessions && chown -R node:node /app
|
RUN mkdir -p /app/sessions && chown -R node:node /app
|
||||||
|
|
||||||
USER node
|
USER node
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
# Projektübersicht — README
|
# Projektübersicht — README
|
||||||
|
|
||||||
## Übersicht
|
## Übersicht
|
||||||
Dieses Projekt ist eine lokale Single-Page-Webanwendung zur Verwaltung elektrotechnischer Projekte und Sitzungsübersichten. Die Anwendung besteht aus:
|
Dieses Projekt ist eine lokale Weboberfläche zur Verwaltung von TGA-Projekten (Elektro, HLS, Architektur) über wöchentliche Sitzungen. Die Anwendung besteht aus:
|
||||||
|
|
||||||
- einem Frontend in `Test.html`, `styles.css` und den JS-Modulen unter `public/js/`
|
- einem Frontend aus zwei HTML-Einstiegen (`Test.html` für die Hauptansicht mit Tabs, `project-plan.html` für die Projektplanung/GANTT-Ansicht) plus `styles.css` und den JS-Modulen unter `public/js/`
|
||||||
- einem einfachen Node.js-Server in `server.js`
|
- einem einfachen Node.js-/Express-Server in `server.js`
|
||||||
- einem Datenspeicher in CSV-Dateien unter `sessions/`
|
- einem Datenspeicher in einer SQLite-Datenbank (`node:sqlite`, benötigt Node ≥ 22.13) unter `sessions/projektuebersicht.db`
|
||||||
- einem Docker-Setup unter `Docker/`
|
- einem Docker-Setup (`Docker/Dockerfile` + `docker-compose.yml` im Projektstamm)
|
||||||
|
|
||||||
## Datei- und Komponentenübersicht
|
## Datei- und Komponentenübersicht
|
||||||
|
|
||||||
@@ -14,14 +14,19 @@ Dieses Projekt ist eine lokale Single-Page-Webanwendung zur Verwaltung elektrote
|
|||||||
- `server.js`
|
- `server.js`
|
||||||
- Node.js/Express-Server
|
- Node.js/Express-Server
|
||||||
- dient als API für das Frontend
|
- dient als API für das Frontend
|
||||||
- stellt statische Dateien (`Test.html`, die Module unter `js/`, `styles.css`, etc.) bereit
|
- stellt statische Dateien (`Test.html`, `project-plan.html`, die Module unter `js/`, `styles.css`, etc.) bereit; statische Assets werden mit `Cache-Control: no-store` ausgeliefert
|
||||||
- implementiert Endpunkte:
|
- implementiert Endpunkte:
|
||||||
- `GET /` → liefert `Test.html`
|
- `GET /` → liefert `Test.html`
|
||||||
- `GET /load-all` → lädt alle CSV-Dateien aus dem aktiven `sessions/<DEPARTMENT>/`-Ordner inkl. `_people.csv`
|
- `GET /load-all?department=…` → lädt alle Sitzungen plus globale Personenliste einer Abteilung
|
||||||
- `POST /save-csv` → speichert eine CSV-Datei (Dateiname wird validiert)
|
- `POST /save-week {department, week, baseUpdatedAt}` → speichert/aktualisiert eine Sitzung; mit `baseUpdatedAt` (die vom Client gehaltene Revision = `updatedAt` aus `load-all`) erkennt der Server veraltete Speicherungen und antwortet dann mit HTTP 409 `{ok:false, conflict:true, …}`; auch gelöschte Sitzungen werden mit nicht-leerer Basis nicht wiederbelebt (409). Erfolgreiche Saves antworten `{ok:true, updatedAt}` (neue Revision)
|
||||||
- `POST /delete-csv` → löscht eine CSV-Datei
|
- `POST /save-people {department, people}` → ersetzt die globale Personenliste einer Abteilung
|
||||||
- `GET /health` → Gesundheitscheck
|
- `POST /delete-week {department, label}` → löscht eine Sitzung
|
||||||
|
- `GET /health` → Gesundheitscheck (`{ok:true}`)
|
||||||
|
- Deep Links je Abteilung + Tab, z. B. `GET /HLS/Auslastung` oder `GET /ELT/Projektübersicht` → liefert `Test.html`; gültige Tab-Slugs sind `Projektübersicht | Auslastung | Projekte | Projektbeteiligte`, alles andere ergibt 404
|
||||||
|
- `GET /<Dept>/Projektplan(/<ProjName>)?` → liefert `project-plan.html` (GANTT-Ansicht); die Identifikation erfolgt über die Query-Parameter `weekLabel` und `projNr`, der Projektname ist nur ein lesbares Pfad-Segment
|
||||||
|
- validiert Abteilungen gegen `ELT | HLS | ARCH` (unbekannte fallen stillschweigend auf `ELT` zurück)
|
||||||
- legt das `sessions`-Verzeichnis beim Start an, falls es nicht existiert
|
- legt das `sessions`-Verzeichnis beim Start an, falls es nicht existiert
|
||||||
|
- JSON-Body-Limit von 2 MB
|
||||||
- beendet sich mit aussagekräftiger Fehlermeldung, wenn der Port belegt ist
|
- beendet sich mit aussagekräftiger Fehlermeldung, wenn der Port belegt ist
|
||||||
|
|
||||||
- `package.json`
|
- `package.json`
|
||||||
@@ -32,54 +37,54 @@ Dieses Projekt ist eine lokale Single-Page-Webanwendung zur Verwaltung elektrote
|
|||||||
- wird für reproduzierbare Docker-Builds verwendet (`npm ci`)
|
- wird für reproduzierbare Docker-Builds verwendet (`npm ci`)
|
||||||
|
|
||||||
- `public/js/` (Module)
|
- `public/js/` (Module)
|
||||||
- Die Client-Logik ist in kleine klassische Script-Dateien aufgeteilt (alle Funktionen global auf `window`):
|
- Die Client-Logik ist in kleine klassische Script-Dateien aufgeteilt (alle Funktionen global auf `window`, kein Bundler):
|
||||||
`globals.js`, `utils.js`, `ui.js`, `date-picker.js`, `csv.js`, `server.js`, `store.js`, `weeks.js`,
|
- gemeinsame Helfer: `globals.js`, `utils.js`, `ui.js`, `date-picker.js`, `server.js`, `store.js`, `weeks.js`, `gantt.js`, `showmode.js`, `tabs-auth.js`, `pdf.js`, `init.js`
|
||||||
`board.js`, `people.js`, `projects.js`, `load.js`, `gantt.js`, `showmode.js`, `tabs-auth.js`, `pdf.js`,
|
- tab-spezifisch in eigenen Ordnern: `board/board.js` (Projektboard), `people/people.js` (Projektbeteiligte), `projects/projects.js` (Projekte), `load/load.js` (Auslastung)
|
||||||
`init.js` sowie die Plan-Module unter `js/plan/` (`plan-state`, `plan-build-gantt`, `plan-gantt`,
|
- Planungs-Module unter `js/plan/` (`plan-state`, `plan-build-gantt`, `plan-gantt`, `plan-drag`, `plan-modals`, `plan-schedule`)
|
||||||
`plan-drag`, `plan-modals`, `plan-schedule`).
|
|
||||||
- rendert das Projektboard, die Projekt- und Personenverwaltung sowie die Auslastungsansicht
|
- rendert das Projektboard, die Projekt- und Personenverwaltung sowie die Auslastungsansicht
|
||||||
- lädt Daten über den Server und speichert Änderungen zurück als CSV
|
- lädt Daten über den Server und speichert Änderungen als JSON-Objekte in die SQLite-Datenbank zurück
|
||||||
- verwendet `currentDepartment` zur Trennung von Abteilungen (`ELT`, `HLS`, `ARCH`)
|
- verwendet `currentDepartment` zur Trennung von Abteilungen (`ELT`, `HLS`, `ARCH`)
|
||||||
- speichert:
|
- speichert:
|
||||||
- `sessions/<DEPT>/<WEEK>.csv`
|
- alle Sitzungen und die globale Personenliste pro Abteilung in `sessions/projektuebersicht.db`
|
||||||
- `sessions/<DEPT>/_people.csv` (globale Personenliste pro Abteilung)
|
|
||||||
- sortiert Sitzungen nach Kalenderwoche, nicht alphabetisch
|
- sortiert Sitzungen nach Kalenderwoche, nicht alphabetisch
|
||||||
- sperrt historische Sitzungen automatisch (bis auf die chronologisch letzte)
|
- sperrt historische Sitzungen automatisch (bis auf die chronologisch letzte)
|
||||||
- Personen sind global pro Abteilung verwaltet; historische Sitzungen behalten ihre lokalen Personenkopien
|
- Personen sind global pro Abteilung verwaltet; historische Sitzungen behalten ihre lokalen Personenkopien
|
||||||
|
- speichert keine Daten in `localStorage` (dort liegt nur UI-Zustand wie Abteilung, Theme und Header-Collapse)
|
||||||
|
|
||||||
- `Test.html`
|
- `Test.html`
|
||||||
- die Benutzeroberfläche der Anwendung
|
- die Haupt-Benutzeroberfläche der Anwendung (Tabs: Projektboard, Auslastung, Projekte, Projektbeteiligte)
|
||||||
- lädt `styles.css` und die JS-Module aus `js/` in fester Reihenfolge (siehe `AGENTS.md`)
|
- lädt `styles.css` und die JS-Module aus `js/` in fester Reihenfolge (siehe `AGENTS.md`)
|
||||||
- enthält die Abteilungsauswahl, Sitzungsnavigation, Tab-Navigation und das Passwort-Modal
|
- enthält die Abteilungsauswahl, Sitzungsnavigation, Tab-Navigation, das Passwort-Modal sowie PDF-Export
|
||||||
|
- lädt CryptoJS (Passwortprüfung) und pdfmake (PDF-Export) von `cdnjs.cloudflare.com` — diese Funktionen benötigen daher Internetzugang
|
||||||
|
|
||||||
- `project-plan.html`
|
- `project-plan.html`
|
||||||
- die Planungsansicht (GANTT/TODOs/Meilensteine) pro Projekt
|
- die Planungsansicht (GANTT/TODOs/Meilensteine) pro Projekt
|
||||||
- öffnet sich in einem neuen Tab über `openProjectPlan()` (`js/gantt.js`)
|
- öffnet sich in einem neuen Tab über `openProjectPlan()` (`js/gantt.js`)
|
||||||
- lädt nur die gemeinsamen Helfermodule plus `js/plan/*`
|
- lädt nur die gemeinsamen Helfermodule plus `js/plan/*` und window-übergreifend abgestimmte Pfade
|
||||||
|
|
||||||
- `styles.css`
|
- `styles.css`
|
||||||
- Layout und Styling der Oberfläche
|
- Layout und Styling der Oberfläche
|
||||||
|
|
||||||
### Docker-bezogene Dateien
|
### Docker-bezogene Dateien
|
||||||
- `Docker/Dockerfile`
|
- `Docker/Dockerfile`
|
||||||
- Basis-Image: `node:20-alpine`
|
- Basis-Image: `node:22-alpine` (nötig, da `node:sqlite` erst ab Node 22.13 ohne Flag stabil verfügbar ist)
|
||||||
- kopiert `package.json` und `package-lock.json`, installiert Produktionsabhängigkeiten mit `npm ci`
|
- kopiert `package.json` und `package-lock.json`, installiert Produktionsabhängigkeiten mit `npm ci`
|
||||||
- kopiert den Rest des Projekts in `/app`
|
- kopiert `server.js` und `public/` nach `/app`
|
||||||
- legt sicherheitshalber `/app/sessions` an
|
- legt sicherheitshalber `/app/sessions` an
|
||||||
- läuft als nicht-root-User `node`
|
- läuft als nicht-root-User `node`
|
||||||
- enthält einen `HEALTHCHECK` auf `/health`
|
- enthält einen `HEALTHCHECK` auf `/health`
|
||||||
- startet `node server.js`
|
- startet `node server.js`
|
||||||
|
|
||||||
- `docker-compose.yml`
|
- `docker-compose.yml` (im Projektstamm)
|
||||||
- definiert den Service `projektuebersicht`
|
- definiert den Service `projektuebersicht`
|
||||||
- baut das Image aus dem Projektstamm mit `Docker/Dockerfile`
|
- baut das Image mit Build-Kontext `.` und `Docker/Dockerfile`
|
||||||
- mappt Port `3003:3003`
|
- mappt Port `3003:3003`
|
||||||
- bindet das Host-Verzeichnis `./sessions` in den Container nach `/app/sessions`
|
- bindet das Host-Verzeichnis `./sessions` in den Container nach `/app/sessions`
|
||||||
- setzt `restart: unless-stopped`
|
- setzt `restart: unless-stopped`
|
||||||
- definiert einen `healthcheck`
|
- definiert einen `healthcheck`
|
||||||
|
|
||||||
- `.dockerignore`
|
- `.dockerignore`
|
||||||
- schließt unnötige Dateien aus dem Docker-Build aus: `Docker/`, `node_modules/`, `npm-debug.log`, `.git`, `.gitignore`
|
- schließt unnötige Dateien aus dem Docker-Build aus: `node_modules/`, `npm-debug.log`, `.git`, `.gitignore`, `Docker/*` (außer `Docker/Dockerfile`)
|
||||||
|
|
||||||
### Sonstige Dateien
|
### Sonstige Dateien
|
||||||
- `dein_bild.ico`
|
- `dein_bild.ico`
|
||||||
@@ -88,34 +93,25 @@ Dieses Projekt ist eine lokale Single-Page-Webanwendung zur Verwaltung elektrote
|
|||||||
- `AGENTS.md`
|
- `AGENTS.md`
|
||||||
- Projektnotizen / Dokumentation, aber nicht Teil des Anwendungslaufs
|
- Projektnotizen / Dokumentation, aber nicht Teil des Anwendungslaufs
|
||||||
|
|
||||||
## Speicherorte und Datenfluss
|
## Persistenz und Datenfluss
|
||||||
|
|
||||||
### Speicherung
|
### Speicherung
|
||||||
- Alle persistierten Daten werden als CSV-Dateien in `sessions/` gespeichert.
|
- Alle persistierten Daten werden in einer SQLite-Datenbank (`node:sqlite`, `DatabaseSync`, WAL-Modus, `PRAGMA foreign_keys = ON`) unter `sessions/projektuebersicht.db` gespeichert.
|
||||||
- Innerhalb von `sessions/` existieren Abteilungsordner:
|
- Das Schema ist vollständig relational (19 Tabellen): `weeks`, `week_people` (+ `week_people_roles`), `attendance`, `projects`, `project_lph`, `project_assign`, `project_aufwand`, `project_aktuelle_lp`, `project_plan`, `plan_people`, `plan_todos` (+ `plan_todo_assignees`, `plan_todo_deps`), `plan_milestones` (+ `plan_milestone_deps`), `people` (+ `person_roles`, `person_absences`). Projekt-/Personen-IDs bleiben dadurch über Neustarts stabil.
|
||||||
- `sessions/ELT/`
|
- Der Datenbankpfad ist über die Umgebungsvariable `DB_PATH` übersteuerbar (für Tests/Backups), Standard ist `sessions/projektuebersicht.db`.
|
||||||
- `sessions/HLS/`
|
|
||||||
- `sessions/ARCH/`
|
|
||||||
- Jede Abteilung enthält mindestens:
|
|
||||||
- `_people.csv` → globale Personenliste mit Rollen und vollständigem Namen
|
|
||||||
- `KW_XX___YYYY.csv` oder vergleichbare Sitzungsdateien → Sitzungsdaten mit Projekten, Zuordnungen und Anwesenheit
|
|
||||||
|
|
||||||
### Wo wird der Server erstellt?
|
### Server-API
|
||||||
- Der Server wird in `server.js` erstellt.
|
- Der Server wird in `server.js` erstellt und startet auf Port `3003` oder dem Wert von `process.env.PORT`.
|
||||||
- `server.js` startet einen Express-Server auf Port `3003` oder dem Wert von `process.env.PORT`.
|
- Das Frontend ruft den Server über `window.location.origin` an (Fallback `http://localhost:3003` bei `file://`-Öffnung).
|
||||||
- Das Frontend ruft diesen Server über `window.location.origin` an.
|
|
||||||
|
|
||||||
### Wo werden die Dateien gespeichert?
|
### Wo werden die Daten gespeichert?
|
||||||
- Das Frontend schreibt über den Server in das Dateisystem.
|
- Das Frontend schreibt über den Server in die SQLite-Datenbank.
|
||||||
- In `js/store.js` ruft die Anwendung beim Speichern `/save-csv` auf.
|
- In `js/store.js` speichert die Anwendung nur **geänderte** Sitzungen (Dirty-Tracking über `markWeekDirty()`/`markPeopleDirty()`) via `/save-week` (Sitzungen) bzw. `/save-people` (globale Personen); ein In-Flight-Guard verhindert Doppel-Speicherungen. Jede Sitzung führt einen Revisions-Zeitstempel (`updated_at`, übertragen als `updatedAt`); speichert ein Client mit veralteter Revision (`baseUpdatedAt`), antwortet der Server mit HTTP 409 und der Client lädt nur diese eine Sitzung neu (andere ungespeicherte Änderungen bleiben erhalten).
|
||||||
- Der Server legt CSV-Dateien unter `sessions/<DEPARTMENT>/` ab.
|
- Der Server zerlegt die JSON-Objekte in die relationalen Tabellen (`serializeWeek()`/`loadWeeks()`) in einer Transaktion pro Sitzung. Beim Start migriert `migrateLegacySchema()` idempotent ein altes Single-JSON-Dokument-Schema in das relationale Schema; anschließend ergänzt `migrateWeeksUpdatedAt()` die `updated_at`-Spalte der `weeks`-Tabelle (idempotent). `PRAGMA busy_timeout = 5000` verhindert Fehler bei parallelen Schreibzugriffen.
|
||||||
- Beispiel:
|
|
||||||
- `sessions/ELT/KW_32___2026.csv`
|
|
||||||
- `sessions/ELT/_people.csv`
|
|
||||||
|
|
||||||
## Betrieb
|
## Betrieb
|
||||||
|
|
||||||
### Lokal mit Node.js
|
### Lokal mit Node.js (Node ≥ 22.13)
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm start
|
npm start
|
||||||
@@ -131,7 +127,7 @@ PORT=3004 npm start
|
|||||||
Im Projektstamm ausführen:
|
Im Projektstamm ausführen:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f Docker/docker-compose.yml up --build
|
docker compose up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
Danach ist die Anwendung erreichbar unter:
|
Danach ist die Anwendung erreichbar unter:
|
||||||
@@ -141,8 +137,9 @@ http://localhost:3003
|
|||||||
```
|
```
|
||||||
|
|
||||||
## Wichtige Hinweise
|
## Wichtige Hinweise
|
||||||
- Der Server benötigt Lese-/Schreibzugriff auf `sessions/`.
|
- Der Server benötigt Lese-/Schreibzugriff auf `sessions/` (für die SQLite-Datenbank und das Docker-Bind-Mount).
|
||||||
- Die Docker-Compose-Konfiguration mountet `./sessions` in den Container. Achte darauf, dass der relative Pfad im Kontext korrekt ist, wenn du `docker compose` aus einem anderen Verzeichnis startest.
|
- Die Docker-Compose-Konfiguration liegt im Projektstamm und mountet `./sessions` in den Container. Achte darauf, dass der relative Pfad im Kontext korrekt ist, wenn du `docker compose` aus einem anderen Verzeichnis startest.
|
||||||
- `js/store.js` und `js/server.js` arbeiten mit Abteilungsparametern und speichern Daten abteilungsweise in separaten Unterordnern.
|
- `js/store.js` und `js/server.js` arbeiten mit Abteilungsparametern und speichern Daten abteilungsweise getrennt.
|
||||||
- Die Passwortfunktion ist nur ein einfacher Client-Mechanismus im Frontend und kein echter Sicherheitsmechanismus.
|
- Die Passwortfunktion (Tabs „Projekte“/„Projektbeteiligte“, Sitzungslöschung, Entsperren) ist nur ein einfacher Client-Mechanismus im Frontend (SHA256-Vergleich) und kein echter Sicherheitsmechanismus.
|
||||||
- Globale Personen werden in `_people.csv` verwaltet. Wird eine Person global gelöscht, bleibt sie in bereits existierenden Sitzungen sichtbar.
|
- Globale Personen werden pro Abteilung in der Datenbank verwaltet. Wird eine Person global gelöscht, bleibt sie in bereits existierenden Sitzungen sichtbar (Wochen-Personenkopien).
|
||||||
|
- CryptoJS und pdfmake werden von `cdnjs.cloudflare.com` geladen — PDF-Export und Passwortprüfung funktionieren ohne Internet nicht.
|
||||||
+291
@@ -0,0 +1,291 @@
|
|||||||
|
# Agent
|
||||||
|
|
||||||
|
<!--introduced_in=v3.2.0-->
|
||||||
|
<!--type=module-->
|
||||||
|
<!-- source_link=lib/dispatcher/agent.js -->
|
||||||
|
|
||||||
|
> Stability: 2 - Stable
|
||||||
|
|
||||||
|
An `Agent` dispatches requests against multiple different origins, creating and
|
||||||
|
reusing a per-origin [`Pool`][] (or [`Client`][]) on demand. It is the default
|
||||||
|
dispatcher used by [`request`][], [`stream`][], and [`fetch`][] when no explicit
|
||||||
|
dispatcher is supplied.
|
||||||
|
|
||||||
|
Requests are not guaranteed to be dispatched in their order of invocation.
|
||||||
|
|
||||||
|
```mjs
|
||||||
|
import { Agent, setGlobalDispatcher } from 'undici'
|
||||||
|
|
||||||
|
const agent = new Agent({ connections: 10 })
|
||||||
|
setGlobalDispatcher(agent)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Class: `Agent`
|
||||||
|
|
||||||
|
<!-- YAML
|
||||||
|
added: v3.2.0
|
||||||
|
-->
|
||||||
|
|
||||||
|
* Extends: {Dispatcher}
|
||||||
|
|
||||||
|
`Agent` keeps an internal map of origins to dispatchers. The first request to a
|
||||||
|
given origin lazily creates a dispatcher through the configured `factory`, and
|
||||||
|
subsequent requests to the same origin reuse it. Idle dispatchers are closed
|
||||||
|
automatically once they have no open connections and are no longer busy.
|
||||||
|
|
||||||
|
### `new Agent([options])`
|
||||||
|
|
||||||
|
<!-- YAML
|
||||||
|
added: v3.2.0
|
||||||
|
changes:
|
||||||
|
- version: v7.16.0
|
||||||
|
pr-url: https://github.com/nodejs/undici/pull/4365
|
||||||
|
description: Added the `maxOrigins` option to cap the number of origins.
|
||||||
|
-->
|
||||||
|
|
||||||
|
* `options` {AgentOptions} (optional) Extends {PoolOptions}.
|
||||||
|
* `factory` {Function} Builds the dispatcher used for a given origin. When the
|
||||||
|
resolved options contain `connections: 1`, the default factory creates a
|
||||||
|
{Client}; otherwise it creates a {Pool}. **Default:** `(origin, opts) => new Pool(origin, opts)`.
|
||||||
|
* `origin` {URL|string} The origin to create a dispatcher for.
|
||||||
|
* `opts` {Object} The resolved options for the new dispatcher.
|
||||||
|
* Returns: {Dispatcher}
|
||||||
|
* `maxOrigins` {number} Limits the total number of distinct origins that may
|
||||||
|
receive requests at the same time. A {MaxOriginsReachedError} is thrown when
|
||||||
|
a request targets a new origin once the limit is reached. When set to
|
||||||
|
`Infinity`, no limit is enforced. Must be a number greater than `0`.
|
||||||
|
**Default:** `Infinity`.
|
||||||
|
|
||||||
|
`Agent` inherits all {PoolOptions} (and therefore all {ClientOptions}). Each
|
||||||
|
origin gets a separate {Pool}, with `connections` acting as the maximum number
|
||||||
|
of clients that pool may create.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> For an h2-capable HTTPS origin, the per-origin pool waits for the first TLS
|
||||||
|
> connection to finish ALPN negotiation. If the server selects h2, concurrent
|
||||||
|
> requests share that session up to `maxConcurrentStreams`. If it selects
|
||||||
|
> HTTP/1.1, normal connection fan-out resumes up to `connections`. See
|
||||||
|
> {PoolOptions} and {ClientOptions} for inherited options such as `allowH2`
|
||||||
|
> (default `true`) and `maxConcurrentStreams` (default `100`).
|
||||||
|
|
||||||
|
### `agent.closed`
|
||||||
|
|
||||||
|
<!-- YAML
|
||||||
|
added: v3.2.0
|
||||||
|
-->
|
||||||
|
|
||||||
|
* Type: {boolean}
|
||||||
|
|
||||||
|
`true` after [`agent.close()`][] has been called.
|
||||||
|
|
||||||
|
### `agent.destroyed`
|
||||||
|
|
||||||
|
<!-- YAML
|
||||||
|
added: v3.2.0
|
||||||
|
-->
|
||||||
|
|
||||||
|
* Type: {boolean}
|
||||||
|
|
||||||
|
`true` after [`agent.destroy()`][] has been called, or after [`agent.close()`][]
|
||||||
|
has been called and the shutdown has completed.
|
||||||
|
|
||||||
|
### `agent.stats`
|
||||||
|
|
||||||
|
<!-- YAML
|
||||||
|
added: v7.9.0
|
||||||
|
-->
|
||||||
|
|
||||||
|
* Type: {Record<string, ClientStats|PoolStats>}
|
||||||
|
|
||||||
|
Aggregate statistics for the agent, keyed by origin. Each value is the
|
||||||
|
[`ClientStats`][] or [`PoolStats`][] object reported by the dispatcher currently
|
||||||
|
serving that origin. Origins whose dispatcher does not expose `stats` are
|
||||||
|
omitted.
|
||||||
|
|
||||||
|
```mjs
|
||||||
|
import { Agent } from 'undici'
|
||||||
|
|
||||||
|
const agent = new Agent()
|
||||||
|
await agent.request({ origin: 'http://localhost:3000', path: '/', method: 'GET' })
|
||||||
|
console.log(agent.stats)
|
||||||
|
// { 'http://localhost:3000': PoolStats { ... } }
|
||||||
|
```
|
||||||
|
|
||||||
|
### `agent.dispatch(options, handler)`
|
||||||
|
|
||||||
|
<!-- YAML
|
||||||
|
added: v3.2.0
|
||||||
|
-->
|
||||||
|
|
||||||
|
* `options` {AgentDispatchOptions} Extends {DispatchOptions}.
|
||||||
|
* `origin` {string|URL} The origin to dispatch the request against. Required.
|
||||||
|
* `handler` {DispatchHandler}
|
||||||
|
* Returns: {boolean} `false` if the dispatcher is busy and the caller should
|
||||||
|
wait for the [`'drain'`][] event before dispatching again.
|
||||||
|
|
||||||
|
Routes the request to the dispatcher for `options.origin`, creating one through
|
||||||
|
the `factory` if needed. Throws an {InvalidArgumentError} when `options.origin`
|
||||||
|
is not a non-empty string or {URL}, and a {MaxOriginsReachedError} when a new
|
||||||
|
origin would exceed `maxOrigins`. Implements [`Dispatcher.dispatch()`][].
|
||||||
|
|
||||||
|
### `agent.close([callback])`
|
||||||
|
|
||||||
|
<!-- YAML
|
||||||
|
added: v3.2.0
|
||||||
|
-->
|
||||||
|
|
||||||
|
* `callback` {Function} (optional) Invoked once every per-origin dispatcher has
|
||||||
|
closed. When omitted, a {Promise} is returned.
|
||||||
|
* Returns: {Promise|void}
|
||||||
|
|
||||||
|
Gracefully closes the agent and every dispatcher it owns, waiting for in-flight
|
||||||
|
requests to complete. Implements [`Dispatcher.close()`][].
|
||||||
|
|
||||||
|
### `agent.destroy([error[, callback]])`
|
||||||
|
|
||||||
|
<!-- YAML
|
||||||
|
added: v3.2.0
|
||||||
|
-->
|
||||||
|
|
||||||
|
* `error` {Error} (optional) The error to abort pending requests with.
|
||||||
|
* `callback` {Function} (optional) Invoked once every per-origin dispatcher has
|
||||||
|
been destroyed. When omitted, a {Promise} is returned.
|
||||||
|
* Returns: {Promise|void}
|
||||||
|
|
||||||
|
Forcefully destroys the agent and every dispatcher it owns, aborting all pending
|
||||||
|
requests. Implements [`Dispatcher.destroy()`][].
|
||||||
|
|
||||||
|
### `agent.connect(options[, callback])`
|
||||||
|
|
||||||
|
<!-- YAML
|
||||||
|
added: v3.2.0
|
||||||
|
-->
|
||||||
|
|
||||||
|
See [`Dispatcher.connect()`][]. The request is routed to the dispatcher for
|
||||||
|
`options.origin`.
|
||||||
|
|
||||||
|
### `agent.pipeline(options, handler)`
|
||||||
|
|
||||||
|
<!-- YAML
|
||||||
|
added: v3.2.0
|
||||||
|
-->
|
||||||
|
|
||||||
|
See [`Dispatcher.pipeline()`][]. The request is routed to the dispatcher for
|
||||||
|
`options.origin`.
|
||||||
|
|
||||||
|
### `agent.request(options[, callback])`
|
||||||
|
|
||||||
|
<!-- YAML
|
||||||
|
added: v3.2.0
|
||||||
|
-->
|
||||||
|
|
||||||
|
See [`Dispatcher.request()`][]. The request is routed to the dispatcher for
|
||||||
|
`options.origin`.
|
||||||
|
|
||||||
|
```mjs
|
||||||
|
import { Agent } from 'undici'
|
||||||
|
|
||||||
|
const agent = new Agent()
|
||||||
|
const { statusCode, body } = await agent.request({
|
||||||
|
origin: 'http://localhost:3000',
|
||||||
|
path: '/',
|
||||||
|
method: 'GET',
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('response received', statusCode)
|
||||||
|
console.log('data', await body.text())
|
||||||
|
```
|
||||||
|
|
||||||
|
### `agent.stream(options, factory[, callback])`
|
||||||
|
|
||||||
|
<!-- YAML
|
||||||
|
added: v3.2.0
|
||||||
|
-->
|
||||||
|
|
||||||
|
See [`Dispatcher.stream()`][]. The request is routed to the dispatcher for
|
||||||
|
`options.origin`.
|
||||||
|
|
||||||
|
### `agent.upgrade(options[, callback])`
|
||||||
|
|
||||||
|
<!-- YAML
|
||||||
|
added: v3.2.0
|
||||||
|
-->
|
||||||
|
|
||||||
|
See [`Dispatcher.upgrade()`][]. The request is routed to the dispatcher for
|
||||||
|
`options.origin`.
|
||||||
|
|
||||||
|
### Event: `'connect'`
|
||||||
|
|
||||||
|
<!-- YAML
|
||||||
|
added: v6.6.0
|
||||||
|
-->
|
||||||
|
|
||||||
|
* `origin` {URL}
|
||||||
|
* `targets` {Array<Dispatcher>} The dispatchers involved, starting with this
|
||||||
|
`Agent` followed by the per-origin dispatcher chain.
|
||||||
|
|
||||||
|
Emitted when a socket has been created for an origin and is ready to receive
|
||||||
|
requests. Forwarded from the underlying dispatcher's [`'connect'`][] event.
|
||||||
|
|
||||||
|
### Event: `'disconnect'`
|
||||||
|
|
||||||
|
<!-- YAML
|
||||||
|
added: v6.6.0
|
||||||
|
-->
|
||||||
|
|
||||||
|
* `origin` {URL}
|
||||||
|
* `targets` {Array<Dispatcher>} The dispatchers involved, starting with this
|
||||||
|
`Agent`.
|
||||||
|
* `error` {Error}
|
||||||
|
|
||||||
|
Emitted when a socket for an origin has disconnected. Forwarded from the
|
||||||
|
underlying dispatcher's [`'disconnect'`][] event.
|
||||||
|
|
||||||
|
### Event: `'connectionError'`
|
||||||
|
|
||||||
|
<!-- YAML
|
||||||
|
added: v6.6.0
|
||||||
|
-->
|
||||||
|
|
||||||
|
* `origin` {URL}
|
||||||
|
* `targets` {Array<Dispatcher>} The dispatchers involved, starting with this
|
||||||
|
`Agent`.
|
||||||
|
* `error` {Error}
|
||||||
|
|
||||||
|
Emitted when a connection attempt for an origin fails. Forwarded from the
|
||||||
|
underlying dispatcher's [`'connectionError'`][] event.
|
||||||
|
|
||||||
|
### Event: `'drain'`
|
||||||
|
|
||||||
|
<!-- YAML
|
||||||
|
added: v6.6.0
|
||||||
|
-->
|
||||||
|
|
||||||
|
* `origin` {URL}
|
||||||
|
* `targets` {Array<Dispatcher>} The dispatchers involved, starting with this
|
||||||
|
`Agent`.
|
||||||
|
|
||||||
|
Emitted when an origin's dispatcher is no longer busy and can accept more
|
||||||
|
requests. Forwarded from the underlying dispatcher's [`'drain'`][] event.
|
||||||
|
|
||||||
|
[`'connect'`]: Dispatcher.md#event-connect
|
||||||
|
[`'connectionError'`]: Dispatcher.md#event-connectionerror
|
||||||
|
[`'disconnect'`]: Dispatcher.md#event-disconnect
|
||||||
|
[`'drain'`]: Dispatcher.md#event-drain
|
||||||
|
[`Client`]: Client.md#class-client
|
||||||
|
[`ClientStats`]: ClientStats.md
|
||||||
|
[`Dispatcher.close()`]: Dispatcher.md#dispatcherclosecallback-promise
|
||||||
|
[`Dispatcher.connect()`]: Dispatcher.md#dispatcherconnectoptions-callback
|
||||||
|
[`Dispatcher.destroy()`]: Dispatcher.md#dispatcherdestroyerror-callback-promise
|
||||||
|
[`Dispatcher.dispatch()`]: Dispatcher.md#dispatcherdispatchoptions-handler
|
||||||
|
[`Dispatcher.pipeline()`]: Dispatcher.md#dispatcherpipelineoptions-handler
|
||||||
|
[`Dispatcher.request()`]: Dispatcher.md#dispatcherrequestoptions-callback
|
||||||
|
[`Dispatcher.stream()`]: Dispatcher.md#dispatcherstreamoptions-factory-callback
|
||||||
|
[`Dispatcher.upgrade()`]: Dispatcher.md#dispatcherupgradeoptions-callback
|
||||||
|
[`Pool`]: Pool.md#class-pool
|
||||||
|
[`PoolStats`]: PoolStats.md
|
||||||
|
[`agent.close()`]: #agentclosecallback
|
||||||
|
[`agent.destroy()`]: #agentdestroyerror-callback
|
||||||
|
[`fetch`]: Fetch.md
|
||||||
|
[`request`]: Dispatcher.md#dispatcherrequestoptions-callback
|
||||||
|
[`stream`]: Dispatcher.md#dispatcherstreamoptions-factory-callback
|
||||||
Vendored
BIN
Binary file not shown.
+14
-3
@@ -5,7 +5,7 @@
|
|||||||
und verwaltet wöchentliche Sitzungen (Sitzungswahl, -anlage, -duplikat und -löschung im
|
und verwaltet wöchentliche Sitzungen (Sitzungswahl, -anlage, -duplikat und -löschung im
|
||||||
seitlichen Menü) je Abteilung (ELT, HLS, Architektur). Sie lädt das gemeinsame Stylesheet
|
seitlichen Menü) je Abteilung (ELT, HLS, Architektur). Sie lädt das gemeinsame Stylesheet
|
||||||
styles.css sowie der Reihe nach die Module js/globals.js, js/utils.js, js/ui.js,
|
styles.css sowie der Reihe nach die Module js/globals.js, js/utils.js, js/ui.js,
|
||||||
js/date-picker.js, js/csv.js, js/server.js, js/store.js, js/weeks.js, danach die
|
js/date-picker.js, js/server.js, js/store.js, js/weeks.js, danach die
|
||||||
tab-spezifischen Module js/board/board.js, js/people/people.js,
|
tab-spezifischen Module js/board/board.js, js/people/people.js,
|
||||||
js/projects/projects.js, js/load/load.js sowie js/gantt.js, js/showmode.js,
|
js/projects/projects.js, js/load/load.js sowie js/gantt.js, js/showmode.js,
|
||||||
js/tabs-auth.js, js/pdf.js und js/init.js; crypto-js und pdfmake kommen von einem
|
js/tabs-auth.js, js/pdf.js und js/init.js; crypto-js und pdfmake kommen von einem
|
||||||
@@ -77,6 +77,15 @@
|
|||||||
<button class="secondary" onclick="exportPDF()">PDF Export</button>
|
<button class="secondary" onclick="exportPDF()">PDF Export</button>
|
||||||
<button class="secondary" onclick="startShowMode()">Show Mode</button>
|
<button class="secondary" onclick="startShowMode()">Show Mode</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<hr class="menu-divider">
|
||||||
|
|
||||||
|
<div class="menu-section">
|
||||||
|
<div class="menu-label">Backup</div>
|
||||||
|
<button class="secondary" onclick="exportCSV()">CSV Export</button>
|
||||||
|
<button class="secondary" onclick="downloadBackup()">DB Backup</button>
|
||||||
|
<button class="secondary" onclick="importDb()">DB Import</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -108,7 +117,7 @@
|
|||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table id="boardTable"></table>
|
<table id="boardTable"></table>
|
||||||
</div>
|
</div>
|
||||||
<div id="boardLegend" style="margin-top:16px; padding:12px; background:#f7fafc; border-radius:6px; font-size:12px;"></div>
|
<div id="boardLegend"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- LOAD -->
|
<!-- LOAD -->
|
||||||
@@ -227,7 +236,6 @@
|
|||||||
<script src="/js/utils.js?v=5"></script>
|
<script src="/js/utils.js?v=5"></script>
|
||||||
<script src="/js/ui.js?v=5"></script>
|
<script src="/js/ui.js?v=5"></script>
|
||||||
<script src="/js/date-picker.js?v=5"></script>
|
<script src="/js/date-picker.js?v=5"></script>
|
||||||
<script src="/js/csv.js?v=5"></script>
|
|
||||||
<script src="/js/server.js?v=5"></script>
|
<script src="/js/server.js?v=5"></script>
|
||||||
<script src="/js/store.js?v=5"></script>
|
<script src="/js/store.js?v=5"></script>
|
||||||
<script src="/js/weeks.js?v=5"></script>
|
<script src="/js/weeks.js?v=5"></script>
|
||||||
@@ -239,6 +247,9 @@
|
|||||||
<script src="/js/showmode.js?v=5"></script>
|
<script src="/js/showmode.js?v=5"></script>
|
||||||
<script src="/js/tabs-auth.js?v=5"></script>
|
<script src="/js/tabs-auth.js?v=5"></script>
|
||||||
<script src="/js/pdf.js?v=5"></script>
|
<script src="/js/pdf.js?v=5"></script>
|
||||||
|
<script src="/js/csv.js?v=1"></script>
|
||||||
|
<script src="/js/backup.js?v=1"></script>
|
||||||
|
<script src="/js/import.js?v=1"></script>
|
||||||
<script src="/js/init.js?v=5"></script>
|
<script src="/js/init.js?v=5"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,39 @@
|
|||||||
|
// -----------------------------
|
||||||
|
// Projektübersicht — Datenbank-Backup (Download)
|
||||||
|
// -----------------------------
|
||||||
|
// Stellt downloadBackup() bereit: lädt einen konsistenten Snapshot der
|
||||||
|
// SQLite-Datenbank über GET /backup als .db-Datei herunter (VACUUM INTO,
|
||||||
|
// WAL-sicher). Hängt im Seitenmenü von Test.html am Button "DB Backup" und
|
||||||
|
// nutzt baseUrl() aus js/server.js. Ergänzt das automatische Backup, das der
|
||||||
|
// Server beim Start in sessions/backups/ ablegt.
|
||||||
|
|
||||||
|
// downloadBackup: fordert den DB-Snapshot an und startet den Download.
|
||||||
|
// Kein Parameter, keine Rückgabe; Fehler werden über ein Alert gemeldet.
|
||||||
|
async function downloadBackup(){
|
||||||
|
try {
|
||||||
|
const res = await fetch(baseUrl() + '/backup');
|
||||||
|
if(!res.ok){
|
||||||
|
let msg = 'Server HTTP ' + res.status;
|
||||||
|
try {
|
||||||
|
const json = await res.json();
|
||||||
|
if(json && json.error) msg = json.error;
|
||||||
|
} catch(e){ /* Antwort war kein JSON */ }
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
const blob = await res.blob();
|
||||||
|
const filename = 'projektuebersicht_backup_'
|
||||||
|
+ new Date().toISOString().slice(0, 10) + '.db';
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
} catch(err){
|
||||||
|
console.error('DB-Backup fehlgeschlagen:', err);
|
||||||
|
alert('DB-Backup fehlgeschlagen: ' + (err.message || err));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,33 +52,8 @@ function formatStatus(command, value){
|
|||||||
document.execCommand(command, false, value || null);
|
document.execCommand(command, false, value || null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// peopleByRole: gruppiert Personen nach Rolle zurück in ein Objekt.
|
// peopleByRole liegt jetzt im gemeinsamen Modul js/utils.js (benötigt auch
|
||||||
// Parameter `target`: Woche(n) mit `people`-Array (oder undefined → aktuelle
|
// die Projektplan-Seite, die board.js nicht lädt).
|
||||||
// Woche). Rückgabe: Objekt `{ Rolle: [{person, role}] }` mit Deduplizierung.
|
|
||||||
function peopleByRole(target){
|
|
||||||
const m = {}; ROLES.forEach(r=>m[r]=[]);
|
|
||||||
const people = [];
|
|
||||||
if(Array.isArray(target)){
|
|
||||||
target.forEach(w => { if(w && Array.isArray(w.people)) people.push(...w.people); });
|
|
||||||
} else if(target && Array.isArray(target.people)){
|
|
||||||
people.push(...target.people);
|
|
||||||
} else if(Array.isArray(currentWeek()?.people)){
|
|
||||||
people.push(...currentWeek().people);
|
|
||||||
}
|
|
||||||
const seen = new Set();
|
|
||||||
people.forEach(p=>{
|
|
||||||
const personRoles = Array.isArray(p.role) ? p.role : [p.role];
|
|
||||||
personRoles.forEach(role=>{
|
|
||||||
if(!m[role]) m[role]=[];
|
|
||||||
const key = `${p.id}|${role}`;
|
|
||||||
if(!seen.has(key)){
|
|
||||||
seen.add(key);
|
|
||||||
m[role].push({person:p, role});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return m;
|
|
||||||
}
|
|
||||||
|
|
||||||
// roleClass: erzeugt CSS-Klassen für eine Rolle bzw. ein Rollen-Array
|
// roleClass: erzeugt CSS-Klassen für eine Rolle bzw. ein Rollen-Array
|
||||||
// (Rollenname kleingeschrieben, Leerzeichen → Bindestrich). Parameter: `role`
|
// (Rollenname kleingeschrieben, Leerzeichen → Bindestrich). Parameter: `role`
|
||||||
@@ -110,7 +85,7 @@ function renderBoard(){
|
|||||||
let h2 = `<tr>`;
|
let h2 = `<tr>`;
|
||||||
ROLES.forEach(r=>{
|
ROLES.forEach(r=>{
|
||||||
if(grouped[r].length===0) h2 += `<th class="rot ${roleClass(r)}">–</th>`;
|
if(grouped[r].length===0) h2 += `<th class="rot ${roleClass(r)}">–</th>`;
|
||||||
grouped[r].forEach(entry=> h2 += `<th class="rot ${roleClass(entry.role)}">${entry.person.name}</th>`);
|
grouped[r].forEach(entry=> h2 += `<th class="rot ${roleClass(entry.role)}">${esc(entry.person.name)}</th>`);
|
||||||
});
|
});
|
||||||
LPHS.forEach(l=> h2 += `<th class="rot">${l}</th>`);
|
LPHS.forEach(l=> h2 += `<th class="rot">${l}</th>`);
|
||||||
h2 += `</tr>`;
|
h2 += `</tr>`;
|
||||||
@@ -171,7 +146,7 @@ function renderBoard(){
|
|||||||
const v = (p.lph[l]!==undefined && p.lph[l]!="") ? p.lph[l] : "";
|
const v = (p.lph[l]!==undefined && p.lph[l]!="") ? p.lph[l] : "";
|
||||||
const aktiv = l === aktLp;
|
const aktiv = l === aktLp;
|
||||||
const inputAttr = locked ? 'readonly tabindex="-1"' : `onchange="editLph('${p.id}','${l}',this.value)"`;
|
const inputAttr = locked ? 'readonly tabindex="-1"' : `onchange="editLph('${p.id}','${l}',this.value)"`;
|
||||||
body += `<td class="lph-cell ${locked ? 'locked-cell' : ''}${aktiv ? ' lp-aktiv' : ''}"${aktiv ? ' title="Aktive Leistungsphase"' : ''}><input type="text" value="${v}" ${inputAttr} placeholder="-">`;
|
body += `<td class="lph-cell ${locked ? 'locked-cell' : ''}${aktiv ? ' lp-aktiv' : ''}"${aktiv ? ' title="Aktive Leistungsphase"' : ''}><input type="text" value="${esc(v)}" ${inputAttr} placeholder="-">`;
|
||||||
if(v!=""){ const pct = Math.min(100, parseFloat(v)||0);
|
if(v!=""){ const pct = Math.min(100, parseFloat(v)||0);
|
||||||
body += `<div class="bar"><div style="width:${pct}%;background:${getProgressColor(pct)}"></div></div>`; }
|
body += `<div class="bar"><div style="width:${pct}%;background:${getProgressColor(pct)}"></div></div>`; }
|
||||||
body += `</td>`;
|
body += `</td>`;
|
||||||
|
|||||||
+33
-348
@@ -1,357 +1,42 @@
|
|||||||
// -----------------------------
|
// -----------------------------
|
||||||
// Projektübersicht — CSV-Serialisierung & Parsing
|
// Projektübersicht — CSV/ZIP-Export
|
||||||
// Hinweis: weekToCsv() und parseSessionCsv() sind invers zueinander.
|
|
||||||
// Das Spaltenlayout darf nicht geändert werden, ohne beide Funktionen anzupassen.
|
|
||||||
// -----------------------------
|
// -----------------------------
|
||||||
// Diese Datei enthält das gesamte CSV-Handling (Serialisieren und Parsen) und
|
// Stellt exportCSV() bereit: lädt den ZIP mit den übersichtlichen CSV-Dateien
|
||||||
// wird nach js/date-picker.js von beiden HTML-Einstiegspunkten geladen. Sie
|
// der aktuellen Abteilung über GET /export-csv und stößt den Download an.
|
||||||
// stellt die globalen Funktionen weekToCsv/parseSessionCsv, das Personen-CSV
|
// Hängt im Seitenmenü von Test.html am Button "CSV Export" und nutzt baseUrl()
|
||||||
// (globalPeopleToCsv/parseGlobalPeopleCsv) sowie Helfer wie weekSortKey und
|
// aus js/server.js sowie currentDepartment aus js/globals.js. Der ZIP enthält
|
||||||
// decodeWeekLabel bereit. Hauptnutzer sind js/store.js (Laden/Speichern aller
|
// pro logischer Entität eine CSV (Wochen, Personen, Projekte, …), erzeugt vom
|
||||||
// Sitzungen), js/weeks.js (Wochen-Klonen) und js/plan/plan-state.js (savePlan),
|
// Server (siehe server.js im Projekt-Root).
|
||||||
// die die CSV-Zeichenketten über js/server.js austauschen.
|
|
||||||
|
|
||||||
// filenameForWeek: leitet aus dem Wochenlabel einen Dateinamen ab
|
// exportCSV: fordert den CSV-Export der aktuellen Abteilung an und lädt ihn
|
||||||
// (alle Nicht-Wort-Zeichen werden durch _ ersetzt) und hängt .csv an.
|
// als ZIP-Datei herunter. Kein Parameter, keine Rückgabe; Fehler werden über
|
||||||
// Parameter: label (String). Rückgabe: String wie 'KW_30___2026.csv'.
|
// ein Alert gemeldet.
|
||||||
function filenameForWeek(label){
|
async function exportCSV(){
|
||||||
return (label.replace(/[^\w]/g,'_'))+'.csv';
|
|
||||||
}
|
|
||||||
|
|
||||||
// csvEscape: maskiert ein CSV-Feld, falls es Sonderzeichen (;, ", Zeilenumbruch) enthält
|
|
||||||
// Parameter: v (beliebiger Wert). Rückgabe: String, bei Bedarf in Anführungszeichen.
|
|
||||||
function csvEscape(v){
|
|
||||||
const s = (v === null || v === undefined ? '' : String(v));
|
|
||||||
if(/[;"\n\r]/.test(s)) return '"' + s.replace(/"/g,'""') + '"';
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
// globalPeopleToCsv: serialisiert die globalen Personen (aller Abteilungen) als CSV-Text
|
|
||||||
// Parameter: people (Array von Person-Objekten). Rückgabe: UTF-8-BOM-CSV-String.
|
|
||||||
function globalPeopleToCsv(people){
|
|
||||||
const rows = [['NAME', 'Rollen', 'Voller Name', 'E-Mail', 'Abwesenheiten']];
|
|
||||||
people.slice().sort((a,b) => a.name.localeCompare(b.name)).forEach(p => {
|
|
||||||
const absences = Array.isArray(p.absences) ? JSON.stringify(p.absences) : '';
|
|
||||||
rows.push([p.name, (p.role || []).join(','), p.fullName || '', p.email || '', absences]);
|
|
||||||
});
|
|
||||||
return '\ufeff' + rows.map(r => r.map(csvEscape).join(';')).join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseGlobalPeopleCsv: parst den _people.csv-Inhalt zurück in Person-Objekte
|
|
||||||
// Parameter: text (String). Rückgabe: Array von Person-Objekten (mit stabiler ID).
|
|
||||||
function parseGlobalPeopleCsv(text){
|
|
||||||
if(!text) return [];
|
|
||||||
const rows = parseCsvText(text);
|
|
||||||
if(!rows.length) return [];
|
|
||||||
const people = [];
|
|
||||||
rows.slice(1).forEach(row => {
|
|
||||||
const name = (row[0] || '').trim();
|
|
||||||
if(!name) return;
|
|
||||||
const roles = String(row[1] || 'Planung').split(',').map(x => x.trim()).filter(Boolean);
|
|
||||||
const fullName = (row[2] || '').trim() || name;
|
|
||||||
const email = (row[3] || '').trim();
|
|
||||||
let absences = [];
|
|
||||||
if(row[4]){
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(row[4]);
|
const dept = currentDepartment || 'ELT';
|
||||||
if(Array.isArray(parsed)) absences = parsed;
|
const res = await fetch(baseUrl() + '/export-csv?department=' + encodeURIComponent(dept));
|
||||||
} catch(e) {
|
if(!res.ok){
|
||||||
// Ignoriere fehlerhafte JSON-Daten
|
let msg = 'Server HTTP ' + res.status;
|
||||||
}
|
|
||||||
}
|
|
||||||
people.push({ id: personIdForName(name), name, fullName, email, role: roles, absences });
|
|
||||||
});
|
|
||||||
return people;
|
|
||||||
}
|
|
||||||
|
|
||||||
// weekToCsv: serialisiert eine komplette Sitzung (Woche) in das feste CSV-Spaltenlayout
|
|
||||||
// Parameter: w (Wochen-Objekt mit label, date, locked, people, attendance, projects).
|
|
||||||
// Rückgabe: UTF-8-BOM-CSV-String. Invers zu parseSessionCsv().
|
|
||||||
function weekToCsv(w){
|
|
||||||
// allPeople enthält für das Board jede (Person, Rolle)-Kombination einmal.
|
|
||||||
const grouped = peopleByRole(w);
|
|
||||||
const allPeople = [];
|
|
||||||
ROLES.forEach(r => grouped[r].forEach(entry => allPeople.push(entry)));
|
|
||||||
const rows = [];
|
|
||||||
// Metadaten-Zeile: Label, Datum und Sperrstatus der Sitzung
|
|
||||||
rows.push(['WOCHE', w.label || '', w.date || '', w.locked ? '1' : '']);
|
|
||||||
// PERSONEN-Zeile: Pro Spalte Name|Rolle|Voller Name, damit beim Laden
|
|
||||||
// die Zuordnung pro Rolle eindeutig bleibt und der volle Name erhalten ist.
|
|
||||||
// Der volle Name wird immer als dritter Teil gespeichert (auch leer),
|
|
||||||
// damit er beim Bearbeiten nicht versehentlich verloren geht.
|
|
||||||
const peopleMeta = allPeople.map(e => `${e.person.name}|${e.role}|${e.person.fullName || ''}`);
|
|
||||||
rows.push(['PERSONEN', '', '', ...peopleMeta, ...LPHS.map(() => ''), '','','','','']);
|
|
||||||
rows.push(['Pr.-Nr.','Bezeichnung','Status', ...allPeople.map(e => e.person.name), ...LPHS, 'Gruppe','Index','Plan','Aufwand','Aktive Phasen']);
|
|
||||||
rows.push(['ANWESENHEIT','', ...allPeople.map(e => w.attendance && w.attendance[e.person.id] ? 'X' : ''), ...LPHS.map(() => ''),'','','','','']);
|
|
||||||
w.projects.forEach(p => {
|
|
||||||
const arr = [p.nr || '', p.name || '', p.status || ''];
|
|
||||||
allPeople.forEach(entry => {
|
|
||||||
const s = p.assign[assignKey(entry.person.id, entry.role)];
|
|
||||||
arr.push(s === 1 ? 'X' : s === 2 ? '(X)' : '');
|
|
||||||
});
|
|
||||||
LPHS.forEach(l => {
|
|
||||||
const v = p.lph[l];
|
|
||||||
arr.push(v !== undefined && v !== '' ? String(v).replace(/%/g,'') + '%' : '');
|
|
||||||
});
|
|
||||||
arr.push(p.group || '');
|
|
||||||
arr.push(p.order !== undefined ? String(p.order) : '');
|
|
||||||
arr.push(p.plan ? JSON.stringify(p.plan) : '');
|
|
||||||
arr.push(p.aufwandProLP ? JSON.stringify(p.aufwandProLP) : '');
|
|
||||||
arr.push(p.aktuelleLP ? JSON.stringify(p.aktuelleLP) : '');
|
|
||||||
rows.push(arr);
|
|
||||||
});
|
|
||||||
return '\ufeff' + rows.map(r => r.map(csvEscape).join(';')).join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// parseCsvText: entfernt ein (mehrfach kodiertes) BOM und zerlegt den Text in Zeilen-Arrays
|
|
||||||
// Parameter: text (String). Rückgabe: Array aus Zeilen, jede ein Feld-Array.
|
|
||||||
function parseCsvText(text){
|
|
||||||
// Manche ältere CSV-Dateien enthalten ein UTF-8-BOM gefolgt von einem
|
|
||||||
// literalen "" (dreifach kodiert). Beides am Anfang entfernen.
|
|
||||||
return text.replace(/^[\ufeff\u00EF\u00BB\u00BF]+/,'').split(/\r?\n/).filter(line => line.trim() !== '').map(parseCsvLine);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toleranter CSV-Zeilenparser. Versteht sowohl sauberes CSV als auch
|
|
||||||
// das ältere Format, bei dem Felder nur ein öffnendes Anführungszeichen
|
|
||||||
// hatten und das Semikolon innerhalb eines quoted Feldes gleichzeitig
|
|
||||||
// als Trennzeichen diente.
|
|
||||||
// parseCsvLine: zerlegt eine einzelne CSV-Zeile in ein Feld-Array
|
|
||||||
// Parameter: line (String). Rückgabe: Array der Felder.
|
|
||||||
function parseCsvLine(line){
|
|
||||||
const result = [];
|
|
||||||
let current = '';
|
|
||||||
let inQuotes = false;
|
|
||||||
let hasUnclosedQuotes = false;
|
|
||||||
let i = 0;
|
|
||||||
while(i < line.length){
|
|
||||||
const c = line[i];
|
|
||||||
const n = line[i+1];
|
|
||||||
if(inQuotes){
|
|
||||||
if(c === '"' && n === '"'){
|
|
||||||
current += '"'; i += 2;
|
|
||||||
} else if(c === ';' && n === '"'){
|
|
||||||
// Altes Trennzeichen \";\\" innerhalb eines quoted Feldes
|
|
||||||
result.push(current);
|
|
||||||
current = '';
|
|
||||||
inQuotes = false;
|
|
||||||
i += 1;
|
|
||||||
} else if(c === '"' && (n === ';' || n === undefined)){
|
|
||||||
result.push(current);
|
|
||||||
current = '';
|
|
||||||
inQuotes = false;
|
|
||||||
i += (n === ';' ? 2 : 1);
|
|
||||||
} else {
|
|
||||||
current += c; i++;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if(c === ';'){
|
|
||||||
result.push(current);
|
|
||||||
current = ''; i++;
|
|
||||||
} else if(c === '"'){
|
|
||||||
inQuotes = true; i++;
|
|
||||||
} else {
|
|
||||||
current += c; i++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if(inQuotes) hasUnclosedQuotes = true;
|
|
||||||
result.push(current);
|
|
||||||
if(hasUnclosedQuotes){
|
|
||||||
console.warn('CSV-Zeile enthält nicht geschlossene Anführungszeichen:', line.slice(0,80));
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// decodeWeekLabel: rekonstruiert das Wochenlabel aus einem Dateinamen
|
|
||||||
// (ersetzt _ durch Leerzeichen und ergänzt das Layout "KW XX / YYYY").
|
|
||||||
// Parameter: filename (String). Rückgabe: Label-String.
|
|
||||||
function decodeWeekLabel(filename){
|
|
||||||
const base = filename.replace(/\.csv$/i,'');
|
|
||||||
let label = base.replace(/_+/g,' ').trim();
|
|
||||||
if(/^KW \d+ \d+$/.test(label)){
|
|
||||||
label = label.replace(/^KW (\d+) (\d+)$/, 'KW $1 / $2');
|
|
||||||
}
|
|
||||||
return label || base;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Erzeugt einen Sortierschlüssel aus einem Wochenlabel, sodass z.B.
|
|
||||||
// "KW 9 / 2026" vor "KW 10 / 2026" kommt.
|
|
||||||
// weekSortKey: liefert einen nach Jahr und Kalenderwoche sortierbaren Schlüssel
|
|
||||||
// Parameter: label (String). Rückgabe: String (Jahr-KW) oder kleingeschriebenes Label.
|
|
||||||
function weekSortKey(label){
|
|
||||||
const m = String(label || '').match(/KW\s*(\d+)[^\d]+(\d{4})/i);
|
|
||||||
if(m){
|
|
||||||
const kw = m[1].padStart(2,'0');
|
|
||||||
return `${m[2]}-${kw}`;
|
|
||||||
}
|
|
||||||
return String(label || '').toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// parseSessionCsv: parst die CSV-Zeilen einer Sitzung zurück in ein Wochen-Objekt
|
|
||||||
// Parameter: filename (String), rows (Array von Feld-Arrays aus parseCsvText).
|
|
||||||
// Rückgabe: Wochen-Objekt {id, label, date, people, projects, attendance, locked}
|
|
||||||
// oder null bei leerer Eingabe. Invers zu weekToCsv().
|
|
||||||
function parseSessionCsv(filename, rows){
|
|
||||||
if(!rows || rows.length === 0) return null;
|
|
||||||
let label, date, headerRowIdx = 0, attendanceRowIdx = 1, projectStartIdx = 2;
|
|
||||||
let locked = false;
|
|
||||||
let metaPeopleRow = null;
|
|
||||||
if(rows[0][0] === 'WOCHE'){
|
|
||||||
label = rows[0][1] || decodeWeekLabel(filename);
|
|
||||||
date = rows[0][2] || '';
|
|
||||||
locked = (rows[0][3] || '').trim() === '1';
|
|
||||||
if(rows[1] && rows[1][0] === 'PERSONEN'){
|
|
||||||
metaPeopleRow = rows[1];
|
|
||||||
headerRowIdx = 2; attendanceRowIdx = 3;
|
|
||||||
} else {
|
|
||||||
headerRowIdx = 1; attendanceRowIdx = 2;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
label = decodeWeekLabel(filename);
|
|
||||||
date = '';
|
|
||||||
}
|
|
||||||
projectStartIdx = attendanceRowIdx + 1;
|
|
||||||
|
|
||||||
const header = rows[headerRowIdx];
|
|
||||||
if(!header || header.length < 4) return { id: uid(), label, date, people: [], projects: [], attendance: {} };
|
|
||||||
|
|
||||||
const lphStart = header.indexOf('LPH 1');
|
|
||||||
const groupIdx = header.indexOf('Gruppe');
|
|
||||||
const orderIdx = header.indexOf('Index');
|
|
||||||
const planIdx = header.indexOf('Plan');
|
|
||||||
const aufwandIdx = header.indexOf('Aufwand');
|
|
||||||
const aktiveIdx = header.indexOf('Aktive Phasen');
|
|
||||||
const peopleCols = header.slice(3, lphStart === -1 ? header.length : lphStart);
|
|
||||||
|
|
||||||
const people = [];
|
|
||||||
const colMap = [];
|
|
||||||
const personMeta = metaPeopleRow ? metaPeopleRow.slice(3, lphStart === -1 ? metaPeopleRow.length : lphStart) : [];
|
|
||||||
const personByName = {};
|
|
||||||
const nameOccurrences = {};
|
|
||||||
peopleCols.forEach((name, idx) => {
|
|
||||||
const n = (name || '').trim();
|
|
||||||
if(!n){ colMap.push(null); return; }
|
|
||||||
|
|
||||||
let roles = ['Planung'];
|
|
||||||
let fullName = n;
|
|
||||||
if(personMeta[idx]){
|
|
||||||
const parts = String(personMeta[idx]).split('|');
|
|
||||||
const rawRoles = parts[1] || '';
|
|
||||||
const parsedRoles = rawRoles.split(',').map(x => x.trim()).filter(x => ROLES.includes(x));
|
|
||||||
if(parsedRoles.length > 0) roles = parsedRoles;
|
|
||||||
// Voller Name ist alles ab dem zweiten Pipe; damit sind auch Pipe-Zeichen
|
|
||||||
// im Namen möglich. Leere Strings werden bewusst übernommen.
|
|
||||||
if(parts.length >= 3) fullName = parts.slice(2).join('|').trim();
|
|
||||||
} else {
|
|
||||||
const occurrence = nameOccurrences[n] || 0;
|
|
||||||
roles = [ROLES[Math.min(occurrence, ROLES.length - 1)]];
|
|
||||||
nameOccurrences[n] = occurrence + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gleiche Person (gleiches Kürzel) wird im Board pro Rolle mehrfach
|
|
||||||
// dargestellt, soll aber im Datenmodell nur einmal existieren.
|
|
||||||
let person = personByName[n];
|
|
||||||
if(!person){
|
|
||||||
person = { id: personIdForName(n), name: n, fullName: fullName, role: [] };
|
|
||||||
personByName[n] = person;
|
|
||||||
people.push(person);
|
|
||||||
}
|
|
||||||
// Überschreibe den vollen Namen mit dem Wert aus der CSV; die CSV ist
|
|
||||||
// hier die führende Datenquelle für den vollständigen Namen.
|
|
||||||
person.fullName = fullName;
|
|
||||||
// Sammle alle bekannten Rollen der Person; die aktuelle Spaltenrolle
|
|
||||||
// wird für die Zuordnung über colMap verwendet.
|
|
||||||
roles.forEach(r => { if(!person.role.includes(r)) person.role.push(r); });
|
|
||||||
|
|
||||||
colMap.push({ person, role: roles[0] });
|
|
||||||
});
|
|
||||||
|
|
||||||
const projects = [];
|
|
||||||
for(let rIdx = projectStartIdx; rIdx < rows.length; rIdx++){
|
|
||||||
const row = rows[rIdx];
|
|
||||||
if(row.length < 4) continue;
|
|
||||||
const nr = row[0] || '';
|
|
||||||
const name = row[1] || '';
|
|
||||||
const status = row[2] || '';
|
|
||||||
if(!nr.trim() && !name.trim()) continue;
|
|
||||||
|
|
||||||
const assign = {};
|
|
||||||
peopleCols.forEach((_, i) => {
|
|
||||||
const entry = colMap[i];
|
|
||||||
if(!entry) return;
|
|
||||||
const v = (row[3+i] || '').trim();
|
|
||||||
const key = assignKey(entry.person.id, entry.role);
|
|
||||||
if(v === 'X') assign[key] = 1;
|
|
||||||
else if(v === '(X)') assign[key] = 2;
|
|
||||||
});
|
|
||||||
|
|
||||||
const lph = {};
|
|
||||||
if(lphStart !== -1){
|
|
||||||
LPHS.forEach((l, j) => {
|
|
||||||
const raw = row[lphStart + j];
|
|
||||||
if(raw !== undefined && raw !== ''){
|
|
||||||
const num = String(raw).replace(/%/g,'');
|
|
||||||
if(!isNaN(parseFloat(num)) && isFinite(num)) lph[l] = num;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let group = 'Aktive Projekte';
|
|
||||||
if(groupIdx >= 0 && row[groupIdx]) group = row[groupIdx].trim() || group;
|
|
||||||
else if(row.length > 3) group = row[row.length - 1].trim() || group;
|
|
||||||
|
|
||||||
let order = rIdx - projectStartIdx;
|
|
||||||
if(orderIdx >= 0 && row[orderIdx] !== undefined && row[orderIdx] !== ''){
|
|
||||||
const parsed = parseInt(row[orderIdx], 10);
|
|
||||||
if(!isNaN(parsed)) order = parsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
let plan = null;
|
|
||||||
if(planIdx >= 0 && row[planIdx]){
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(row[planIdx]);
|
const json = await res.json();
|
||||||
if(parsed && typeof parsed === 'object') plan = parsed;
|
if(json && json.error) msg = json.error;
|
||||||
} catch(e) {
|
} catch(e){ /* Antwort war kein JSON */ }
|
||||||
console.warn('Plan-Daten für Projekt konnten nicht geparst werden:', nr, name, e);
|
throw new Error(msg);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
const blob = await res.blob();
|
||||||
|
const filename = 'Projektuebersicht_' + dept + '_'
|
||||||
|
+ new Date().toISOString().slice(0, 10) + '.zip';
|
||||||
|
|
||||||
let aufwandProLP;
|
const url = URL.createObjectURL(blob);
|
||||||
if(aufwandIdx >= 0 && row[aufwandIdx]){
|
const a = document.createElement('a');
|
||||||
try {
|
a.href = url;
|
||||||
const parsed = JSON.parse(row[aufwandIdx]);
|
a.download = filename;
|
||||||
if(parsed && typeof parsed === 'object') aufwandProLP = parsed;
|
document.body.appendChild(a);
|
||||||
} catch(e) {
|
a.click();
|
||||||
console.warn('LP-Aufwand für Projekt konnten nicht geparst werden:', nr, name, e);
|
a.remove();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
} catch(err){
|
||||||
|
console.error('CSV-Export fehlgeschlagen:', err);
|
||||||
|
alert('CSV-Export fehlgeschlagen: ' + (err.message || err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let aktuelleLP;
|
|
||||||
if(aktiveIdx >= 0 && row[aktiveIdx]){
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(row[aktiveIdx]);
|
|
||||||
if(Array.isArray(parsed)) aktuelleLP = parsed;
|
|
||||||
else if(typeof parsed === 'string') aktuelleLP = [parsed];
|
|
||||||
} catch(e) {
|
|
||||||
console.warn('Aktive Phasen für Projekt konnten nicht geparst werden:', nr, name, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
projects.push({ id: uid(), nr, name, status, group, lph, assign, order, plan, aufwandProLP, aktuelleLP });
|
|
||||||
}
|
|
||||||
|
|
||||||
const attendance = {};
|
|
||||||
const attRow = rows[attendanceRowIdx];
|
|
||||||
if(attRow && attRow[0] === 'ANWESENHEIT'){
|
|
||||||
peopleCols.forEach((_, i) => {
|
|
||||||
const entry = colMap[i];
|
|
||||||
if(entry && (attRow[2+i] || '').trim() === 'X'){
|
|
||||||
attendance[entry.person.id] = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { id: uid(), label, date, people, projects, attendance, locked };
|
|
||||||
}
|
|
||||||
+2
-2
@@ -335,8 +335,8 @@ function buildGanttHtml(projects, start, end, options = {}){
|
|||||||
function openProjectPlan(pid){
|
function openProjectPlan(pid){
|
||||||
const w = currentWeek(); if(!w) return;
|
const w = currentWeek(); if(!w) return;
|
||||||
const p = w.projects.find(x => x.id === pid); if(!p) return;
|
const p = w.projects.find(x => x.id === pid); if(!p) return;
|
||||||
// IDs sind nicht persistent (werden bei jedem CSV-Laden neu erzeugt).
|
// Projekt-IDs sind in der DB stabil, aber die Plan-Seite ist eine separate
|
||||||
// Daher werden stabile Werte (Label, Projekt-Nr.) in der URL übergeben.
|
// Route. Daher werden stabile Werte (Label, Projekt-Nr.) in der URL übergeben.
|
||||||
// Absoluter Pfad mit Abteilung und Projektname als Route-Segment, damit der
|
// Absoluter Pfad mit Abteilung und Projektname als Route-Segment, damit der
|
||||||
// Aufruf auch von Deep-Link-URLs wie "/HLS/Auslastung" funktioniert.
|
// Aufruf auch von Deep-Link-URLs wie "/HLS/Auslastung" funktioniert.
|
||||||
const url = new URL('/' + currentDepartment + '/' + PLAN_SLUG + '/' + encodeURIComponent(p.name || ''), window.location.origin);
|
const url = new URL('/' + currentDepartment + '/' + PLAN_SLUG + '/' + encodeURIComponent(p.name || ''), window.location.origin);
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// -----------------------------
|
||||||
|
// Projektübersicht — Datenbank-Import (Backup-Wiederherstellung)
|
||||||
|
// -----------------------------
|
||||||
|
// Stellt importDb() bereit: wählt über einen Datei-Dialog eine mit "DB Backup"
|
||||||
|
// erzeugte .db-Datei aus, lädt sie als rohen Binär-Body an POST /import und
|
||||||
|
// lädt nach erfolgreicher Wiederherstellung die Seite neu. Da der Import den
|
||||||
|
// kompletten Datenbestand ersetzt, ist er passwortgeschützt (gleiche Logik wie
|
||||||
|
// Wochen-Löschung über requestedAction/isAuthenticated, siehe js/tabs-auth.js
|
||||||
|
// und js/weeks.js) und wird zusätzlich im Browser per Bestätigungsdialog
|
||||||
|
// abgesichert.
|
||||||
|
|
||||||
|
// importDb: öffnet bei Bedarf das Passwort-Modal (requestedAction 'importDb')
|
||||||
|
// und startet danach den Datei-Dialog für das Backup. Parameter `skipAuth=true`
|
||||||
|
// überspringt die Passwortabfrage (wird von confirmPassword() genutzt).
|
||||||
|
// Keine Rückgabe.
|
||||||
|
function importDb(skipAuth = false){
|
||||||
|
if(!skipAuth && !isAuthenticated){
|
||||||
|
requestedAction = { type: 'importDb' };
|
||||||
|
document.getElementById('passwordModal').style.display = 'flex';
|
||||||
|
document.getElementById('passwordInput').value = '';
|
||||||
|
document.getElementById('passwordInput').focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(!confirm('Datenbank-Backup wirklich wiederherstellen?\n\nAlle aktuellen Daten werden ersetzt.')){
|
||||||
|
requestedAction = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.accept = '.db,application/octet-stream,application/x-sqlite3';
|
||||||
|
input.onchange = () => {
|
||||||
|
const file = input.files && input.files[0];
|
||||||
|
if(!file){
|
||||||
|
requestedAction = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uploadBackup(file);
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
requestedAction = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// uploadBackup: lädt die gewählte .db-Datei als rohen Binär-Body an
|
||||||
|
// POST /import und informiert über das Ergebnis. Bei Erfolg wird die Seite neu
|
||||||
|
// geladen, damit das Frontend den wiederhergestellten Datenbestand zeigt.
|
||||||
|
async function uploadBackup(file){
|
||||||
|
try {
|
||||||
|
const res = await fetch(baseUrl() + '/import', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/octet-stream' },
|
||||||
|
body: file
|
||||||
|
});
|
||||||
|
if(!res.ok){
|
||||||
|
let msg = 'Server HTTP ' + res.status;
|
||||||
|
try {
|
||||||
|
const json = await res.json();
|
||||||
|
if(json && json.error) msg = json.error;
|
||||||
|
} catch(e){ /* Antwort war kein JSON */ }
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
alert('Datenbank-Backup erfolgreich wiederhergestellt.\nDie Seite wird neu geladen.');
|
||||||
|
window.location.reload();
|
||||||
|
} catch(err){
|
||||||
|
console.error('DB-Import fehlgeschlagen:', err);
|
||||||
|
alert('DB-Import fehlgeschlagen: ' + (err.message || err));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
// people → projects → load → gantt → …). Es stellt renderLoad(), das LP-
|
// people → projects → load → gantt → …). Es stellt renderLoad(), das LP-
|
||||||
// Aufwand-Modal und das Neues-Projekt-Modal bereit. Genutzt werden LPHS/ROLES
|
// Aufwand-Modal und das Neues-Projekt-Modal bereit. Genutzt werden LPHS/ROLES
|
||||||
// aus js/globals.js, currentWeek()/save() aus js/store.js, guardWeekLocked()
|
// aus js/globals.js, currentWeek()/save() aus js/store.js, guardWeekLocked()
|
||||||
// aus js/weeks.js sowie peopleByRole()/assignKey() aus js/board/board.js/js/utils.js.
|
// aus js/weeks.js sowie peopleByRole()/assignKey() aus js/utils.js.
|
||||||
// Die Auslastungs-Logik (berechneAuslastung, lpActiveList, faktorFormat,
|
// Die Auslastungs-Logik (berechneAuslastung, lpActiveList, faktorFormat,
|
||||||
// pdfLoadAmpelColor, LOAD_BAR_MAX) wird zusätzlich von js/pdf.js für den
|
// pdfLoadAmpelColor, LOAD_BAR_MAX) wird zusätzlich von js/pdf.js für den
|
||||||
// PDF-Export wiederverwendet.
|
// PDF-Export wiederverwendet.
|
||||||
|
|||||||
+24
-10
@@ -8,7 +8,7 @@
|
|||||||
// vor js/init.js); exportPDF() hängt am Export-Button von Test.html und nutzt
|
// vor js/init.js); exportPDF() hängt am Export-Button von Test.html und nutzt
|
||||||
// die per CDN eingebundene Bibliothek pdfmake (offline nicht verfügbar). Es
|
// die per CDN eingebundene Bibliothek pdfmake (offline nicht verfügbar). Es
|
||||||
// liest die aktuelle Woche über currentWeek() aus js/store.js, Personen über
|
// liest die aktuelle Woche über currentWeek() aus js/store.js, Personen über
|
||||||
// peopleByRole() aus js/board/board.js, Helfer wie assignKey()/sortGroups()/
|
// peopleByRole() aus js/utils.js, Helfer wie assignKey()/sortGroups()/
|
||||||
// formatWeekDate() aus js/utils.js sowie die Auslastungs-Berechnung
|
// formatWeekDate() aus js/utils.js sowie die Auslastungs-Berechnung
|
||||||
// (berechneAuslastung, lpActiveList, faktorFormat, pdfLoadAmpelColor,
|
// (berechneAuslastung, lpActiveList, faktorFormat, pdfLoadAmpelColor,
|
||||||
// LOAD_BAR_MAX) aus js/load/load.js. Das Modul stellt ausschließlich exportPDF()
|
// LOAD_BAR_MAX) aus js/load/load.js. Das Modul stellt ausschließlich exportPDF()
|
||||||
@@ -272,6 +272,12 @@ function pdfProjectRow(p, allPeople, alt){
|
|||||||
];
|
];
|
||||||
|
|
||||||
allPeople.forEach(entry => {
|
allPeople.forEach(entry => {
|
||||||
|
// Platzhalter-Einträge für leere Rollen (person === null) → leere Zelle,
|
||||||
|
// damit Spaltenzahl mit der Kopfzeile übereinstimmt.
|
||||||
|
if(!entry.person){
|
||||||
|
row.push({ text: '', style: 'tdCenter', fillColor: base || '#FFFFFF' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const s = (p.assign || {})[assignKey(entry.person.id, entry.role)] || 0;
|
const s = (p.assign || {})[assignKey(entry.person.id, entry.role)] || 0;
|
||||||
const txt = s === 1 ? 'X' : s === 2 ? '(X)' : '';
|
const txt = s === 1 ? 'X' : s === 2 ? '(X)' : '';
|
||||||
const bg = s === 1 ? PDF_ASSIGN_MAIN
|
const bg = s === 1 ? PDF_ASSIGN_MAIN
|
||||||
@@ -468,17 +474,25 @@ async function exportPDF(){
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const grouped = peopleByRole(w);
|
const grouped = peopleByRole(w);
|
||||||
const allPeople = [];
|
// displayPeople: Spalten der Tabelle inkl. Platzhalter für leere Rollen –
|
||||||
ROLES.forEach(r => (grouped[r] || []).forEach(e => {
|
// pdfHeaderRows erzwingt pro Rolle mindestens eine Spalte (colSpan/“–”),
|
||||||
// role explizit setzen, falls peopleByRole sie nicht mitliefert
|
// deshalb müssen Zeilen, Breiten und colCount dieselbe Spaltenzahl nutzen,
|
||||||
allPeople.push({ person: e.person, role: e.role || r });
|
// sonst wirft pdfmake bei einer leeren Rolle einen Layout-Fehler.
|
||||||
}));
|
const displayPeople = [];
|
||||||
|
ROLES.forEach(r => {
|
||||||
|
const list = grouped[r] || [];
|
||||||
|
if(list.length === 0){
|
||||||
|
displayPeople.push({ person: null, role: r });
|
||||||
|
} else {
|
||||||
|
list.forEach(e => displayPeople.push({ person: e.person, role: e.role || r }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
pdfCheckWidth(allPeople);
|
pdfCheckWidth(displayPeople);
|
||||||
|
|
||||||
const logo = await loadLogoPng();
|
const logo = await loadLogoPng();
|
||||||
const colCount = 3 + allPeople.length + LPHS.length;
|
const colCount = 3 + displayPeople.length + LPHS.length;
|
||||||
const widths = pdfColumnWidths(allPeople);
|
const widths = pdfColumnWidths(displayPeople);
|
||||||
|
|
||||||
// --- Deckblatt ---
|
// --- Deckblatt ---
|
||||||
const cover = [];
|
const cover = [];
|
||||||
@@ -521,7 +535,7 @@ async function exportPDF(){
|
|||||||
const header = pdfHeaderRows(grouped);
|
const header = pdfHeaderRows(grouped);
|
||||||
|
|
||||||
const body = [ pdfGroupTitleRow(groupName, colCount), header[0], header[1] ];
|
const body = [ pdfGroupTitleRow(groupName, colCount), header[0], header[1] ];
|
||||||
projects.forEach((p, i) => body.push(pdfProjectRow(p, allPeople, i % 2 === 0)));
|
projects.forEach((p, i) => body.push(pdfProjectRow(p, displayPeople, i % 2 === 0)));
|
||||||
|
|
||||||
content.push({
|
content.push({
|
||||||
table: { headerRows: 3, widths: widths, body: body, dontBreakRows: true },
|
table: { headerRows: 3, widths: widths, body: body, dontBreakRows: true },
|
||||||
|
|||||||
+37
-19
@@ -45,7 +45,7 @@ function addPerson(){
|
|||||||
document.getElementById('newPersonFullName').value='';
|
document.getElementById('newPersonFullName').value='';
|
||||||
document.getElementById('newPersonEmail').value='';
|
document.getElementById('newPersonEmail').value='';
|
||||||
document.querySelectorAll('input[name="newPersonRoles"]').forEach(el=>el.checked=false);
|
document.querySelectorAll('input[name="newPersonRoles"]').forEach(el=>el.checked=false);
|
||||||
save(); renderPeople(); renderBoard();
|
save({people:true}); renderPeople(); renderBoard();
|
||||||
}
|
}
|
||||||
|
|
||||||
// delPerson: entfernt eine Person aus der globalen Liste. Bereits in
|
// delPerson: entfernt eine Person aus der globalen Liste. Bereits in
|
||||||
@@ -54,12 +54,9 @@ function addPerson(){
|
|||||||
function delPerson(id){
|
function delPerson(id){
|
||||||
if(!confirm("Person global löschen? Sie bleibt in bereits existierenden Sitzungen sichtbar, wird aber in neuen Sitzungen nicht mehr angeboten.")) return;
|
if(!confirm("Person global löschen? Sie bleibt in bereits existierenden Sitzungen sichtbar, wird aber in neuen Sitzungen nicht mehr angeboten.")) return;
|
||||||
|
|
||||||
// Aus globaler Liste entfernen
|
// Erst prüfen, ob die Person in der aktuellen Sitzung noch zugeordnet ist –
|
||||||
if(Array.isArray(db.globalPeople[currentDepartment])){
|
// erst danach die globalen Listen verändern, sonst wäre die globale Löschung
|
||||||
db.globalPeople[currentDepartment] = db.globalPeople[currentDepartment].filter(p=>p.id!==id);
|
// trotz Abbruch bereits durchgeführt (und würde über save() persistiert).
|
||||||
}
|
|
||||||
|
|
||||||
// Aus aktueller Sitzung entfernen (nur wenn nicht in Zuordnungen genutzt)
|
|
||||||
const w = currentWeek();
|
const w = currentWeek();
|
||||||
if(w){
|
if(w){
|
||||||
const inUse = w.projects.some(p => Object.keys(p.assign).some(k => k.startsWith(id + '|')));
|
const inUse = w.projects.some(p => Object.keys(p.assign).some(k => k.startsWith(id + '|')));
|
||||||
@@ -67,10 +64,19 @@ function delPerson(id){
|
|||||||
alert('Die Person ist in der aktuellen Sitzung noch Projekten zugeordnet. Bitte zuerst die Zuordnungen entfernen.');
|
alert('Die Person ist in der aktuellen Sitzung noch Projekten zugeordnet. Bitte zuerst die Zuordnungen entfernen.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aus globaler Liste entfernen
|
||||||
|
if(Array.isArray(db.globalPeople[currentDepartment])){
|
||||||
|
db.globalPeople[currentDepartment] = db.globalPeople[currentDepartment].filter(p=>p.id!==id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aus aktueller Sitzung entfernen
|
||||||
|
if(w){
|
||||||
w.people = (w.people || []).filter(p=>p.id!==id);
|
w.people = (w.people || []).filter(p=>p.id!==id);
|
||||||
}
|
}
|
||||||
|
|
||||||
save(); renderPeople(); renderBoard();
|
save({people:true}); renderPeople(); renderBoard();
|
||||||
}
|
}
|
||||||
|
|
||||||
// editPerson: ändert Name/Vollname/Rolle sowohl global als auch in der
|
// editPerson: ändert Name/Vollname/Rolle sowohl global als auch in der
|
||||||
@@ -126,7 +132,7 @@ function editPerson(id,field,val){
|
|||||||
delete w.attendance[id];
|
delete w.attendance[id];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
save(); renderPeople(); renderBoard();
|
save({people:true}); renderPeople(); renderBoard();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,7 +146,7 @@ function editPerson(id,field,val){
|
|||||||
const localPerson = (w.people || []).find(p => p.id === id);
|
const localPerson = (w.people || []).find(p => p.id === id);
|
||||||
if(localPerson) localPerson[field] = val;
|
if(localPerson) localPerson[field] = val;
|
||||||
}
|
}
|
||||||
save(); renderPeople(); renderBoard();
|
save({people:true}); renderPeople(); renderBoard();
|
||||||
}
|
}
|
||||||
|
|
||||||
// togglePersonRole: fügt eine Rolle (`role`) zur Person `id` hinzu bzw. entfernt
|
// togglePersonRole: fügt eine Rolle (`role`) zur Person `id` hinzu bzw. entfernt
|
||||||
@@ -166,7 +172,7 @@ function togglePersonRole(id, role, checked){
|
|||||||
|
|
||||||
if(globalPerson) updateRoles(globalPerson);
|
if(globalPerson) updateRoles(globalPerson);
|
||||||
if(localPerson) updateRoles(localPerson);
|
if(localPerson) updateRoles(localPerson);
|
||||||
save(); renderPeople(); renderBoard();
|
save({people:true}); renderPeople(); renderBoard();
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderPeople: baut die HTML-Liste der Personen für den Personen-Tab auf
|
// renderPeople: baut die HTML-Liste der Personen für den Personen-Tab auf
|
||||||
@@ -176,12 +182,14 @@ function togglePersonRole(id, role, checked){
|
|||||||
function renderPeople(){
|
function renderPeople(){
|
||||||
const people = db.globalPeople[currentDepartment] || [];
|
const people = db.globalPeople[currentDepartment] || [];
|
||||||
const locked = isWeekLocked(currentWeek());
|
const locked = isWeekLocked(currentWeek());
|
||||||
let html = `<div class="people-sortable">`;
|
let html = `<div class="people-sortable"
|
||||||
|
ondragover="handlePersonDragOver(event)"
|
||||||
|
ondrop="handlePersonDrop(event)">`;
|
||||||
people.forEach(p=>{
|
people.forEach(p=>{
|
||||||
const isOpen = personAbsenceOpen[p.id];
|
const isOpen = personAbsenceOpen[p.id];
|
||||||
html += `<div class="person-card${isOpen ? ' open' : ''}" data-person-id="${p.id}">`;
|
html += `<div class="person-card${isOpen ? ' open' : ''}" data-person-id="${p.id}">`;
|
||||||
html += `<div class="person-header" onclick="togglePersonAbsences('${p.id}')">
|
html += `<div class="person-header" onclick="togglePersonAbsences('${p.id}')">
|
||||||
<span class="drag-handle" style="cursor:grab;color:#999;margin-right:8px" onclick="event.stopPropagation()" ondragstart="handlePersonDragStart(event)" draggable="true">⋮⋮</span>
|
${locked ? '' : `<span class="drag-handle" style="cursor:grab;color:#999;margin-right:8px" onclick="event.stopPropagation()" ondragstart="handlePersonDragStart(event)" ondragend="handlePersonDragEnd(event)" draggable="true">⋮⋮</span>`}
|
||||||
<span class="person-toggle">${isOpen ? '▼' : '▶'}</span>
|
<span class="person-toggle">${isOpen ? '▼' : '▶'}</span>
|
||||||
<input type="text" value="${esc(p.name)}" onclick="event.stopPropagation()" onchange="editPerson('${p.id}','name',this.value)" style="width:80px;" placeholder="Kürzel">
|
<input type="text" value="${esc(p.name)}" onclick="event.stopPropagation()" onchange="editPerson('${p.id}','name',this.value)" style="width:80px;" placeholder="Kürzel">
|
||||||
<input type="text" value="${esc(p.fullName||p.name)}" onclick="event.stopPropagation()" onchange="editPerson('${p.id}','fullName',this.value)" style="width:180px;" placeholder="Voller Name">
|
<input type="text" value="${esc(p.fullName||p.name)}" onclick="event.stopPropagation()" onchange="editPerson('${p.id}','fullName',this.value)" style="width:180px;" placeholder="Voller Name">
|
||||||
@@ -273,7 +281,7 @@ function handlePersonDrop(event){
|
|||||||
if(!draggedPerson || draggedPerson === targetCard) return;
|
if(!draggedPerson || draggedPerson === targetCard) return;
|
||||||
const sortable = document.querySelector('.people-sortable');
|
const sortable = document.querySelector('.people-sortable');
|
||||||
if(!sortable) return;
|
if(!sortable) return;
|
||||||
const afterElement = getDragAfterElement(sortable, event.clientY);
|
const afterElement = getDragAfterElement(sortable, event.clientY, '.person-card:not([style*="opacity"])');
|
||||||
if(afterElement == null){
|
if(afterElement == null){
|
||||||
sortable.appendChild(draggedPerson);
|
sortable.appendChild(draggedPerson);
|
||||||
} else {
|
} else {
|
||||||
@@ -291,8 +299,9 @@ function handlePersonDragEnd(event){
|
|||||||
}
|
}
|
||||||
|
|
||||||
// reorderPeople: übernimmt die im DOM vorgefundene Reihenfolge der
|
// reorderPeople: übernimmt die im DOM vorgefundene Reihenfolge der
|
||||||
// Personen-Karten in die globale Personenliste der Abteilung und speichert sie.
|
// Personen-Karten in die globale Personenliste der Abteilung UND die
|
||||||
// Keine Parameter, keine Rückgabe; abgebrochen bei gesperrter Sitzung.
|
// Personen-Kopie der aktuellen Woche (letztere bestimmt die Spaltenreihenfolge
|
||||||
|
// im Projektboard), dann speichern. Keine Parameter, keine Rückgabe.
|
||||||
function reorderPeople(){
|
function reorderPeople(){
|
||||||
if(guardWeekLocked()) return;
|
if(guardWeekLocked()) return;
|
||||||
const sortable = document.querySelector('.people-sortable');
|
const sortable = document.querySelector('.people-sortable');
|
||||||
@@ -306,7 +315,16 @@ function reorderPeople(){
|
|||||||
});
|
});
|
||||||
if(newPeople.length === people.length){
|
if(newPeople.length === people.length){
|
||||||
db.globalPeople[currentDepartment] = newPeople;
|
db.globalPeople[currentDepartment] = newPeople;
|
||||||
save();
|
// Wochen-Kopie auf dieselbe Reihenfolge bringen, damit das Board sie nutzt
|
||||||
|
const w = currentWeek();
|
||||||
|
if(w && Array.isArray(w.people)){
|
||||||
|
const byId = {};
|
||||||
|
w.people.forEach(p => { byId[p.id] = p; });
|
||||||
|
const newWeekPeople = [];
|
||||||
|
newPeople.forEach(p => { if(byId[p.id]) newWeekPeople.push(byId[p.id]); });
|
||||||
|
if(newWeekPeople.length === w.people.length) w.people = newWeekPeople;
|
||||||
|
}
|
||||||
|
save({people:true});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,7 +348,7 @@ function addAbsence(personId){
|
|||||||
if(!Array.isArray(person.absences)) person.absences = [];
|
if(!Array.isArray(person.absences)) person.absences = [];
|
||||||
person.absences.push({ id: uid(), start, end, type, note });
|
person.absences.push({ id: uid(), start, end, type, note });
|
||||||
person.absences.sort((a,b) => parseDateISO(a.start) - parseDateISO(b.start));
|
person.absences.sort((a,b) => parseDateISO(a.start) - parseDateISO(b.start));
|
||||||
save();
|
save({week:false, people:true});
|
||||||
renderPeople();
|
renderPeople();
|
||||||
if(currentTab === 'gantt') renderGantt();
|
if(currentTab === 'gantt') renderGantt();
|
||||||
}
|
}
|
||||||
@@ -343,7 +361,7 @@ function deleteAbsence(personId, absenceId){
|
|||||||
const person = people.find(p => p.id === personId);
|
const person = people.find(p => p.id === personId);
|
||||||
if(!person || !person.absences) return;
|
if(!person || !person.absences) return;
|
||||||
person.absences = person.absences.filter(a => a.id !== absenceId);
|
person.absences = person.absences.filter(a => a.id !== absenceId);
|
||||||
save();
|
save({week:false, people:true});
|
||||||
renderPeople();
|
renderPeople();
|
||||||
if(currentTab === 'gantt') renderGantt();
|
if(currentTab === 'gantt') renderGantt();
|
||||||
}
|
}
|
||||||
@@ -141,6 +141,7 @@
|
|||||||
const row = buildMilestoneRow(m, mIdx);
|
const row = buildMilestoneRow(m, mIdx);
|
||||||
insertAfter.parentNode.insertBefore(row, insertAfter.nextSibling);
|
insertAfter.parentNode.insertBefore(row, insertAfter.nextSibling);
|
||||||
insertAfter = row;
|
insertAfter = row;
|
||||||
|
insertedMilestoneIds.add(m.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Aufgabenzeile
|
// Aufgabenzeile
|
||||||
|
|||||||
+21
-14
@@ -85,19 +85,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// getGanttRowDragAfterElement: ermittelt unter allen nicht gezogenen
|
// getGanttRowDragAfterElement: ermittelt unter allen nicht gezogenen
|
||||||
// Zeilen jenes Element, das der übergebenen y-Position am nächsten liegt.
|
// Zeilen jenes Element, das der übergebenen y-Position am nächsten liegt
|
||||||
// Rückgabe: Element oder undefined (dann wäre die Zeile am Chart-Ende).
|
// und dessen Mitte unterhalb des Cursors beginnt. Rückgabe: Element oder
|
||||||
|
// null, wenn keine Zeile mehr unter dem Cursor liegt (Curser unterhalb der
|
||||||
|
// letzten Zeile → die gezogene Zeile gehört ans Chart-Ende).
|
||||||
function getGanttRowDragAfterElement(chart, y){
|
function getGanttRowDragAfterElement(chart, y){
|
||||||
const draggableElements = [...chart.querySelectorAll(DRAGGABLE_GANTT_ROW_SELECTOR + ':not(.dragging)')];
|
const draggableElements = [...chart.querySelectorAll(DRAGGABLE_GANTT_ROW_SELECTOR + ':not(.dragging)')];
|
||||||
return draggableElements.reduce((closest, child) => {
|
const closest = draggableElements.reduce((acc, child) => {
|
||||||
const box = child.getBoundingClientRect();
|
const box = child.getBoundingClientRect();
|
||||||
const offset = y - box.top - box.height / 2;
|
const offset = y - box.top - box.height / 2;
|
||||||
if(offset < 0 && offset > closest.offset){
|
if(offset < 0 && offset > acc.offset){
|
||||||
return { offset: offset, element: child };
|
return { offset: offset, element: child };
|
||||||
} else {
|
|
||||||
return closest;
|
|
||||||
}
|
}
|
||||||
}, { offset: Number.NEGATIVE_INFINITY }).element;
|
return acc;
|
||||||
|
}, { offset: Number.NEGATIVE_INFINITY });
|
||||||
|
return closest.element || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// reorderPlanRows: übernimmt die im DOM gefundene Zeilen-Reihenfolge in den
|
// reorderPlanRows: übernimmt die im DOM gefundene Zeilen-Reihenfolge in den
|
||||||
@@ -112,16 +114,20 @@
|
|||||||
const todos = Array.isArray(plan.todos) ? plan.todos : [];
|
const todos = Array.isArray(plan.todos) ? plan.todos : [];
|
||||||
const milestones = Array.isArray(plan.milestones) ? plan.milestones : [];
|
const milestones = Array.isArray(plan.milestones) ? plan.milestones : [];
|
||||||
const todoIds = todos.map(t => t.id);
|
const todoIds = todos.map(t => t.id);
|
||||||
const milestoneIds = milestones.map(m => m.id);
|
|
||||||
|
|
||||||
// Alle beweglichen Zeilen in aktueller DOM-Reihenfolge
|
// Alle beweglichen Zeilen in aktueller DOM-Reihenfolge
|
||||||
const rows = [...chart.querySelectorAll(DRAGGABLE_GANTT_ROW_SELECTOR)];
|
const rows = [...chart.querySelectorAll(DRAGGABLE_GANTT_ROW_SELECTOR)];
|
||||||
const todoRowIds = rows.filter(r => r.classList.contains('gantt-todo')).map(r => r.dataset.todoId).filter(Boolean);
|
const todoRowIds = rows.filter(r => r.classList.contains('gantt-todo')).map(r => r.dataset.todoId).filter(Boolean);
|
||||||
const milestoneRowIds = rows.filter(r => r.classList.contains('gantt-milestone-row')).map(r => r.dataset.milestoneId).filter(Boolean);
|
const milestoneRowIds = rows.filter(r => r.classList.contains('gantt-milestone-row')).map(r => r.dataset.milestoneId).filter(Boolean);
|
||||||
|
|
||||||
// Sicherheitscheck: Anzahl muss stimmen
|
// Sicherheitscheck: Anzahl muss stimmen. Nicht jedes Meilenstein wird als
|
||||||
|
// eigene .gantt-milestone-row gerendert – nur die mit einer TODO-basierten
|
||||||
|
// Position (before:/after:) sind eigene Zeilen. Meilensteine ohne Position
|
||||||
|
// liegen in der Projektzeile und werden hier nicht umsortiert, deshalb nur
|
||||||
|
// die positionierten zählen.
|
||||||
if(todoRowIds.length !== todoIds.length) return;
|
if(todoRowIds.length !== todoIds.length) return;
|
||||||
if(milestoneRowIds.length !== milestoneIds.length) return;
|
const positionedIds = milestones.filter(m => m.position && /^(before|after):/.test(m.position)).map(m => m.id);
|
||||||
|
if(milestoneRowIds.length !== positionedIds.length) return;
|
||||||
|
|
||||||
// TODOs neu ordnen
|
// TODOs neu ordnen
|
||||||
const newTodos = [];
|
const newTodos = [];
|
||||||
@@ -151,9 +157,11 @@
|
|||||||
position = 'after:' + prevRow.dataset.todoId;
|
position = 'after:' + prevRow.dataset.todoId;
|
||||||
} else if(nextRow && nextRow.classList.contains('gantt-todo')){
|
} else if(nextRow && nextRow.classList.contains('gantt-todo')){
|
||||||
position = 'before:' + nextRow.dataset.todoId;
|
position = 'before:' + nextRow.dataset.todoId;
|
||||||
} else if(prevRow && prevRow.classList.contains('gantt-milestone-row')){
|
} else if((!prevRow || !prevRow.classList.contains('gantt-todo')) && (!nextRow || !nextRow.classList.contains('gantt-todo'))){
|
||||||
// Zwei Meilensteine hintereinander: beziehe dich auf die nächste TODO danach oder letzte davor
|
// Weder vor noch hinter diesem Meilenstein steht direkt eine TODO –
|
||||||
// Suche nächste TODO nach diesem Block
|
// das ist der Fall für einen führenden Meilenstein-Block (vor der
|
||||||
|
// ersten TODO) oder mehrere Meilensteine hintereinander. Beziehe dich
|
||||||
|
// auf die nächste TODO danach oder die letzte TODO davor.
|
||||||
let nextTodoId = null;
|
let nextTodoId = null;
|
||||||
for(let i = idx + 1; i < rows.length; i++){
|
for(let i = idx + 1; i < rows.length; i++){
|
||||||
if(rows[i].classList.contains('gantt-todo')){ nextTodoId = rows[i].dataset.todoId; break; }
|
if(rows[i].classList.contains('gantt-todo')){ nextTodoId = rows[i].dataset.todoId; break; }
|
||||||
@@ -161,7 +169,6 @@
|
|||||||
if(nextTodoId){
|
if(nextTodoId){
|
||||||
position = 'before:' + nextTodoId;
|
position = 'before:' + nextTodoId;
|
||||||
} else {
|
} else {
|
||||||
// Keine TODO danach -> letzte TODO davor suchen
|
|
||||||
let prevTodoId = null;
|
let prevTodoId = null;
|
||||||
for(let i = idx - 1; i >= 0; i--){
|
for(let i = idx - 1; i >= 0; i--){
|
||||||
if(rows[i].classList.contains('gantt-todo')){ prevTodoId = rows[i].dataset.todoId; break; }
|
if(rows[i].classList.contains('gantt-todo')){ prevTodoId = rows[i].dataset.todoId; break; }
|
||||||
|
|||||||
@@ -41,7 +41,12 @@ window.PlanApp = {
|
|||||||
app.department = routeMatch ? routeMatch[1] : (app.params.get('department') || 'ELT');
|
app.department = routeMatch ? routeMatch[1] : (app.params.get('department') || 'ELT');
|
||||||
app.weekLabel = app.params.get('weekLabel');
|
app.weekLabel = app.params.get('weekLabel');
|
||||||
app.projNr = app.params.get('projNr') || '';
|
app.projNr = app.params.get('projNr') || '';
|
||||||
app.projName = routeMatch && routeMatch[2] ? decodeURIComponent(routeMatch[2]) : (app.params.get('projName') || '');
|
let projNameFromRoute = '';
|
||||||
|
if(routeMatch && routeMatch[2]){
|
||||||
|
try { projNameFromRoute = decodeURIComponent(routeMatch[2]); }
|
||||||
|
catch(e) { projNameFromRoute = app.params.get('projName') || ''; }
|
||||||
|
}
|
||||||
|
app.projName = projNameFromRoute || (app.params.get('projName') || '');
|
||||||
|
|
||||||
// init: Einstiegspunkt nach DOMContentLoaded. Übernimmt das Theme aus
|
// init: Einstiegspunkt nach DOMContentLoaded. Übernimmt das Theme aus
|
||||||
// localStorage/System, prüft die URL-Parameter und bricht mit Fehlermeldung
|
// localStorage/System, prüft die URL-Parameter und bricht mit Fehlermeldung
|
||||||
@@ -61,20 +66,17 @@ window.PlanApp = {
|
|||||||
|
|
||||||
// bindToolbarButtons: verbindet die Toolbar-Buttons (TODO hinzufügen,
|
// bindToolbarButtons: verbindet die Toolbar-Buttons (TODO hinzufügen,
|
||||||
// Auto-Schedule, Kritischer Pfad) mit ihren globalen Funktionen der
|
// Auto-Schedule, Kritischer Pfad) mit ihren globalen Funktionen der
|
||||||
// anderen plan-Module.
|
// anderen plan-Module. Die Buttons binden ihre Handler bereits über das
|
||||||
|
// onclick-Attribut in project-plan.html; hier werden nur Lücken gefüllt,
|
||||||
|
// falls ein Button-Element fehlt.
|
||||||
function bindToolbarButtons(){
|
function bindToolbarButtons(){
|
||||||
const addTodoBtn = document.getElementById('addTodoBtn');
|
|
||||||
if(addTodoBtn) addTodoBtn.addEventListener('click', () => addTodo());
|
|
||||||
const autoScheduleBtn = document.getElementById('autoScheduleBtn');
|
|
||||||
if(autoScheduleBtn) autoScheduleBtn.addEventListener('click', () => autoScheduleTodos());
|
|
||||||
const criticalPathBtn = document.getElementById('criticalPathBtn');
|
|
||||||
if(criticalPathBtn) criticalPathBtn.addEventListener('click', () => toggleCriticalPath());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadPlanData: lädt alle Sitzungen und die globale Personenliste der
|
// loadPlanData: lädt alle Sitzungen und die globale Personenliste der
|
||||||
// Abteilung über die Server-API, sortiert die Wochen chronologisch und
|
// Abteilung über die Server-API (JSON-Dokumente), sortiert die Wochen
|
||||||
// sucht daraus die Ziel-Woche bzw. das Ziel-Projekt. Zeigt Fehlermeldungen
|
// chronologisch und sucht daraus die Ziel-Woche bzw. das Ziel-Projekt. Zeigt
|
||||||
// der Reihenfolge nach im Seitenkopf an und startet sonst renderPlanPage().
|
// Fehlermeldungen der Reihenfolge nach im Seitenkopf an und startet sonst
|
||||||
|
// renderPlanPage().
|
||||||
function loadPlanData(){
|
function loadPlanData(){
|
||||||
if(!window.fetch){
|
if(!window.fetch){
|
||||||
document.getElementById('planSubtitle').textContent = 'Netzwerk nicht verfügbar.';
|
document.getElementById('planSubtitle').textContent = 'Netzwerk nicht verfügbar.';
|
||||||
@@ -85,11 +87,14 @@ window.PlanApp = {
|
|||||||
.then(r => { if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); })
|
.then(r => { if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); })
|
||||||
.then(data => {
|
.then(data => {
|
||||||
db = { weeks: [], currentWeek: null, globalPeople: {} };
|
db = { weeks: [], currentWeek: null, globalPeople: {} };
|
||||||
db.globalPeople[app.department] = parseGlobalPeopleCsv(data.people);
|
db.globalPeople[app.department] = Array.isArray(data.people) ? data.people : [];
|
||||||
const parsed = (data.sessions || []).map(s => ({ filename: s.filename, rows: parseCsvText(s.content) }));
|
db.weeks = (Array.isArray(data.weeks) ? data.weeks : [])
|
||||||
parsed.forEach(s => {
|
.filter(w => w && typeof w.label === 'string')
|
||||||
const w = parseSessionCsv(s.filename, s.rows);
|
.map(w => {
|
||||||
if(w) db.weeks.push(w);
|
if(!Array.isArray(w.people)) w.people = [];
|
||||||
|
if(!Array.isArray(w.projects)) w.projects = [];
|
||||||
|
if(!w.attendance) w.attendance = {};
|
||||||
|
return w;
|
||||||
});
|
});
|
||||||
db.weeks.sort((a,b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label)));
|
db.weeks.sort((a,b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label)));
|
||||||
app.week = db.weeks.find(w => w.label === app.weekLabel);
|
app.week = db.weeks.find(w => w.label === app.weekLabel);
|
||||||
@@ -171,22 +176,43 @@ window.PlanApp = {
|
|||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Speichern: nur diese Sitzung; globale Personenliste nicht überschreiben
|
// Speichern: nur diese Sitzung; globale Personenliste nicht überschreiben.
|
||||||
|
// In-Flight-Schutz: läuft bereits ein Save, wird kein zweiter POST mit
|
||||||
|
// identischer Revisions-Basis abgeschickt (der würde fälschlich als 409-
|
||||||
|
// Konflikt gemeldet). Stattdessen wird ein Folgesave eingereiht, der nach
|
||||||
|
// Abschluss des laufenden Saves mit der dann frischen Revision nachzieht.
|
||||||
|
let savePlanRunning = false;
|
||||||
|
let savePlanQueued = false;
|
||||||
function savePlan(){
|
function savePlan(){
|
||||||
if(!app.week || !app.project) return;
|
if(!app.week || !app.project) return;
|
||||||
const filename = filenameForWeek(app.week.label);
|
if(savePlanRunning){ savePlanQueued = true; return; }
|
||||||
const csv = weekToCsv(app.week);
|
savePlanRunning = true;
|
||||||
sendCsvToServer(filename, csv).catch(err => console.error('Speichern fehlgeschlagen:', err));
|
sendWeekToServer(app.week)
|
||||||
|
.then(res => {
|
||||||
|
// Lokale Revisions-Basis aktualisieren, damit spätere eigene Saves
|
||||||
|
// nicht fälschlich als Konflikt gemeldet werden.
|
||||||
|
if(res && res.updatedAt) app.week.updatedAt = res.updatedAt;
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
if(err && err.conflict){
|
||||||
|
showToast('Konflikt: Die Sitzung wurde zwischenzeitlich geändert. Bitte Seite neu laden.');
|
||||||
|
console.error('Speichern fehlgeschlagen (Konflikt):', err.message || err);
|
||||||
|
} else {
|
||||||
|
console.error('Speichern fehlgeschlagen:', err);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
savePlanRunning = false;
|
||||||
|
if(savePlanQueued){ savePlanQueued = false; savePlan(); }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// saveGlobalPeople: schreibt die globale Personenliste der Abteilung als
|
// saveGlobalPeople: schreibt die globale Personenliste der Abteilung als
|
||||||
// _people.csv auf den Server, aber nur wenn sie nicht leer ist.
|
// JSON-Array auf den Server. Auch eine leere Liste wird gespeichert, damit
|
||||||
|
// das Löschen der letzten Person erhalten bleibt.
|
||||||
function saveGlobalPeople(){
|
function saveGlobalPeople(){
|
||||||
const people = db.globalPeople[app.department] || [];
|
const people = db.globalPeople[app.department] || [];
|
||||||
if(people.length > 0){
|
sendPeopleToServer(people).catch(err => console.error('Speichern Personen fehlgeschlagen:', err));
|
||||||
const peopleCsv = globalPeopleToCsv(people);
|
|
||||||
sendCsvToServer('_people.csv', peopleCsv).catch(err => console.error('Speichern Personen fehlgeschlagen:', err));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Person in der globalen Liste finden (dort werden Abwesenheiten gepflegt)
|
// Person in der globalen Liste finden (dort werden Abwesenheiten gepflegt)
|
||||||
|
|||||||
@@ -24,11 +24,23 @@ function delProjectFromList(id){
|
|||||||
|
|
||||||
// editProjectField: ändert ein Feld eines Projekts (z.B. Name oder Gruppe).
|
// editProjectField: ändert ein Feld eines Projekts (z.B. Name oder Gruppe).
|
||||||
// Parameter: `id` (Projekt-ID), `field` (Feldname), `val` (neuer Wert).
|
// Parameter: `id` (Projekt-ID), `field` (Feldname), `val` (neuer Wert).
|
||||||
// Keine Rückgabe.
|
// Beim Wechsel der Gruppe wird das Projekt ans Ende der Zielgruppe angehängt
|
||||||
|
// (neue `order` = höchste Order der Zielgruppe + 1), damit es nicht anhand
|
||||||
|
// seiner alten `order` in der Liste landet. Keine Rückgabe.
|
||||||
function editProjectField(id,field,val){
|
function editProjectField(id,field,val){
|
||||||
if(guardWeekLocked()) return;
|
if(guardWeekLocked()) return;
|
||||||
const p = currentWeek().projects.find(x=>x.id===id);
|
const w = currentWeek();
|
||||||
if(p){ p[field]=val; save(); renderProjects(); renderBoard(); }
|
const p = w.projects.find(x=>x.id===id);
|
||||||
|
if(!p) return;
|
||||||
|
if(field === 'group' && val && val !== p.group){
|
||||||
|
p.group = val;
|
||||||
|
const targetProjects = w.projects.filter(x => x.id !== id && x.group === val);
|
||||||
|
const maxOrder = targetProjects.reduce((m,x)=>Math.max(m, x.order || 0), -1);
|
||||||
|
p.order = maxOrder + 1;
|
||||||
|
} else {
|
||||||
|
p[field] = val;
|
||||||
|
}
|
||||||
|
save(); renderProjects(); renderBoard();
|
||||||
}
|
}
|
||||||
|
|
||||||
// editProjectActiveLP: setzt bzw. hebt die amtierende Leistungsphase eines
|
// editProjectActiveLP: setzt bzw. hebt die amtierende Leistungsphase eines
|
||||||
@@ -50,6 +62,7 @@ function editProjectActiveLP(id, value){
|
|||||||
function renderProjects(){
|
function renderProjects(){
|
||||||
const w = currentWeek();
|
const w = currentWeek();
|
||||||
if(!w) return;
|
if(!w) return;
|
||||||
|
const locked = isWeekLocked(w);
|
||||||
let html="";
|
let html="";
|
||||||
const groups = {};
|
const groups = {};
|
||||||
w.projects.forEach(p=>{ (groups[p.group]||(groups[p.group]=[])).push(p); });
|
w.projects.forEach(p=>{ (groups[p.group]||(groups[p.group]=[])).push(p); });
|
||||||
@@ -59,10 +72,10 @@ function renderProjects(){
|
|||||||
|
|
||||||
sortGroups(Object.keys(groups)).forEach(g=>{
|
sortGroups(Object.keys(groups)).forEach(g=>{
|
||||||
html += `<div class="section-title">${esc(g)}</div>`;
|
html += `<div class="section-title">${esc(g)}</div>`;
|
||||||
html += `<div class="projects-sortable" data-group="${g}">`;
|
html += `<div class="projects-sortable" data-group="${g}" ${locked ? '' : 'ondragover="handleDragOver(event)" ondrop="handleDrop(event)"'}>`;
|
||||||
groups[g].forEach(p=>{
|
groups[g].forEach(p=>{
|
||||||
html += `<div class="person-list-item" draggable="true" data-project-id="${p.id}" ondragstart="handleDragStart(event)" ondragover="handleDragOver(event)" ondrop="handleDrop(event)" ondragend="handleDragEnd(event)">
|
html += `<div class="person-list-item" ${locked ? '' : 'draggable="true" ondragstart="handleDragStart(event)" ondragend="handleDragEnd(event)"'} data-project-id="${p.id}">
|
||||||
<span class="drag-handle" style="cursor:grab;color:#999;margin-right:8px">⋮⋮</span>
|
${locked ? '' : `<span class="drag-handle" style="cursor:grab;color:#999;margin-right:8px">⋮⋮</span>`}
|
||||||
<input type="text" value="${esc(p.nr)}" onchange="editProjectField('${p.id}','nr',this.value)" style="width:100px" placeholder="Proj-Nr">
|
<input type="text" value="${esc(p.nr)}" onchange="editProjectField('${p.id}','nr',this.value)" style="width:100px" placeholder="Proj-Nr">
|
||||||
<input type="text" value="${esc(p.name)}" onchange="editProjectField('${p.id}','name',this.value)" style="width:200px" placeholder="Bezeichnung">
|
<input type="text" value="${esc(p.name)}" onchange="editProjectField('${p.id}','name',this.value)" style="width:200px" placeholder="Bezeichnung">
|
||||||
<select onchange="editProjectField('${p.id}','group',this.value)">
|
<select onchange="editProjectField('${p.id}','group',this.value)">
|
||||||
@@ -106,19 +119,32 @@ function handleDragOver(event){
|
|||||||
event.dataTransfer.dropEffect = 'move';
|
event.dataTransfer.dropEffect = 'move';
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleDrop: legt die gezogene Projekt-Zeile an der Position ab, die
|
// handleDrop: legt die gezogene Projekt-Zeile ab. Der Handler hängt am
|
||||||
// `getDragAfterElement` relativ zur Mausposition ermittelt, und speichert
|
// Gruppen-Container (`.projects-sortable`), sodass das Ablegen über der
|
||||||
// die neue Reihenfolge über `reorderProjects()`.
|
// gesamten Gruppe inklusive leerer Flächen möglich ist. Beim Wechsel zwischen
|
||||||
|
// Gruppen (z. B. "Kleine Projekte" → "Aktive Projekte") wird das Projekt ans
|
||||||
|
// Ende der Zielgruppe angehängt; innerhalb derselben Gruppe wird die Position
|
||||||
|
// per `getDragAfterElement` relativ zur Mausposition bestimmt. Danach speichert
|
||||||
|
// `reorderProjects()` die neue Reihenfolge.
|
||||||
function handleDrop(event){
|
function handleDrop(event){
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if(!draggedProject || draggedProject === event.currentTarget) return;
|
if(!draggedProject) return;
|
||||||
const sortable = event.currentTarget.parentElement;
|
const sortable = event.currentTarget;
|
||||||
|
const sourceSortable = draggedProject.parentElement;
|
||||||
|
const isSameGroup = sourceSortable &&
|
||||||
|
sourceSortable.dataset && sortable.dataset &&
|
||||||
|
sourceSortable.dataset.group === sortable.dataset.group;
|
||||||
|
if(isSameGroup){
|
||||||
const afterElement = getDragAfterElement(sortable, event.clientY);
|
const afterElement = getDragAfterElement(sortable, event.clientY);
|
||||||
if(afterElement == null){
|
if(afterElement == null){
|
||||||
sortable.appendChild(draggedProject);
|
sortable.appendChild(draggedProject);
|
||||||
} else {
|
} else {
|
||||||
sortable.insertBefore(draggedProject, afterElement);
|
sortable.insertBefore(draggedProject, afterElement);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Gruppenwechsel: ans Ende der Zielgruppe anhängen
|
||||||
|
sortable.appendChild(draggedProject);
|
||||||
|
}
|
||||||
reorderProjects();
|
reorderProjects();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,11 +157,14 @@ function handleDragEnd(event){
|
|||||||
|
|
||||||
// getDragAfterElement: ermittelt das Element in `container`, vor dem die
|
// getDragAfterElement: ermittelt das Element in `container`, vor dem die
|
||||||
// gezogene Zeile bei Mausposition `y` abgelegt werden soll (Suche nach dem
|
// gezogene Zeile bei Mausposition `y` abgelegt werden soll (Suche nach dem
|
||||||
// Element mit der kleinsten negativen Distanz zur Mitte). Rückgabe:
|
// Element mit der kleinsten negativen Distanz zur Mitte). Parameter:
|
||||||
// Ziel-Element oder undefined (→ ans Ende anfügen). Wird auch von
|
// `selector` = CSS-Selektor der sortierbaren Elemente (standardmäßig die
|
||||||
// `js/people/people.js` für die Personen-Sortierung verwendet.
|
// Projekt-Zeilen, steht aber auch der Personen-Liste für `.person-card` zur
|
||||||
function getDragAfterElement(container, y){
|
// Verfügung). Rückgabe: Ziel-Element oder undefined (→ ans Ende anfügen).
|
||||||
const draggableElements = [...container.querySelectorAll('.person-list-item:not([style*="opacity"])')];
|
// Wird auch von `js/people/people.js` für die Personen-Sortierung verwendet.
|
||||||
|
function getDragAfterElement(container, y, selector){
|
||||||
|
const sel = selector || '.person-list-item:not([style*="opacity"])';
|
||||||
|
const draggableElements = [...container.querySelectorAll(sel)];
|
||||||
return draggableElements.reduce((closest, child) => {
|
return draggableElements.reduce((closest, child) => {
|
||||||
const box = child.getBoundingClientRect();
|
const box = child.getBoundingClientRect();
|
||||||
const offset = y - box.top - box.height / 2;
|
const offset = y - box.top - box.height / 2;
|
||||||
|
|||||||
+50
-16
@@ -1,13 +1,15 @@
|
|||||||
// -----------------------------
|
// -----------------------------
|
||||||
// Projektübersicht — Server-Kommunikation (CSV-API)
|
// Projektübersicht — Server-Kommunikation (JSON-API)
|
||||||
// -----------------------------
|
// -----------------------------
|
||||||
// Diese Datei kapselt die HTTP-Kommunikation mit dem Express-Server (siehe
|
// Diese Datei kapselt die HTTP-Kommunikation mit dem Express-Server (siehe
|
||||||
// server.js im Projekt-Root) und wird nach js/csv.js von beiden
|
// server.js im Projekt-Root) und wird nach js/date-picker.js von beiden
|
||||||
// HTML-Einstiegspunkten geladen. Sie stellt baseUrl, blobToDataUrl,
|
// HTML-Einstiegspunkten geladen. Sie stellt baseUrl, blobToDataUrl,
|
||||||
// sendCsvToServer und deleteCsvFromServer bereit. Genutzt wird sie von
|
// sendWeekToServer, sendPeopleToServer und deleteWeekFromServer bereit.
|
||||||
// js/store.js (syncAllToServer, Speichern/Löschen aller Wochen),
|
// Genutzt wird sie von js/store.js (syncAllToServer, Speichern aller Wochen
|
||||||
// js/weeks.js (Wochen-Löschung) und js/plan/plan-state.js (savePlan schreibt
|
// und der Personenliste), js/weeks.js (Wochen-Löschung) und
|
||||||
// direkt die aktuelle Woche über sendCsvToServer).
|
// js/plan/plan-state.js (savePlan schreibt direkt die aktuelle Woche über
|
||||||
|
// sendWeekToServer). Die Daten werden als strukturierte JSON-Objekte an die
|
||||||
|
// SQLite-API des Servers übergeben (keine CSV-Strings mehr).
|
||||||
|
|
||||||
// baseUrl: dynamische Server-URL, damit die App auch von anderen Geraeten im Netzwerk erreichbar ist.
|
// baseUrl: dynamische Server-URL, damit die App auch von anderen Geraeten im Netzwerk erreichbar ist.
|
||||||
// Fallback auf localhost:3003, wenn die Seite als lokale Datei geladen wurde.
|
// Fallback auf localhost:3003, wenn die Seite als lokale Datei geladen wurde.
|
||||||
@@ -31,24 +33,56 @@ function blobToDataUrl(blob){
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendCsvToServer: POSTet eine Speicherwoche im CSV-Format an <baseUrl>/save-csv
|
// sendWeekToServer: POSTet ein komplettes Wochen-Objekt (JSON) an
|
||||||
// Parameter: filename (String), csvContent (String). Rückgabe: Promise mit Server-Antwort.
|
// <baseUrl>/save-week. Parameter: week (Objekt). Rückgabe: Promise mit
|
||||||
function sendCsvToServer(filename, csvContent){
|
// Server-Antwort. `baseUpdatedAt` übermittelt die Revision, auf der die
|
||||||
|
// lokalen Daten beruhen; meldet der Server 409 (conflict), wird ein Error
|
||||||
|
// mit `err.conflict = true` und `err.updatedAt` geworfen, damit der Aufrufer
|
||||||
|
// den Konflikt anzeigen kann.
|
||||||
|
function sendWeekToServer(week){
|
||||||
if(!window.fetch) return Promise.reject(new Error('fetch nicht verfügbar'));
|
if(!window.fetch) return Promise.reject(new Error('fetch nicht verfügbar'));
|
||||||
return fetch(baseUrl() + '/save-csv',{
|
return fetch(baseUrl() + '/save-week',{
|
||||||
method:'POST',
|
method:'POST',
|
||||||
headers:{'Content-Type':'application/json; charset=utf-8'},
|
headers:{'Content-Type':'application/json; charset=utf-8'},
|
||||||
body:JSON.stringify({filename,csv:csvContent,department:currentDepartment})
|
body:JSON.stringify({
|
||||||
|
week,
|
||||||
|
department:currentDepartment,
|
||||||
|
baseUpdatedAt:(week && week.updatedAt) ? week.updatedAt : undefined
|
||||||
|
})
|
||||||
|
}).then(r=>{
|
||||||
|
return r.json().then(json => {
|
||||||
|
if(r.ok && json) return json;
|
||||||
|
if(json && json.conflict){
|
||||||
|
const err = new Error(json.error || 'Konflikt: Sitzung wurde zwischenzeitlich geändert');
|
||||||
|
err.conflict = true;
|
||||||
|
err.label = (week && week.label) || '';
|
||||||
|
err.updatedAt = json.updatedAt;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
throw new Error('Server HTTP '+r.status);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendPeopleToServer: POSTet die globale Personenliste einer Abteilung als
|
||||||
|
// JSON-Array an <baseUrl>/save-people. Parameter: people (Array).
|
||||||
|
// Rückgabe: Promise mit Server-Antwort.
|
||||||
|
function sendPeopleToServer(people){
|
||||||
|
if(!window.fetch) return Promise.reject(new Error('fetch nicht verfügbar'));
|
||||||
|
return fetch(baseUrl() + '/save-people',{
|
||||||
|
method:'POST',
|
||||||
|
headers:{'Content-Type':'application/json; charset=utf-8'},
|
||||||
|
body:JSON.stringify({people, department:currentDepartment})
|
||||||
}).then(r=>{ if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); });
|
}).then(r=>{ if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
// deleteCsvFromServer: fordert das Löschen einer Speicherwoche über POST /delete-csv an
|
// deleteWeekFromServer: fordert das Löschen einer Woche über POST /delete-week an
|
||||||
// Parameter: filename (String). Rückgabe: Promise mit Server-Antwort.
|
// Parameter: label (String, das Wochen-Label). Rückgabe: Promise mit Server-Antwort.
|
||||||
function deleteCsvFromServer(filename){
|
function deleteWeekFromServer(label){
|
||||||
if(!window.fetch) return Promise.reject(new Error('fetch nicht verfügbar'));
|
if(!window.fetch) return Promise.reject(new Error('fetch nicht verfügbar'));
|
||||||
return fetch(baseUrl() + '/delete-csv', {
|
return fetch(baseUrl() + '/delete-week', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type':'application/json; charset=utf-8'},
|
headers: {'Content-Type':'application/json; charset=utf-8'},
|
||||||
body: JSON.stringify({ filename, department: currentDepartment })
|
body: JSON.stringify({ label, department: currentDepartment })
|
||||||
}).then(r=>{ if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); });
|
}).then(r=>{ if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); });
|
||||||
}
|
}
|
||||||
+14
-5
@@ -18,6 +18,9 @@ let showModeTimer = null;
|
|||||||
let showModeResumeTimer = null;
|
let showModeResumeTimer = null;
|
||||||
// showModePaused: true, wenn der Durchlauf gerade pausiert ist
|
// showModePaused: true, wenn der Durchlauf gerade pausiert ist
|
||||||
let showModePaused = false;
|
let showModePaused = false;
|
||||||
|
// showModePollTimer: Timer-Handle des Poll-Intervalls in waitForLoadThenRender
|
||||||
|
// (wird in stopShowMode mit aufgeräumt, sonst rendert es nach dem Beenden weiter)
|
||||||
|
let showModePollTimer = null;
|
||||||
// showModeOriginal: Abteilung und Tab vor dem Start, wird beim Beenden wiederhergestellt
|
// showModeOriginal: Abteilung und Tab vor dem Start, wird beim Beenden wiederhergestellt
|
||||||
let showModeOriginal = { department: DEFAULT_DEPARTMENT, tab: DEFAULT_TAB };
|
let showModeOriginal = { department: DEFAULT_DEPARTMENT, tab: DEFAULT_TAB };
|
||||||
const SHOW_MODE_SLIDE_DURATION = 10000; // 10 Sekunden pro Slide
|
const SHOW_MODE_SLIDE_DURATION = 10000; // 10 Sekunden pro Slide
|
||||||
@@ -62,16 +65,19 @@ function stopShowMode(){
|
|||||||
showModePaused = false;
|
showModePaused = false;
|
||||||
clearTimeout(showModeTimer);
|
clearTimeout(showModeTimer);
|
||||||
clearTimeout(showModeResumeTimer);
|
clearTimeout(showModeResumeTimer);
|
||||||
|
if(showModePollTimer){ clearInterval(showModePollTimer); showModePollTimer = null; }
|
||||||
|
|
||||||
const overlay = document.getElementById('showModeOverlay');
|
const overlay = document.getElementById('showModeOverlay');
|
||||||
if(overlay) overlay.style.display = 'none';
|
if(overlay) overlay.style.display = 'none';
|
||||||
|
|
||||||
// Ursprünglichen Zustand wiederherstellen
|
// Ursprünglichen Zustand wiederherstellen. Geschützte Tabs ('projects'/
|
||||||
|
// 'people') werden nur ohne Passwort angezeigt, wenn bereits authentifiziert –
|
||||||
|
// sonst öffnet showTab (ohne skipAuth) das Passwort-Modal.
|
||||||
currentTab = showModeOriginal.tab;
|
currentTab = showModeOriginal.tab;
|
||||||
if(currentDepartment !== showModeOriginal.department){
|
if(currentDepartment !== showModeOriginal.department){
|
||||||
switchDepartment(showModeOriginal.department);
|
switchDepartment(showModeOriginal.department);
|
||||||
} else {
|
} else {
|
||||||
showTab(currentTab, true);
|
showTab(currentTab, isAuthenticated);
|
||||||
renderAll();
|
renderAll();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -155,11 +161,14 @@ function showModeNextSlide(){
|
|||||||
// plant die nächste Folie. Parameter: view = 'board' oder 'load'.
|
// plant die nächste Folie. Parameter: view = 'board' oder 'load'.
|
||||||
function waitForLoadThenRender(view){
|
function waitForLoadThenRender(view){
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
const check = setInterval(() => {
|
showModePollTimer = setInterval(() => {
|
||||||
attempts++;
|
attempts++;
|
||||||
|
// Poll nur fortsetzen, solange der Show Mode noch läuft (sonst würde nach
|
||||||
|
// stopShowMode der gerade wiederhergestellte Zustand überschrieben).
|
||||||
|
if(!showModeActive && showModePollTimer){ clearInterval(showModePollTimer); showModePollTimer = null; return; }
|
||||||
// Wenn Wochen geladen sind oder nach Seed gefallen ist
|
// Wenn Wochen geladen sind oder nach Seed gefallen ist
|
||||||
if(db.weeks.length > 0 || attempts > 30){
|
if((db.weeks.length > 0 || attempts > 30) && showModeActive){
|
||||||
clearInterval(check);
|
if(showModePollTimer){ clearInterval(showModePollTimer); showModePollTimer = null; }
|
||||||
showTab(view, true);
|
showTab(view, true);
|
||||||
renderAll();
|
renderAll();
|
||||||
// Kurze Verzögerung, damit das DOM aktualisiert ist, bevor kopiert wird
|
// Kurze Verzögerung, damit das DOM aktualisiert ist, bevor kopiert wird
|
||||||
|
|||||||
+182
-40
@@ -1,22 +1,44 @@
|
|||||||
// -----------------------------
|
// -----------------------------
|
||||||
// Projektübersicht — Daten laden, Speichern, Abteilung wechseln
|
// Projektübersicht — Daten laden, Speichern, Abteilung wechseln
|
||||||
// -----------------------------
|
// -----------------------------
|
||||||
// Diese Datei bildet die Schnittstelle zum Server: Sie lädt alle Sitzungs-CSVs
|
// Diese Datei bildet die Schnittstelle zum Server: Sie lädt alle Sitzungen
|
||||||
// beim Start, speichert den kompletten Datenbestand (alle Wochen + globale
|
// beim Start, speichert den kompletten Datenbestand (alle Wochen + globale
|
||||||
// Personenliste) und verwaltet den Wechsel zwischen Abteilungen (ELT/HLS/ARCH).
|
// Personenliste) und verwaltet den Wechsel zwischen Abteilungen (ELT/HLS/ARCH).
|
||||||
// Sie wird von `Test.html` nach `js/csv.js` und `js/server.js` geladen und stellt
|
// Die Daten liegen serverseitig in einer SQLite-Datenbank und werden als
|
||||||
|
// strukturierte JSON-Objekte über die API ausgetauscht (keine CSV-Strings).
|
||||||
|
// Sie wird von `Test.html` nach `js/date-picker.js` und `js/server.js` geladen und stellt
|
||||||
// die globalen Funktionen `save()`, `syncAllToServer()`, `switchDepartment()`,
|
// die globalen Funktionen `save()`, `syncAllToServer()`, `switchDepartment()`,
|
||||||
// `load()`, `seed()` und `demoProj()` bereit. Aufrufer sind u.a. `js/weeks.js`,
|
// `load()`, `seed()` und `demoProj()` bereit. Aufrufer sind u.a. `js/weeks.js`,
|
||||||
// `js/board/board.js`, `js/people/people.js`, `js/projects/projects.js` und `js/load/load.js`; das Speichern
|
// `js/board/board.js`, `js/people/people.js`, `js/projects/projects.js` und `js/load/load.js`; das Speichern
|
||||||
// ist über `saveSyncTimer` (in `js/globals.js` deklariert) um 500 ms debounced.
|
// ist über `saveSyncTimer` (in `js/globals.js` deklariert) um 500 ms debounced.
|
||||||
|
|
||||||
// save: synchronisiert die aktuellen Daten als CSV-Dateien zum Server.
|
// save: plant einen (debounced) Sync. Standardmäßig wird die aktuell gewählte
|
||||||
// Jede Sitzung wird als eigene CSV-Datei gespeichert. Personen und Rollen werden
|
// Woche als geändert markiert, da sich die meisten Bearbeitungen auf die
|
||||||
// in der Sitzungscsv selbst abgelegt.
|
// aktuelle Sitzung beziehen. Optionen:
|
||||||
function save(){
|
// { week:false } – aktuelle Woche NICHT markieren (z. B. reine Personen-Aktionen)
|
||||||
|
// { people:true } – zusätzlich die globale Personenliste als geändert markieren
|
||||||
|
function save(opts = {}){
|
||||||
|
if(opts.week !== false){
|
||||||
|
const w = currentWeek();
|
||||||
|
if(w) dirtyWeeks.add(w.label);
|
||||||
|
}
|
||||||
|
if(opts.people) dirtyPeople = true;
|
||||||
scheduleSessionSync();
|
scheduleSessionSync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dirty-Tracking: merkt sich, welche Wochen bzw. ob die globale Personenliste
|
||||||
|
// seit dem letzten Sync lokal geändert wurden. Gespeichert wird nur das, was
|
||||||
|
// sich wirklich geändert hat (statt wie früher immer alle Wochen).
|
||||||
|
let dirtyWeeks = new Set();
|
||||||
|
let dirtyPeople = false;
|
||||||
|
|
||||||
|
// In-Flight-Schutz für syncAllToServer: läuft bereits ein Sync, wird kein
|
||||||
|
// zweiter gestartet (sonst senden zwei Syncs dieselbe Woche mit gleicher
|
||||||
|
// Revisions-Basis und der zweite bekäme einen scheinbaren 409-Konflikt).
|
||||||
|
// Stattdessen wird ein Nachlauf-Sync eingereiht und nach Abschluss ausgeführt.
|
||||||
|
let syncInFlight = false;
|
||||||
|
let syncQueued = false;
|
||||||
|
|
||||||
// scheduleSessionSync: plant den Speichervorgang für alle Wochen und Personen
|
// scheduleSessionSync: plant den Speichervorgang für alle Wochen und Personen
|
||||||
// über einen 500-ms-Timer (Debounce). Bei erneutem Aufruf wird ein laufender
|
// über einen 500-ms-Timer (Debounce). Bei erneutem Aufruf wird ein laufender
|
||||||
// Timer zurückgesetzt, damit aufeinanderfolgende Änderungen in einem Sync enden.
|
// Timer zurückgesetzt, damit aufeinanderfolgende Änderungen in einem Sync enden.
|
||||||
@@ -25,33 +47,133 @@ function scheduleSessionSync(){
|
|||||||
saveSyncTimer = setTimeout(() => { syncAllToServer(); saveSyncTimer = null; }, 500);
|
saveSyncTimer = setTimeout(() => { syncAllToServer(); saveSyncTimer = null; }, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
// syncAllToServer: schreibt die gesamte Datenbank als CSV-Dateien zum Server.
|
// syncAllToServer: schreibt nur die seit dem letzten Sync geänderten Wochen
|
||||||
// Zuerst wird die globale Personenliste (`_people.csv`) gespeichert, danach jede
|
// plus – falls markiert – die globale Personenliste zum Server. Konflikte
|
||||||
// Sitzung einzeln über `filenameForWeek()`/`weekToCsv()` und `sendCsvToServer()`.
|
// (409) auf einer Woche werden über handleWeekConflict() gemeldet. Fehlerhaft
|
||||||
// Gibt nichts zurück; Fehler werden nur per console.error protokolliert.
|
// gespeicherte Wochen bleiben dirty, damit der nächste Sync sie erneut
|
||||||
|
// versucht; nur erfolgreich gespeicherte werden aus dem Dirty-Satz entfernt.
|
||||||
function syncAllToServer(){
|
function syncAllToServer(){
|
||||||
// Zuerst globale Personenliste speichern
|
if(syncInFlight){ syncQueued = true; return; }
|
||||||
saveGlobalPeopleToServer();
|
syncInFlight = true;
|
||||||
// Dann jede Sitzung speichern
|
|
||||||
db.weeks.forEach(w => {
|
// Zuerst globale Personenliste speichern, falls geändert
|
||||||
const filename = filenameForWeek(w.label);
|
const jobs = [];
|
||||||
const csv = weekToCsv(w);
|
if(dirtyPeople) jobs.push(saveGlobalPeopleToServer());
|
||||||
sendCsvToServer(filename, csv)
|
|
||||||
.then(res => { if(res && res.ok) console.log('Sitzung gespeichert:', filename); })
|
const weekLabels = Array.from(dirtyWeeks);
|
||||||
.catch(err => { console.error('Sitzung konnte nicht gespeichert werden:', filename, err.message || err); });
|
weekLabels.forEach(label => {
|
||||||
|
const w = db.weeks.find(x => x.label === label);
|
||||||
|
if(!w){ dirtyWeeks.delete(label); return; }
|
||||||
|
jobs.push(sendWeekToServer(w)
|
||||||
|
.then(res => {
|
||||||
|
if(res && res.ok){
|
||||||
|
console.log('Sitzung gespeichert:', w.label);
|
||||||
|
// Lokale Revisions-Basis aktualisieren, damit spätere eigene Saves
|
||||||
|
// nicht fälschlich als Konflikt gemeldet werden.
|
||||||
|
if(res.updatedAt) w.updatedAt = res.updatedAt;
|
||||||
|
dirtyWeeks.delete(label);
|
||||||
|
} else {
|
||||||
|
console.error('Sitzung konnte nicht gespeichert werden:', w.label, res && res.error ? res.error : 'unbekannter Fehler');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
if(err && err.conflict){
|
||||||
|
dirtyWeeks.delete(label);
|
||||||
|
handleWeekConflict(w, err);
|
||||||
|
} else {
|
||||||
|
console.error('Sitzung konnte nicht gespeichert werden:', w.label, err.message || err);
|
||||||
|
// Label bleibt dirty -> der nächste Sync versucht es erneut.
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
Promise.all(jobs).then(() => {
|
||||||
|
syncInFlight = false;
|
||||||
|
if(syncQueued){ syncQueued = false; syncAllToServer(); }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// saveGlobalPeopleToServer: serialisiert die globale Personenliste der aktuellen
|
// markWeekDirty: markiert eine bestimmte Woche (label) als geändert – für
|
||||||
// Abteilung per `globalPeopleToCsv()` und speichert sie als `_people.csv`.
|
// Aktionen, die Wochen AUFßER der aktuellen betreffen (z. B. Auto-Lock).
|
||||||
// Leere Personenlisten werden übersprungen (kein Schreibvorgang).
|
function markWeekDirty(label){
|
||||||
|
if(typeof label === 'string' && label) dirtyWeeks.add(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
// markPeopleDirty: markiert die globale Personenliste als geändert.
|
||||||
|
function markPeopleDirty(){
|
||||||
|
dirtyPeople = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWeekConflict: wird aufgerufen, wenn der Server eine zwischenzeitlich
|
||||||
|
// geänderte Woche meldet (409). Nur diese eine Woche wird vom Server neu
|
||||||
|
// geladen; andere lokal geänderte Wochen (dirtyWeeks) und die Personenliste
|
||||||
|
// bleiben unangetastet und gehen nicht verloren.
|
||||||
|
function handleWeekConflict(week, err){
|
||||||
|
const label = (week && week.label) || (err && err.label) || '';
|
||||||
|
alert('Achtung: ' + label + ' wurde zwischenzeitlich von einer anderen Person '
|
||||||
|
+ 'geändert. Ihre Änderungen an dieser Sitzung wurden nicht gespeichert; '
|
||||||
|
+ 'die aktuellen Daten dieser Sitzung werden geladen.');
|
||||||
|
if(label) reloadWeekFromServer(label);
|
||||||
|
else load();
|
||||||
|
}
|
||||||
|
|
||||||
|
// reloadWeekFromServer: lädt die aktuelle Abteilung neu und ersetzt NUR die
|
||||||
|
// übergebene Woche (bzw. entfernt sie, falls sie am Server gelöscht wurde).
|
||||||
|
// Der Dirty-Zustand der konfliktbehafteten Woche wurde bereits von
|
||||||
|
// syncAllToServer entfernt; alle anderen Dirty-Änderungen bleiben erhalten.
|
||||||
|
function reloadWeekFromServer(label){
|
||||||
|
const requestedDepartment = currentDepartment;
|
||||||
|
fetch(baseUrl() + '/load-all' + departmentParam())
|
||||||
|
.then(r => { if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); })
|
||||||
|
.then(data => {
|
||||||
|
if(currentDepartment !== requestedDepartment) return;
|
||||||
|
const fresh = (Array.isArray(data.weeks) ? data.weeks : [])
|
||||||
|
.find(w => w && w.label === label);
|
||||||
|
const idx = db.weeks.findIndex(w => w.label === label);
|
||||||
|
if(idx >= 0){
|
||||||
|
if(fresh){
|
||||||
|
// Felder absichern (wie in load()), damit keine kaputten Daten crashen
|
||||||
|
if(!Array.isArray(fresh.people)) fresh.people = [];
|
||||||
|
if(!Array.isArray(fresh.projects)) fresh.projects = [];
|
||||||
|
if(!fresh.attendance) fresh.attendance = {};
|
||||||
|
db.weeks[idx] = fresh;
|
||||||
|
} else {
|
||||||
|
db.weeks.splice(idx, 1);
|
||||||
|
}
|
||||||
|
// Wurde die aktuelle Woche entfernt, auf die letzte verbleibende wechseln
|
||||||
|
if(!db.weeks.some(w => w.id === db.currentWeek)){
|
||||||
|
db.currentWeek = db.weeks.length ? db.weeks[db.weeks.length - 1].id : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renderAll();
|
||||||
|
showTab(currentTab);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('Neu laden der Sitzung fehlgeschlagen:', err);
|
||||||
|
load();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveGlobalPeopleToServer: speichert die globale Personenliste der aktuellen
|
||||||
|
// Abteilung über `sendPeopleToServer()` (JSON-Array). Auch eine leere Liste
|
||||||
|
// wird gespeichert, damit das Löschen der letzten Person erhalten bleibt. Bei
|
||||||
|
// Erfolg wird der Dirty-Status für Personen zurückgesetzt, bei Fehler bleibt
|
||||||
|
// er erhalten (Retry beim nächsten Sync). Rückgabe: Promise.
|
||||||
function saveGlobalPeopleToServer(){
|
function saveGlobalPeopleToServer(){
|
||||||
const people = db.globalPeople[currentDepartment] || [];
|
const people = db.globalPeople[currentDepartment] || [];
|
||||||
if(people.length === 0) return;
|
return sendPeopleToServer(people)
|
||||||
const csv = globalPeopleToCsv(people);
|
.then(res => {
|
||||||
sendCsvToServer('_people.csv', csv)
|
if(res && res.ok){
|
||||||
.then(res => { if(res && res.ok) console.log('Globale Personenliste gespeichert'); })
|
dirtyPeople = false;
|
||||||
.catch(err => { console.error('Globale Personenliste konnte nicht gespeichert werden:', err.message || err); });
|
console.log('Globale Personenliste gespeichert');
|
||||||
|
} else {
|
||||||
|
console.error('Globale Personenliste konnte nicht gespeichert werden:', res && res.error ? res.error : 'unbekannter Fehler');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('Globale Personenliste konnte nicht gespeichert werden:', err.message || err);
|
||||||
|
// dirtyPeople bleibt true -> der nächste Sync versucht es erneut.
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// switchDepartment: wechselt die aktive Abteilung (z.B. "ELT") und lädt deren
|
// switchDepartment: wechselt die aktive Abteilung (z.B. "ELT") und lädt deren
|
||||||
@@ -60,6 +182,12 @@ function saveGlobalPeopleToServer(){
|
|||||||
// Gibt nichts zurück.
|
// Gibt nichts zurück.
|
||||||
function switchDepartment(dept){
|
function switchDepartment(dept){
|
||||||
if(!DEPARTMENTS[dept]) return;
|
if(!DEPARTMENTS[dept]) return;
|
||||||
|
// Laufenden Speicher-Debounce der alten Abteilung verwerfen, damit dessen
|
||||||
|
// Sync nicht nach dem Reset mit dem neuen currentDepartment feuert und die
|
||||||
|
// Daten der falschen Abteilung schreibt.
|
||||||
|
if(saveSyncTimer){ clearTimeout(saveSyncTimer); saveSyncTimer = null; }
|
||||||
|
dirtyWeeks.clear();
|
||||||
|
dirtyPeople = false;
|
||||||
currentDepartment = dept;
|
currentDepartment = dept;
|
||||||
saveUiState();
|
saveUiState();
|
||||||
updateRouteUrl(dept, currentTab);
|
updateRouteUrl(dept, currentTab);
|
||||||
@@ -80,34 +208,47 @@ function switchDepartment(dept){
|
|||||||
load();
|
load();
|
||||||
}
|
}
|
||||||
|
|
||||||
// load: liest alle CSV-Dateien der aktuellen Abteilung über `/load-all` vom
|
// load: liest alle Sitzungen und die globale Personenliste der aktuellen
|
||||||
// Server ein, parst Personen und Sitzungen (mit `parseSessionCsv()`) und baut
|
// Abteilung über `/load-all` als JSON vom Server und baut daraus `db.weeks`
|
||||||
// daraus `db.weeks` auf. Sortiert nach `weekSortKey()`, wendet `applyAutoLock()`
|
// auf. Sortiert nach `weekSortKey()`, wendet `applyAutoLock()` an und rendert
|
||||||
// an und rendert die Ansicht. Kein Parameter, keine Rückgabe; bei Fehlern oder
|
// die Ansicht. Kein Parameter, keine Rückgabe; bei Fehlern oder fehlenden
|
||||||
// fehlenden Daten werden Beispieldaten über `seed()` geladen.
|
// Daten werden Beispieldaten über `seed()` geladen.
|
||||||
function load(){
|
function load(){
|
||||||
|
// Nach einem (Neu-)Load sind die lokalen Daten serverseitig aktuell:
|
||||||
|
// laufenden Debounce abbrechen und Dirty-Zustand verwerfen.
|
||||||
|
if(saveSyncTimer){ clearTimeout(saveSyncTimer); saveSyncTimer = null; }
|
||||||
|
dirtyWeeks.clear();
|
||||||
|
dirtyPeople = false;
|
||||||
if(!window.fetch){
|
if(!window.fetch){
|
||||||
alert('Ihr Browser unterstützt keine Netzwerk-Anfragen. Bitte verwenden Sie einen aktuellen Browser.');
|
alert('Ihr Browser unterstützt keine Netzwerk-Anfragen. Bitte verwenden Sie einen aktuellen Browser.');
|
||||||
seed(); return;
|
seed(); return;
|
||||||
}
|
}
|
||||||
|
// Abteilung einfrieren, für die diese Anfrage gestartet wurde. Antworten,
|
||||||
|
// die nach einem zwischenzeitlichen Abteilungswechsel eintreffen, werden
|
||||||
|
// verworfen (Stale-Response), sonst würden Daten der alten Abteilung unter
|
||||||
|
// der neuen Abteilung angezeigt und gespeichert.
|
||||||
|
const requestedDepartment = currentDepartment;
|
||||||
const url = baseUrl() + '/load-all' + departmentParam();
|
const url = baseUrl() + '/load-all' + departmentParam();
|
||||||
console.log('Loading from', url);
|
console.log('Loading from', url);
|
||||||
fetch(url)
|
fetch(url)
|
||||||
.then(r => { if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); })
|
.then(r => { if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); })
|
||||||
.then(data => {
|
.then(data => {
|
||||||
|
if(currentDepartment !== requestedDepartment) return;
|
||||||
// Globale Personenliste laden
|
// Globale Personenliste laden
|
||||||
db.globalPeople[currentDepartment] = parseGlobalPeopleCsv(data.people);
|
db.globalPeople[currentDepartment] = Array.isArray(data.people) ? data.people : [];
|
||||||
|
|
||||||
const parsedSessions = (data.sessions || []).map(s => ({ filename: s.filename, rows: parseCsvText(s.content) }));
|
db.weeks = (Array.isArray(data.weeks) ? data.weeks : [])
|
||||||
|
.filter(w => w && typeof w.label === 'string')
|
||||||
db.weeks = [];
|
.map(w => {
|
||||||
parsedSessions.forEach(s => {
|
// Felder absichern, damit alte/kaputte Dokumente nicht crashen
|
||||||
const w = parseSessionCsv(s.filename, s.rows);
|
if(!Array.isArray(w.people)) w.people = [];
|
||||||
if(w) db.weeks.push(w);
|
if(!Array.isArray(w.projects)) w.projects = [];
|
||||||
|
if(!w.attendance) w.attendance = {};
|
||||||
|
return w;
|
||||||
});
|
});
|
||||||
|
|
||||||
if(db.weeks.length === 0){
|
if(db.weeks.length === 0){
|
||||||
console.warn('Server lieferte 0 Sitzungsdateien. Es werden Beispieldaten (seed) angezeigt. Pruefen Sie, ob der Ordner sessions/ mitkopiert wurde.');
|
console.warn('Server lieferte 0 Sitzungen. Es werden Beispieldaten (seed) angezeigt. Pruefen Sie, ob die SQLite-Datenbank sessions/projektuebersicht.db existiert.');
|
||||||
seed();
|
seed();
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
@@ -126,6 +267,7 @@ function load(){
|
|||||||
showTab(currentTab);
|
showTab(currentTab);
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
|
if(currentDepartment !== requestedDepartment) return;
|
||||||
console.error('Laden vom Server fehlgeschlagen:', err);
|
console.error('Laden vom Server fehlgeschlagen:', err);
|
||||||
const serverUrl = baseUrl();
|
const serverUrl = baseUrl();
|
||||||
const msg = 'Server unter ' + serverUrl + ' nicht erreichbar.\n\n' +
|
const msg = 'Server unter ' + serverUrl + ' nicht erreichbar.\n\n' +
|
||||||
|
|||||||
@@ -47,14 +47,21 @@ function confirmPassword(){
|
|||||||
const pwdHash = CryptoJS.SHA256(pwd).toString();
|
const pwdHash = CryptoJS.SHA256(pwd).toString();
|
||||||
|
|
||||||
if(pwdHash === PROTECTED_PASSWORD_HASH){
|
if(pwdHash === PROTECTED_PASSWORD_HASH){
|
||||||
|
// Sitzungs-Authentifizierung merken, damit Tab-Wechsel und Abteilungs-
|
||||||
|
// wechsel innerhalb dieser Sitzung kein erneutes Passwort verlangen.
|
||||||
|
isAuthenticated = true;
|
||||||
|
// Nur die zuletzt angeforderte Aktion ausführen (nicht eine evtl. bereits
|
||||||
|
// durch closePasswordModal verworfenen Aktion erneut starten) – daher die
|
||||||
|
// Aktion hier erneut über requestedAction prüfen statt sie festzuhalten.
|
||||||
|
setTimeout(() => {
|
||||||
const action = requestedAction;
|
const action = requestedAction;
|
||||||
requestedAction = null;
|
requestedAction = null;
|
||||||
document.getElementById('passwordModal').style.display='none';
|
|
||||||
setTimeout(() => {
|
|
||||||
if(action?.type==='tab') showTab(action.tab, true);
|
if(action?.type==='tab') showTab(action.tab, true);
|
||||||
else if(action?.type==='deleteWeek') deleteWeek(true);
|
else if(action?.type==='deleteWeek') deleteWeek(true);
|
||||||
else if(action?.type==='unlockWeek') unlockCurrentWeek(true);
|
else if(action?.type==='unlockWeek') unlockCurrentWeek(true);
|
||||||
|
else if(action?.type==='importDb') importDb(true);
|
||||||
}, 100);
|
}, 100);
|
||||||
|
document.getElementById('passwordModal').style.display='none';
|
||||||
} else {
|
} else {
|
||||||
alert("Falsches Passwort!");
|
alert("Falsches Passwort!");
|
||||||
document.getElementById('passwordInput').value='';
|
document.getElementById('passwordInput').value='';
|
||||||
|
|||||||
+42
-1
@@ -5,7 +5,7 @@
|
|||||||
// ID-, Sortier- und Datumsfunktionen) und wird nach js/globals.js von beiden
|
// ID-, Sortier- und Datumsfunktionen) und wird nach js/globals.js von beiden
|
||||||
// HTML-Einstiegspunkten geladen. Sie stellt ausschließlich globale Funktionen
|
// HTML-Einstiegspunkten geladen. Sie stellt ausschließlich globale Funktionen
|
||||||
// ohne eigenen Zustand bereit; genutzt wird sie praktisch von allen Modulen,
|
// ohne eigenen Zustand bereit; genutzt wird sie praktisch von allen Modulen,
|
||||||
// u. a. js/csv.js, js/board/board.js, js/load/load.js sowie den Plan-Modulen unter js/plan/*
|
// u. a. js/board/board.js, js/load/load.js sowie den Plan-Modulen unter js/plan/*
|
||||||
// (z. B. für ISO-Datumsformate und Werktage) und von js/gantt.js.
|
// (z. B. für ISO-Datumsformate und Werktage) und von js/gantt.js.
|
||||||
|
|
||||||
// departmentParam: gibt ein Query-String-Fragment fuer die aktuelle Abteilung zurueck.
|
// departmentParam: gibt ein Query-String-Fragment fuer die aktuelle Abteilung zurueck.
|
||||||
@@ -35,6 +35,47 @@ function sortGroups(arr){
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// peopleByRole: gruppiert Personen nach Rolle zurück in ein Objekt.
|
||||||
|
// Parameter `target`: Woche(n) mit `people`-Array (oder undefined → aktuelle
|
||||||
|
// Woche). Rückgabe: Objekt `{ Rolle: [{person, role}] }` mit Deduplizierung.
|
||||||
|
// Liegt im gemeinsamen Modul, weil board/load/pdf auf sie zugreifen.
|
||||||
|
function peopleByRole(target){
|
||||||
|
const m = {}; ROLES.forEach(r=>m[r]=[]);
|
||||||
|
const people = [];
|
||||||
|
if(Array.isArray(target)){
|
||||||
|
target.forEach(w => { if(w && Array.isArray(w.people)) people.push(...w.people); });
|
||||||
|
} else if(target && Array.isArray(target.people)){
|
||||||
|
people.push(...target.people);
|
||||||
|
} else if(Array.isArray(currentWeek()?.people)){
|
||||||
|
people.push(...currentWeek().people);
|
||||||
|
}
|
||||||
|
const seen = new Set();
|
||||||
|
people.forEach(p=>{
|
||||||
|
const personRoles = Array.isArray(p.role) ? p.role : [p.role];
|
||||||
|
personRoles.forEach(role=>{
|
||||||
|
if(!m[role]) m[role]=[];
|
||||||
|
const key = `${p.id}|${role}`;
|
||||||
|
if(!seen.has(key)){
|
||||||
|
seen.add(key);
|
||||||
|
m[role].push({person:p, role});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
// weekSortKey: liefert einen nach Jahr und Kalenderwoche sortierbaren Schlüssel,
|
||||||
|
// sodass z.B. "KW 9 / 2026" vor "KW 10 / 2026" kommt.
|
||||||
|
// Parameter: label (String). Rückgabe: String (Jahr-KW) oder kleingeschriebenes Label.
|
||||||
|
function weekSortKey(label){
|
||||||
|
const m = String(label || '').match(/KW\s*(\d+)[^\d]+(\d{4})/i);
|
||||||
|
if(m){
|
||||||
|
const kw = m[1].padStart(2,'0');
|
||||||
|
return `${m[2]}-${kw}`;
|
||||||
|
}
|
||||||
|
return String(label || '').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
// Persistente ID fuer eine Person aus dem Kuerzel, damit Anwesenheit und
|
// Persistente ID fuer eine Person aus dem Kuerzel, damit Anwesenheit und
|
||||||
// Zuordnungen auch nach einem Neuladen stabil bleiben.
|
// Zuordnungen auch nach einem Neuladen stabil bleiben.
|
||||||
// Parameter: name (String). Rückgabe: String wie 'p_ab'.
|
// Parameter: name (String). Rückgabe: String wie 'p_ab'.
|
||||||
|
|||||||
+19
-13
@@ -31,9 +31,11 @@ function updateWeekDate(){
|
|||||||
// Kein Parameter; Rückgabe: das gefundene Woche-Objekt oder undefined.
|
// Kein Parameter; Rückgabe: das gefundene Woche-Objekt oder undefined.
|
||||||
function currentWeek(){ return db.weeks.find(w=>w.id===db.currentWeek); }
|
function currentWeek(){ return db.weeks.find(w=>w.id===db.currentWeek); }
|
||||||
|
|
||||||
// switchWeek: setzt die aktuell gewählte Wochen-ID (`id`), speichert und
|
// switchWeek: setzt die aktuell gewählte Wochen-ID (`id`) und rendert die
|
||||||
// rendert die Ansicht neu. Keine Rückgabe.
|
// Ansicht neu. Es werden keine Daten geändert, daher wird nicht gespeichert
|
||||||
function switchWeek(id){ db.currentWeek = id; save(); renderAll(); }
|
// (eine save() würde die Woche nur als dirty markieren und unnötig senden).
|
||||||
|
// Keine Rückgabe.
|
||||||
|
function switchWeek(id){ db.currentWeek = id; renderAll(); }
|
||||||
|
|
||||||
// newWeek: erzeugt über zwei prompt-Dialoge eine neue leere Sitzung (Bezeichnung
|
// newWeek: erzeugt über zwei prompt-Dialoge eine neue leere Sitzung (Bezeichnung
|
||||||
// und Datum). Die neue Woche übernimmt die globale Personenliste der Abteilung,
|
// und Datum). Die neue Woche übernimmt die globale Personenliste der Abteilung,
|
||||||
@@ -82,7 +84,11 @@ function applyAutoLock(){
|
|||||||
const sorted = [...db.weeks].sort((a,b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label)));
|
const sorted = [...db.weeks].sort((a,b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label)));
|
||||||
const latestId = sorted[sorted.length - 1].id;
|
const latestId = sorted[sorted.length - 1].id;
|
||||||
db.weeks.forEach(w => {
|
db.weeks.forEach(w => {
|
||||||
|
const wasLocked = !!w.locked;
|
||||||
w.locked = (w.id !== latestId);
|
w.locked = (w.id !== latestId);
|
||||||
|
// Nur tatsächlich geänderte Lock-Zustände übertragen (beim bloßen Laden
|
||||||
|
// sind die Werte identisch und es wird nichts gesendet).
|
||||||
|
if(wasLocked !== w.locked) markWeekDirty(w.label);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,9 +130,9 @@ function unlockCurrentWeek(skipAuth = false){
|
|||||||
}
|
}
|
||||||
|
|
||||||
// deleteWeek: löscht die aktuell ausgewählte Sitzung (nur wenn mehr als 1
|
// deleteWeek: löscht die aktuell ausgewählte Sitzung (nur wenn mehr als 1
|
||||||
// vorhanden) inkl. ihrer CSV-Datei auf dem Server. Parameter `skipAuth=true`
|
// vorhanden) auf dem Server. Parameter `skipAuth=true` überspringt die
|
||||||
// überspringt die Passwortabfrage. Danach wird die letzte verbleibende Woche
|
// Passwortabfrage. Danach wird die letzte verbleibende Woche aktiviert und neu
|
||||||
// aktiviert und neu gerendert. Keine Rückgabe.
|
// gerendert. Keine Rückgabe.
|
||||||
function deleteWeek(skipAuth = false){
|
function deleteWeek(skipAuth = false){
|
||||||
if(db.weeks.length<=1){ alert("Mindestens eine Sitzung muss bestehen bleiben."); return; }
|
if(db.weeks.length<=1){ alert("Mindestens eine Sitzung muss bestehen bleiben."); return; }
|
||||||
if(!skipAuth && !isAuthenticated){
|
if(!skipAuth && !isAuthenticated){
|
||||||
@@ -138,13 +144,13 @@ function deleteWeek(skipAuth = false){
|
|||||||
}
|
}
|
||||||
if(!confirm("Diese Sitzung wirklich löschen?")) { requestedAction = null; return; }
|
if(!confirm("Diese Sitzung wirklich löschen?")) { requestedAction = null; return; }
|
||||||
const current = currentWeek();
|
const current = currentWeek();
|
||||||
const filename = current ? filenameForWeek(current.label) : null;
|
if(current){
|
||||||
if(filename){
|
const label = current.label;
|
||||||
console.log('Lösche CSV-Datei auf Server:', filename);
|
console.log('Lösche Woche auf Server:', label);
|
||||||
deleteCsvFromServer(filename).then(res=>{
|
deleteWeekFromServer(label).then(res=>{
|
||||||
if(res && res.ok){ console.log('CSV-Datei gelöscht:', filename); }
|
if(res && res.ok){ console.log('Woche gelöscht:', label); }
|
||||||
else { alert('CSV-Löschung fehlgeschlagen: ' + (res && res.error ? res.error : 'unbekannter Fehler')); }
|
else { alert('Wochen-Löschung fehlgeschlagen: ' + (res && res.error ? res.error : 'unbekannter Fehler')); }
|
||||||
}).catch(err=>{ alert('CSV-Löschung fehlgeschlagen: ' + (err.message || err)); });
|
}).catch(err=>{ alert('Wochen-Löschung fehlgeschlagen: ' + (err.message || err)); });
|
||||||
}
|
}
|
||||||
db.weeks = db.weeks.filter(w=>w.id!==db.currentWeek);
|
db.weeks = db.weeks.filter(w=>w.id!==db.currentWeek);
|
||||||
db.weeks.sort((a,b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label)));
|
db.weeks.sort((a,b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label)));
|
||||||
|
|||||||
-3279
File diff suppressed because it is too large.
Load diff
@@ -6,7 +6,7 @@
|
|||||||
in js/gantt.js in einem neuen Tab geöffnet und per Deep-Link-Route
|
in js/gantt.js in einem neuen Tab geöffnet und per Deep-Link-Route
|
||||||
"/<Abteilung>/Projektplan/<Projektname>" adressiert; Woche und Projekt-Nr.
|
"/<Abteilung>/Projektplan/<Projektname>" adressiert; Woche und Projekt-Nr.
|
||||||
kommen als URL-Parameter (weekLabel, projNr). Sie lädt styles.css und ausschließlich die gemeinsamen
|
kommen als URL-Parameter (weekLabel, projNr). Sie lädt styles.css und ausschließlich die gemeinsamen
|
||||||
Module js/globals.js, js/utils.js, js/ui.js, js/date-picker.js, js/csv.js, js/server.js
|
Module js/globals.js, js/utils.js, js/ui.js, js/date-picker.js, js/server.js
|
||||||
und js/gantt.js sowie danach die Plan-Module js/plan/plan-state.js,
|
und js/gantt.js sowie danach die Plan-Module js/plan/plan-state.js,
|
||||||
js/plan/plan-build-gantt.js, js/plan/plan-gantt.js, js/plan/plan-drag.js,
|
js/plan/plan-build-gantt.js, js/plan/plan-gantt.js, js/plan/plan-drag.js,
|
||||||
js/plan/plan-modals.js und js/plan/plan-schedule.js. WICHTIG: store, weeks, board, people,
|
js/plan/plan-modals.js und js/plan/plan-schedule.js. WICHTIG: store, weeks, board, people,
|
||||||
@@ -318,7 +318,6 @@
|
|||||||
<script src="/js/utils.js?v=5"></script>
|
<script src="/js/utils.js?v=5"></script>
|
||||||
<script src="/js/ui.js?v=5"></script>
|
<script src="/js/ui.js?v=5"></script>
|
||||||
<script src="/js/date-picker.js?v=5"></script>
|
<script src="/js/date-picker.js?v=5"></script>
|
||||||
<script src="/js/csv.js?v=5"></script>
|
|
||||||
<script src="/js/server.js?v=5"></script>
|
<script src="/js/server.js?v=5"></script>
|
||||||
<script src="/js/gantt.js?v=5"></script>
|
<script src="/js/gantt.js?v=5"></script>
|
||||||
<script src="/js/plan/plan-state.js?v=5"></script>
|
<script src="/js/plan/plan-state.js?v=5"></script>
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -1161,8 +1161,12 @@ th.role-bauüberwachung { color: var(--text); }
|
|||||||
.legend-item.locked-legend-item { opacity: .7; }
|
.legend-item.locked-legend-item { opacity: .7; }
|
||||||
|
|
||||||
#boardLegend {
|
#boardLegend {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 12px;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--line-soft);
|
border: 1px solid var(--line-soft);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.swatch {
|
.swatch {
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
@@ -1,8 +0,0 @@
|
|||||||
WOCHE;KW 32 / 2026;2026-08-05;
|
|
||||||
PERSONEN;;;RS|Planung|Rieke Stilleke;AC|Planung|Atilla Ceylan;CJ|Planung|Carina Jost;SM|Planung|Shahab Mushref;TC|Planung|Tetiana Chevhuz;MK|Projektleitung|Maximilian Kriener;SS|Projektleitung|Sekandar Seradj;MK|Bauüberwachung|Maximilian Kriener;SS|Bauüberwachung|Sekandar Seradj;;;;;;;;;;;
|
|
||||||
Pr.-Nr.;Bezeichnung;Status;RS;AC;CJ;SM;TC;MK;SS;MK;SS;LPH 1;LPH 2;LPH 3;LPH 4;LPH 5;LPH 6;LPH 7;LPH 8;W+M;Gruppe;Index;Plan
|
|
||||||
ANWESENHEIT;;;;;;;;;;;;;;;;;;;;;;
|
|
||||||
21-4001;Feuerwehrgeräteh. Rodenkirchen;- Wettbewerbsbeitrag erstellt;;;;;;;;;;100%;100%;;;;;;;;Aktive Projekte;5;
|
|
||||||
;Umbau Praxisräume;- Bestandsaufnahme geplant Test schreiben ;;;;;;;;;;100%;;;;;;;;;In Anbahnung;0;
|
|
||||||
;Denkmalschutzgutachten;- Kurzcheck erstellt;;;;;;;;;;100%;;;;;;;;;In Anbahnung;1;
|
|
||||||
;Architektur Zeichnungsvorlagen;- Vorlagen aktualisiert;;;;;;;;;;100%;;;;;;;;;Allgemein;0;
|
|
||||||
|
@@ -1,8 +0,0 @@
|
|||||||
NAME;Rollen;Voller Name;E-Mail;Abwesenheiten
|
|
||||||
AC;Planung;Atilla Ceylan;a.ceylan@rauschenberg-ing.de;[]
|
|
||||||
CJ;Planung;Carina Jost;c.jost@rauschenberg-ing.de;[]
|
|
||||||
MK;Projektleitung,Bauüberwachung;Maximilian Kriener;m.kriener@rauschenberg-ing.de;[]
|
|
||||||
RS;Planung;Rieke Stilleke;r.stilleke@rauschenberg-ing.de;[]
|
|
||||||
SM;Planung;Shahab Mushref;s.mushref@rauschenberg-ing.de;[]
|
|
||||||
SS;Projektleitung,Bauüberwachung;Sekandar Seradj;s.seradj@rauschenberg-ing.de;[]
|
|
||||||
TC;Planung;Tetiana Chevhuz;t.chevhuz@rauschenberg-ing.de;
|
|
||||||
|
@@ -1,9 +0,0 @@
|
|||||||
WOCHE;KW 30 / 2026;2026-07-24;1
|
|
||||||
PERSONEN;;;AP|Planung|AP;SK|Planung|SK;AK|Planung|AK;ED|Planung|ED;SE|Planung|SE;THK|Planung|THK;SP|Planung|SP;SG|Planung|SG;TW|Planung|TW;RR|Planung|RR;MK|Planung|MK;HR|Planung|HR;;;;;;;;;;;;;;
|
|
||||||
Pr.-Nr.;Bezeichnung;Status;AP;SK;AK;ED;SE;THK;SP;SG;TW;RR;MK;HR;LPH 1;LPH 2;LPH 3;LPH 4;LPH 5;LPH 6;LPH 7;LPH 8;W+M;Gruppe;Index;Plan;Aufwand;Aktive Phasen
|
|
||||||
ANWESENHEIT;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
||||||
18-4007;Janusz-Korczak-Gesamtsch. Bottrop;LPH5 als Vorabzug raus. NW-Räume vorauss. August.;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;95%;;Aktive Projekte;0;;;
|
|
||||||
21-4030;NSHV Köln Immobilienprüfung in Hof;Thorsten W. klärt und übernimmt Schriftverkehr. LV liegt zur Sichtung bei T.W.;;;;;;;;;;;;;100%;100%;100%;;100%;100%;;;;Aktive Projekte;1;;;
|
|
||||||
22-4056;Gymnasium Hemer;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;90%;;Aktive Projekte;2;;;
|
|
||||||
22-4066;DSW 21 – Umbau E-Mobilität;Elt-Montagen ab KW 33.;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;20%;;Aktive Projekte;3;;;
|
|
||||||
24-4043;S-K ODL-Heim Sanierung Krefeld;Voraussichtlich LV-Abgabe.;;;;;;;;;;;;;100%;100%;100%;;100%;;;;;Aktive Projekte;4;;;
|
|
||||||
|
@@ -1,33 +0,0 @@
|
|||||||
WOCHE;KW 31 / 2026;2026-07-29;1
|
|
||||||
PERSONEN;;;AP|Planung|Alexander Plum;AK|Planung|Alexander Kenne;ED|Planung|Erik Dunin;SK|Planung|Simon Kunigk;SE|Planung|Steven Esche;WS|Planung|Winfried Sältzer;ED|Projektleitung|Erik Dunin;SP|Projektleitung|Sebastian Pöttinger;THK|Projektleitung|Thorsten Kramp;RR|Projektleitung|Robert Röllecke;MK|Projektleitung|Maximilian Kriener;TW|Projektleitung|Thorsten Werner;SG|Projektleitung|Steffen Gaida;ED|Bauüberwachung|Erik Dunin;SK|Bauüberwachung|Simon Kunigk;SP|Bauüberwachung|Sebastian Pöttinger;THK|Bauüberwachung|Thorsten Kramp;HR|Bauüberwachung|Hermann Rollecke;;;;;;;;;;;;;;
|
|
||||||
Pr.-Nr.;Bezeichnung;Status;AP;AK;ED;SK;SE;WS;ED;SP;THK;RR;MK;TW;SG;ED;SK;SP;THK;HR;LPH 1;LPH 2;LPH 3;LPH 4;LPH 5;LPH 6;LPH 7;LPH 8;W+M;Gruppe;Index;Plan;Aufwand;Aktive Phasen
|
|
||||||
ANWESENHEIT;;X;X;;X;X;X;;X;X;;;;X;;X;X;X;;;;;;;;;;;;;;;
|
|
||||||
18-4007;Janusz-Korczak-Gesamtsch. Bottrop;- KW 35 NW Raum Planung <div>- KW 44 Möbel </div>;;;;;;;;;;;;;;;;;;;100%;100%;100%;;90%;;;;;Aktive Projekte;0;"{""start"":"""",""end"":"""",""milestones"":[],""people"":[],""progress"":0}";;
|
|
||||||
21-4030;NSHV Köln Immobilienprüfung in Hof;- Thorsten W. klärt und übernimmt Schriftverkehr - LV liegt zur Sichtung bei T.W.;;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;;;;Aktive Projekte;2;;;
|
|
||||||
22-4066;DSW 21, Umbau E-Mobilität;"- Elt-Montagen ab <font color=""#c53030""><b>KW 33</b></font>.";;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;20%;;Aktive Projekte;6;;;
|
|
||||||
24-4043;S-K ODL-Heim Sanierung Krefeld;- Vorraussichtlich LV-Abgabe XX.XX<div>- LPH5 mündlich freigegeben <div>- Neuer EG Grundriss mit diversen Änderungen kommt noch von Architekten</div></div>;;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;;;;;Aktive Projekte;10;;;
|
|
||||||
21-4001;Feuerwehrgeräteh. Rodenkirchen;"- KNX muß noch programmiert werden. <font color=""#c53030"">Abnahme 27.07</font> <div><font color=""#c53030""><b>- Fertigstellung Ende Juli</b></font><div><font color=""#c53030""><b>- Abnahmen laufen bis Ende KW31</b></font></div><div><font color=""#c53030""><b>- Blackbuilding Test 26.07</b></font></div></div>";;X;;;;;;;;;;;X;;;;;;;;;;100%;100%;100%;;;Aktive Projekte;1;"{""start"":"""",""end"":"""",""milestones"":[{""id"":""mslrv3i1edl08"",""name"":""SV Abnahme"",""date"":""2026-08-18""},{""id"":""mslrvh7hjau1e"",""name"":""Test"",""date"":""2026-09-05""}],""people"":[""p_ap"",""p_ak"",""p_sk"",""p_sg""],""progress"":""0"",""todos"":[{""id"":""msluufbp114g1"",""title"":""Test"",""start"":""2026-08-09"",""end"":""2026-08-13"",""done"":false},{""id"":""mslv6h76cmx8k"",""title"":""iuguzgv"",""start"":""2026-08-23"",""end"":""2026-09-06"",""done"":false},{""id"":""mslw4l3qp1v18"",""title"":""Hallo"",""start"":""2026-11-29"",""end"":""2027-04-30"",""done"":false}]}";;
|
|
||||||
22-4027;Sporthalle Soest;- SV und VOB Abnahme erfolgt.<div>- Nachprüfung VOB 24.07</div><div>- Keine Infos über Nachprüfung</div>;;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;90%;;Aktive Projekte;3;;;
|
|
||||||
22-4056;Gymnasium Hemer;;;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;90%;;Aktive Projekte;4;;;
|
|
||||||
22-4056-1;Gymnasium Hemer (Deckensanierung);- Beginn der Arbeiten vorraussichtlich KW 30 (KW 32 Kickoff);;;;;;;;;;;;;;;;;;;;;100%;;100%;100%;;;;Aktive Projekte;5;;;
|
|
||||||
23-4042;GWK Neufelder Straße;"- Nachweis für E-30 Leitungen notwendig. Rechnungen wurden nicht anerkannt. <div><font color=""#c53030"">- 22.07 Aufschaltung BMA und Amok</font></div><div><font color=""#c53030"">- KW 31 VOB Abnahme</font></div>";;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;95%;;Aktive Projekte;7;;;
|
|
||||||
23-4070;Umbau Bürogebäude NEW, MV10;"- neue NSHV anbieten(Warten auf Infos), <div>- 28.07 Sachverständiger Abnahme ELT&SiBe </div><div>- 30.07 Sachverständigen Abnahme BMA</div>";;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;90%;;Aktive Projekte;8;;;
|
|
||||||
23-4072;S-K OGS-Kaldenkirchen Schulanbau;"- Feininstallation beginnt, Fertigstellung voraussichtlich <font color=""#c53030"">01.08</font>";;;;;;;;;;;;;;;;;;;;;;;;;;70%;;Aktive Projekte;9;;;
|
|
||||||
24-4049;BMA Remscheid;"- Feuerwehrabnahme organisiert die Errichterfirma. <div>- SV-Abnahmen 15 & 16.07: nicht abgenommen</div>";;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;90%;;Aktive Projekte;11;;;
|
|
||||||
24-4069;S-K Erw. GS Blankenheim Neubau;- nur backup;;;;;;;;;;;;;;;;;;;;;;;100%;100%;;;;Kleine Projekte;0;;;
|
|
||||||
24-4096;Umbau Wohngebäude Kirchhoff;"- Position HA wird von uns bestimmt. - Notwendiges Leerrohr für Leuchten im Vordach ist zu klären.<div><font color=""#c53030"">- Abgabe Kostenberechnung bis Ende KW29</font></div><div><font color=""#c53030"">- LV Fertig bis Ende KW 32 </font> </div>";;;;(X);;;;;;;;;;;;;;;100%;100%;100%;;95%;85%;0%;;;Aktive Projekte;12;;;
|
|
||||||
25-4003;Märkische Bank Boele;- Feininstallation<div>- Ab KW30 Einbringung Tresore</div>;;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;80%;;Aktive Projekte;13;;;
|
|
||||||
25-4028;TenBrinke, Amprion, Wietmarschen;;;;;;;;;;;;;;;;;;;;100%;100%;100%;;95%;;;;;Aktive Projekte;14;;;
|
|
||||||
25-4028-1;TenBrinke, Amprion, Meerbusch;;;;;;;;;;;;;;;;;;;;100%;100%;100%;;95%;;;;;Aktive Projekte;15;;;
|
|
||||||
25-4028-2;TenBrinke, Amprion, Gelsenkirchen;;;;;;;;;;;;;;;;;;;;100%;100%;100%;;95%;;;;;Aktive Projekte;16;;;
|
|
||||||
26-4018;TUI Immobilien GmbH Salzgitter;;;;;;;;;;;;;;;;;;;;50%;;;;;;;;;Aktive Projekte;17;;;
|
|
||||||
26-4023;HA Rettungsw. Nahmer Gasst. - Los 1;;;;X;;;;;;;;;;;;;;;;;;;;;;;;;In Anbahnung;0;;;
|
|
||||||
26-4024;HA Rettungsw. Dahl Am Obergr. - Los 2;;;;X;;;;;;;;;;;;;;;;;;;;;;;;;In Anbahnung;1;;;
|
|
||||||
26-4025;HA, SeePark Hengstey;- LPH2 erfolgt, weitere Arbeiten folgen.;;;;;;;;;;;;;;;;;;;100%;100%;;;;;;;;Aktive Projekte;18;;;
|
|
||||||
26-4026;Stadt Hemer, Anbau OGS Oesetalschule;"<font color=""#1a1a1a"">- Bearbeitung LPH3 in Absprache mit Achitektur S+D</font>";;;;;;;;;;;;;;;;;;;100%;100%;;;;;;;;Aktive Projekte;19;;;
|
|
||||||
26-4046;AXA Außenbeleuchtung;- Warten auf Unterlagen (Grundlagen & Vorplanung);;;;;;;;;;;;;;;;;;;10%;;;;;;;;;Aktive Projekte;20;;;
|
|
||||||
;KFW Köln/Bonn Sicherheitskonzept;- Auftrag erhalten;;;;;;;;;;;;;;;;;;;;;;;;;;;;In Anbahnung;2;;;
|
|
||||||
;Swisstal;;;;;;;;;;;;;;;;;;;;;;;;;;;;;In Anbahnung;3;"{""start"":"""",""end"":"""",""milestones"":[],""people"":[],""progress"":0}";;
|
|
||||||
;Plancodierung/Benennung;- Von Rieke erstellt. Info folgt ? KW31?;;;;;;;;;;;;;;;;;;;;;;;;;;;;Allgemein;0;;;
|
|
||||||
;Ordnerstruktur;- in Projekthilfen _Ordnerstruktur abgelegt, ist in Anlehnung an Leitfaden aufgebaut;;;;;;;;;;;;;;;;;;;;;;;;;;;;Allgemein;1;;;
|
|
||||||
26-4042;Zanderstraße 7, PV-Anlage;- 82 kWp Anlage angefragt. Preisschätzung wird angefertigt;;;;;;;;;;;;;;;;;;;100%;80%;;;;80%;;;;Kleine Projekte;1;;;
|
|
||||||
|
@@ -1,33 +0,0 @@
|
|||||||
WOCHE;KW 33 / 2026;2026-08-09;
|
|
||||||
PERSONEN;;;AP|Planung|Alexander Plum;AK|Planung|Alex Kenne;ED|Planung|Erik Dunin;SK|Planung|Simon Kunigk;SE|Planung|Steven Esche;WS|Planung|Winfried Sältzer;ED|Projektleitung|Erik Dunin;SP|Projektleitung|Sebastian Pöttinger;THK|Projektleitung|Thorsten Kramp;RR|Projektleitung|Robert Röllecke;MK|Projektleitung|Maximilian Kriener;TW|Projektleitung|Thorsten Werner;SG|Projektleitung|Steffen Gaida;ED|Bauüberwachung|Erik Dunin;SK|Bauüberwachung|Simon Kunigk;SP|Bauüberwachung|Sebastian Pöttinger;THK|Bauüberwachung|Thorsten Kramp;HR|Bauüberwachung|Hermann Rollecke;;;;;;;;;;;;;;
|
|
||||||
Pr.-Nr.;Bezeichnung;Status;AP;AK;ED;SK;SE;WS;ED;SP;THK;RR;MK;TW;SG;ED;SK;SP;THK;HR;LPH 1;LPH 2;LPH 3;LPH 4;LPH 5;LPH 6;LPH 7;LPH 8;W+M;Gruppe;Index;Plan;Aufwand;Aktive Phasen
|
|
||||||
ANWESENHEIT;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
||||||
18-4007;Janusz-Korczak-Gesamtsch. Bottrop;- KW 35 NW Raum Planung <div>- KW 44 Möbel </div>;X;;;;;;;;;;;;;;;;;;100%;100%;100%;;90%;;;;;Aktive Projekte;0;"{""start"":"""",""end"":"""",""milestones"":[],""people"":[],""progress"":0}";"{""LPH 5"":{""Planung"":0.6},""LPH 8"":{""Planung"":0,""Projektleitung"":0.2}}";
|
|
||||||
21-4030;NSHV Köln Immobilienprüfung in Hof;- Thorsten W. klärt und übernimmt Schriftverkehr <div>- LV liegt zur Sichtung bei T.W.</div>;X;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;;;;Aktive Projekte;2;;;
|
|
||||||
22-4066;DSW 21, Umbau E-Mobilität;- Elt-Montagen ab <font><b>KW 33</b></font>.;;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;20%;;Aktive Projekte;6;;;
|
|
||||||
24-4043;S-K ODL-Heim Sanierung Krefeld;- Vorraussichtlich LV-Abgabe XX.XX<div>- LPH5 mündlich freigegeben <div>- Neuer EG Grundriss mit diversen Änderungen kommt noch von Architekten</div></div>;;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;;;;;Aktive Projekte;10;;;
|
|
||||||
21-4001;Feuerwehrgeräteh. Rodenkirchen;- KNX muß noch programmiert werden. <font>Abnahme 27.07</font> <div><font><b>- Fertigstellung Ende Juli</b></font><div><font><b>- Abnahmen laufen bis Ende KW31</b></font></div><div><font><b>- Blackbuilding Test 26.07</b></font></div></div>;X;;X;;;;X;X;;;;;;X;;;;;100%;100%;100%;;100%;100%;100%;;;Aktive Projekte;1;"{""start"":"""",""end"":"""",""milestones"":[{""id"":""mslrvh7hjau1e"",""name"":""Übergabe"",""date"":""2026-09-12"",""dependsOn"":[""mslv6h76cmx8k""],""position"":""after:mslv6h76cmx8k""}],""people"":[""p_ap"",""p_ak"",""p_sk"",""p_sg""],""progress"":""0"",""todos"":[{""id"":""msluufbp114g1"",""title"":""Test"",""start"":""2026-08-10"",""end"":""2026-08-14"",""done"":false,""dependsOn"":[],""assignees"":[""p_ap"",""p_ed""],""progress"":0},{""id"":""mslv6h76cmx8k"",""title"":""Test 2"",""start"":""2026-08-24"",""end"":""2026-09-04"",""done"":false,""dependsOn"":[""msluufbp114g1""],""assignees"":[""p_ap""],""progress"":41},{""id"":""mslw4l3qp1v18"",""title"":""Test 3"",""start"":""2026-09-14"",""end"":""2026-09-22"",""done"":false,""dependsOn"":[""m_mslrvh7hjau1e""],""assignees"":[],""progress"":0},{""id"":""msoltaa1itx48"",""title"":""Gehen"",""start"":""2026-09-23"",""end"":""2026-09-25"",""done"":false,""progress"":0,""dependsOn"":[""mslw4l3qp1v18""],""assignees"":[""p_ws""]}]}";"{""LPH 1"":{""Planung"":0.8},""LPH 2"":{""Planung"":0.8},""LPH 3"":{""Planung"":0.8},""LPH 4"":{""Planung"":0.8},""LPH 5"":{""Planung"":0.8}}";"[""LPH 3""]"
|
|
||||||
22-4027;Sporthalle Soest;- SV und VOB Abnahme erfolgt.<div>- Nachprüfung VOB 24.07</div><div>- Keine Infos über Nachprüfung</div>;X;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;90%;;Aktive Projekte;3;;;"[""LPH 5""]"
|
|
||||||
22-4056;Gymnasium Hemer;;;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;90%;;Aktive Projekte;4;;;
|
|
||||||
22-4056-1;Gymnasium Hemer (Deckensanierung);- Beginn der Arbeiten vorraussichtlich KW 30 (KW 32 Kickoff);;;;;;;;;;;;;;;;;;;;;100%;;100%;100%;;;;Aktive Projekte;5;;;
|
|
||||||
23-4042;GWK Neufelder Straße;- Nachweis für E-30 Leitungen notwendig. Rechnungen wurden nicht anerkannt. <div><font>- 22.07 Aufschaltung BMA und Amok</font></div><div><font>- KW 31 VOB Abnahme</font></div>;;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;95%;;Aktive Projekte;7;;;
|
|
||||||
23-4070;Umbau Bürogebäude NEW, MV10;"- neue NSHV anbieten(Warten auf Infos), <div>- 28.07 Sachverständiger Abnahme ELT&SiBe </div><div>- 30.07 Sachverständigen Abnahme BMA</div>";;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;90%;;Aktive Projekte;8;;;
|
|
||||||
23-4072;S-K OGS-Kaldenkirchen Schulanbau;- Feininstallation beginnt, Fertigstellung voraussichtlich <font>01.08</font>;;;;;;;;;;;;;;;;;;;;;;;;;;70%;;Aktive Projekte;9;;;
|
|
||||||
24-4049;BMA Remscheid;"- Feuerwehrabnahme organisiert die Errichterfirma. <div>- SV-Abnahmen 15 & 16.07: nicht abgenommen</div>";;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;90%;;Aktive Projekte;11;;;
|
|
||||||
24-4069;S-K Erw. GS Blankenheim Neubau;- nur backup;;;;;;;;;;;;;;;;;;;;;;;100%;100%;;;;Kleine Projekte;0;;;
|
|
||||||
24-4096;Umbau Wohngebäude Kirchhoff;- Position HA wird von uns bestimmt. <div>- Notwendiges Leerrohr für Leuchten im Vordach ist zu klären.<div><font>- Abgabe Kostenberechnung bis Ende KW29</font></div><div><font>- LV Fertig bis Ende KW 32 </font> </div></div>;;;;;;;;;;;;;;;;;;;100%;100%;100%;;95%;85%;0%;;;Aktive Projekte;12;;;
|
|
||||||
25-4003;Märkische Bank Boele;- Feininstallation<div>- Ab KW30 Einbringung Tresore</div>;;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;80%;;Aktive Projekte;13;;;
|
|
||||||
25-4028;TenBrinke, Amprion, Wietmarschen;;;;;;;;;;;;;;;;;;;;100%;100%;100%;;95%;;;;;Aktive Projekte;14;;;
|
|
||||||
25-4028-1;TenBrinke, Amprion, Meerbusch;;;;;;;;;;;;;;;;;;;;100%;100%;100%;;95%;;;;;Aktive Projekte;15;;;
|
|
||||||
25-4028-2;TenBrinke, Amprion, Gelsenkirchen;;;;;;;;;;;;;;;;;;;;100%;100%;100%;;95%;;;;;Aktive Projekte;16;;;
|
|
||||||
26-4018;TUI Immobilien GmbH Salzgitter;;;;;;;;;;;;;;;;;;;;50%;;;;;;;;;Aktive Projekte;17;;;
|
|
||||||
26-4023;HA Rettungsw. Nahmer Gasst. - Los 1;;;;;;;;;;;;;;;;;;;;;;;;;;;;;In Anbahnung;0;;;
|
|
||||||
26-4024;HA Rettungsw. Dahl Am Obergr. - Los 2;;;;;;;;;;;;;;;;;;;;;;;;;;;;;In Anbahnung;1;;;
|
|
||||||
26-4025;HA, SeePark Hengstey;- LPH2 erfolgt, weitere Arbeiten folgen.;;;;;;;;;;;;;;;;;;;100%;100%;;;;;;;;Aktive Projekte;18;;;
|
|
||||||
26-4026;Stadt Hemer, Anbau OGS Oesetalschule;"<font color=""#1a1a1a"">- Bearbeitung LPH3 in Absprache mit Achitektur S+D</font>";;;;;;;;;;;;;;;;;;;100%;100%;;;;;;;;Aktive Projekte;19;;;
|
|
||||||
26-4046;AXA Außenbeleuchtung;- Warten auf Unterlagen (Grundlagen & Vorplanung);;;;;;;;;;;;;;;;;;;10%;;;;;;;;;Aktive Projekte;20;;;
|
|
||||||
;KFW Köln/Bonn Sicherheitskonzept;- Auftrag erhalten;;;;;;;;;;;;;;;;;;;;;;;;;;;;In Anbahnung;2;;;
|
|
||||||
;Swisstal;;;;;;;;;;;;;;;;;;;;;;;;;;;;;In Anbahnung;3;"{""start"":"""",""end"":"""",""milestones"":[],""people"":[],""progress"":0}";;
|
|
||||||
;Plancodierung/Benennung;- Von Rieke erstellt. Info folgt ? KW31?;;;;;;;;;;;;;;;;;;;;;;;;;;;;Allgemein;0;;;
|
|
||||||
;Ordnerstruktur;- in Projekthilfen _Ordnerstruktur abgelegt, ist in Anlehnung an Leitfaden aufgebaut;;;;;;;;;;;;;;;;;;;;;;;;;;;;Allgemein;1;;;
|
|
||||||
26-4042;Zanderstraße 7, PV-Anlage;- 82 kWp Anlage angefragt. Preisschätzung wird angefertigt;;;;;;;;;;;;;;;;;;;100%;80%;;;;80%;;;;Kleine Projekte;1;;;
|
|
||||||
|
@@ -1,14 +0,0 @@
|
|||||||
NAME;Rollen;Voller Name;E-Mail;Abwesenheiten
|
|
||||||
AK;Planung;Alex Kenne;a.kenne@rauschenberg-ing.de;[]
|
|
||||||
AP;Planung;Alexander Plum;a.plum@rauschenberg-ing.de;[]
|
|
||||||
ED;Planung,Projektleitung,Bauüberwachung;Erik Dunin;e.dunin@rauschenberg-ing.de;[]
|
|
||||||
HR;Planung,Bauüberwachung;Hermann Rollecke;h.roellecke@rauschenberg-ing.de;[]
|
|
||||||
MK;Planung,Projektleitung;Maximilian Kriener;m.kriener@rauschenberg-ing.de;[]
|
|
||||||
RR;Planung,Projektleitung;Robert Röllecke;r.roellecke@rauschenberg-ing.de;[]
|
|
||||||
SE;Planung;Steven Esche;s.esche@rauschenberg-ing.de;[]
|
|
||||||
SG;Planung,Projektleitung;Steffen Gaida;s.gaida@rauschenberg-ing.de;[]
|
|
||||||
SK;Planung,Bauüberwachung;Simon Kunigk;s.kunigk@rauschenberg-ing.de;[]
|
|
||||||
SP;Planung,Projektleitung,Bauüberwachung;Sebastian Pöttinger;s.poettinger@rauschenberg-ing.de;[]
|
|
||||||
THK;Planung,Projektleitung,Bauüberwachung;Thorsten Kramp;t.kramp@rauschenberg-ing.de;[]
|
|
||||||
TW;Planung,Projektleitung;Thorsten Werner;t.werner@rauschenberg-ing.de;[]
|
|
||||||
WS;Planung;Winfried Sältzer;w.sältzer@rauschenberg-ing.de;[]
|
|
||||||
|
@@ -1,9 +0,0 @@
|
|||||||
WOCHE;KW 30 / 2026;2026-07-24;1
|
|
||||||
PERSONEN;;;AP|Planung|AP;SK|Planung|SK;AK|Planung|AK;ED|Planung|ED;SE|Planung|SE;THK|Planung|THK;SP|Planung|SP;SG|Planung|SG;TW|Planung|TW;RR|Planung|RR;MK|Planung|MK;HR|Planung|HR;;;;;;;;;;;;;;
|
|
||||||
Pr.-Nr.;Bezeichnung;Status;AP;SK;AK;ED;SE;THK;SP;SG;TW;RR;MK;HR;LPH 1;LPH 2;LPH 3;LPH 4;LPH 5;LPH 6;LPH 7;LPH 8;W+M;Gruppe;Index;Plan;Aufwand;Aktive Phasen
|
|
||||||
ANWESENHEIT;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
||||||
18-4007;Janusz-Korczak-Gesamtsch. Bottrop;LPH5 als Vorabzug raus. NW-Räume vorauss. August.;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;95%;;Aktive Projekte;0;;;
|
|
||||||
21-4030;NSHV Köln Immobilienprüfung in Hof;Thorsten W. klärt und übernimmt Schriftverkehr. LV liegt zur Sichtung bei T.W.;;;;;;;;;;;;;100%;100%;100%;;100%;100%;;;;Aktive Projekte;1;;;
|
|
||||||
22-4056;Gymnasium Hemer;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;90%;;Aktive Projekte;2;;;
|
|
||||||
22-4066;DSW 21 – Umbau E-Mobilität;Elt-Montagen ab KW 33.;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;20%;;Aktive Projekte;3;;;
|
|
||||||
24-4043;S-K ODL-Heim Sanierung Krefeld;Voraussichtlich LV-Abgabe.;;;;;;;;;;;;;100%;100%;100%;;100%;;;;;Aktive Projekte;4;;;
|
|
||||||
|
@@ -1,11 +0,0 @@
|
|||||||
WOCHE;KW 32 / 2026;2026-08-05;
|
|
||||||
PERSONEN;;;MZ|Planung|Markus Zimmermann;FP|Planung|Frank Pietruschka;TK|Planung|Tim Küppers;RR|Projektleitung|Robert Röllecke;AS|Bauüberwachung|Andreas Simon;SL|Bauüberwachung|Steffan Licata;;;;;;;;;;;;;;
|
|
||||||
Pr.-Nr.;Bezeichnung;Status;MZ;FP;TK;RR;AS;SL;LPH 1;LPH 2;LPH 3;LPH 4;LPH 5;LPH 6;LPH 7;LPH 8;W+M;Gruppe;Index;Plan;Aufwand;Aktive Phasen
|
|
||||||
ANWESENHEIT;;;;;;;;;;;;;;;;;;;;;
|
|
||||||
24-4101;Krankenhaus Köln, Heizungsmodernisierung;- Austausch Kesselhaus geplant;;;;;;;100%;100%;100%;;100%;100%;;90%;;Aktive Projekte;0;"{""start"":"""",""end"":"""",""todos"":[{""id"":""msmzj493z5gie"",""title"":""Abgabe"",""start"":""2026-08-10"",""end"":""2026-08-28"",""done"":false,""progress"":23,""dependsOn"":[],""assignees"":[""p_fp""]}],""milestones"":[{""id"":""msmzi2pbbsv1s"",""name"":""sxcdv"",""date"":""2026-08-15"",""dependsOn"":[],""position"":""""}],""people"":[],""progress"":0}";"{""LPH 1"":{""Planung"":1}}";"[""LPH 1""]"
|
|
||||||
24-4102;Bürokomplex Dortmund, Lüftung;- Filterkonzept in Abstimmung;;;;;;;100%;100%;100%;;95%;;;;;Aktive Projekte;1;"{""start"":"""",""end"":"""",""todos"":[],""milestones"":[{""id"":""msmzhc7wo8alc"",""name"":""Test 123"",""date"":""2026-08-10"",""dependsOn"":[],""position"":""""}],""people"":[],""progress"":0}";;
|
|
||||||
26-4105;Wohnanlage Essen, Regenwasser;- Planung Drainage;;;;;;;100%;100%;;;;;;;;Aktive Projekte;4;;;
|
|
||||||
;Badmodernisierung Privatkunde;- Angebot erstellt;;;;;;;;;;;100%;;;;;In Anbahnung;0;;;
|
|
||||||
;Wärmepumpen-Studie;- Erstgespräch durchgeführt;;;;;;;100%;;;;;;;;;In Anbahnung;1;;;
|
|
||||||
;HLS Planungscheckliste;- Vorlage aktualisiert;;;;;;;100%;;;;;;;;;Allgemein;0;;;
|
|
||||||
21-4001;Feuerwehrgeräteh. Rodenkirchen;;;;;;;;;;;;;;;;;Aktive Projekte;5;;;
|
|
||||||
|
@@ -1,7 +0,0 @@
|
|||||||
NAME;Rollen;Voller Name;E-Mail;Abwesenheiten
|
|
||||||
AS;Bauüberwachung;Andreas Simon;[];[]
|
|
||||||
FP;Planung;Frank Pietruschka;[];[]
|
|
||||||
MZ;Planung;Markus Zimmermann;[];[]
|
|
||||||
RR;Planung,Projektleitung;Robert Röllecke;[];[]
|
|
||||||
SL;Bauüberwachung;Steffan Licata;[];[]
|
|
||||||
TK;Planung;Tim Küppers;[];[]
|
|
||||||
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in new issue
Block a user