Unnötige Datein gelöscht. DB normalisiert
This commit is contained in:
1 parent
da64472251
commit
4e083bee73
32 files changed
+962
-5533
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
|
||||||
@@ -4,39 +4,38 @@
|
|||||||
|
|
||||||
Single-page app for tracking engineering projects across weekly sessions
|
Single-page app for tracking engineering projects across weekly sessions
|
||||||
("Sitzungen"). Vanilla JS/HTML/CSS frontend + Node.js/Express server. No build
|
("Sitzungen"). Vanilla JS/HTML/CSS frontend + Node.js/Express server. No build
|
||||||
step, no bundler, no tests. Data is persisted as semicolon-separated CSVs
|
step, no bundler, no tests. Data is persisted in a SQLite database
|
||||||
under `sessions/`.
|
(`node:sqlite`, needs Node >= 22.13) at `sessions/projektuebersicht.db`.
|
||||||
|
|
||||||
Two HTML entrypoints, both served statically by Express:
|
Two HTML entrypoints, both served statically by Express:
|
||||||
- `Test.html` — the main app (tabs: Projektboard, Auslastung, Projekte,
|
- `Test.html` — the main app (tabs: Projektboard, Auslastung, Projekte,
|
||||||
Projektbeteiligte). Served at `/`.
|
Projektbeteiligte). Served at `/`.
|
||||||
- `project-plan.html` — per-project Gantt/TODO planning view, opened in a new
|
- `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
|
tab via `openProjectPlan()` in `js/gantt.js`. Loads the shared helper modules
|
||||||
(`globals`, `utils`, `ui`, `date-picker`, `csv`, `server`, `gantt`) plus the
|
(`globals`, `utils`, `ui`, `date-picker`, `server`, `gantt`) plus the
|
||||||
plan modules under `js/plan/` — NOT the full main module set.
|
plan modules under `js/plan/` — NOT the full main module set.
|
||||||
|
|
||||||
All UI strings/comments/CSV content are German. Preserve German in
|
All UI strings/comments are German. Preserve German in
|
||||||
user-facing text.
|
user-facing text.
|
||||||
|
|
||||||
## File structure
|
## File structure
|
||||||
|
|
||||||
```
|
```
|
||||||
.
|
.
|
||||||
├── server.js # Express server + CSV API
|
├── server.js # Express server + SQLite API
|
||||||
├── package.json # deps: express, cors only
|
├── package.json # deps: express, cors only
|
||||||
├── docker-compose.yml # at repo ROOT; build context ., dockerfile Docker/Dockerfile
|
├── docker-compose.yml # at repo ROOT; build context ., dockerfile Docker/Dockerfile
|
||||||
├── Docker/Dockerfile # node:20-alpine
|
├── Docker/Dockerfile # node:22-alpine (nötig für node:sqlite)
|
||||||
├── .dockerignore
|
├── .dockerignore
|
||||||
├── .vscode/extensions.json
|
├── .vscode/extensions.json
|
||||||
├── public/
|
├── public/
|
||||||
│ ├── Test.html # main app (served at /)
|
│ ├── Test.html # main app (served at /)
|
||||||
│ ├── js/ # shared modules (classic scripts, global fns)
|
│ ├── js/ # shared modules (classic scripts, global fns)
|
||||||
│ │ ├── globals.js # Konstanten (LPHS, ROLES, LPH-Farben, DEPARTMENTS) + db/currentDepartment
|
│ │ ├── globals.js # Konstanten (LPHS, ROLES, LPH-Farben, DEPARTMENTS) + db/currentDepartment
|
||||||
│ │ ├── utils.js # esc, uid, Datum-/Differenz-Helfer, personIdForName, peopleByRole, assignKey, weekSortKey-Quelle
|
│ │ ├── utils.js # esc, uid, Datum-/Differenz-Helfer, personIdForName, peopleByRole, assignKey, weekSortKey
|
||||||
│ │ ├── ui.js # LocalStorage-UI-State, Header-Collapse, Theme
|
│ │ ├── ui.js # LocalStorage-UI-State, Header-Collapse, Theme
|
||||||
│ │ ├── date-picker.js # Datums-/Bereichspicker-Overlays
|
│ │ ├── date-picker.js # Datums-/Bereichspicker-Overlays
|
||||||
│ │ ├── csv.js # CSV-Parser/Serializer + weekSortKey, filenameForWeek
|
│ │ ├── server.js # baseUrl, sendWeekToServer, sendPeopleToServer, blobToDataUrl
|
||||||
│ │ ├── server.js # baseUrl, sendCsvToServer, deleteCsvFromServer, blobToDataUrl
|
|
||||||
│ │ ├── store.js # save/syncAllToServer, switchDepartment, load, seed
|
│ │ ├── store.js # save/syncAllToServer, switchDepartment, load, seed
|
||||||
│ │ ├── weeks.js # Wochen-Auswahl, -Erzeugung, -Klonen, Auto-Lock, -Löschung
|
│ │ ├── weeks.js # Wochen-Auswahl, -Erzeugung, -Klonen, Auto-Lock, -Löschung
|
||||||
│ │ ├── gantt.js # GANTT-Diagramm (buildGanttHtml, openProjectPlan), geshared
|
│ │ ├── gantt.js # GANTT-Diagramm (buildGanttHtml, openProjectPlan), geshared
|
||||||
@@ -62,8 +61,8 @@ user-facing text.
|
|||||||
│ │ └── plan-schedule.js # Kritischer Pfad, Auto-Schedule, Mail-Link
|
│ │ └── plan-schedule.js # Kritischer Pfad, Auto-Schedule, Mail-Link
|
||||||
│ ├── styles.css
|
│ ├── styles.css
|
||||||
│ └── dein_bild.ico
|
│ └── dein_bild.ico
|
||||||
└── sessions/ # data; bind-mounted into Docker container
|
└── sessions/ # SQLite-DB; bind-mounted into Docker container
|
||||||
├── ELT/ HLS/ ARCH/ # one folder per department
|
└── projektuebersicht.db # node:sqlite database (WAL mode)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Running
|
## Running
|
||||||
@@ -93,31 +92,55 @@ docker compose up --build # Docker variant (compose file is at repo root)
|
|||||||
reads/writes these routes via `applyUrlRoute()`/`updateRouteUrl()` in
|
reads/writes these routes via `applyUrlRoute()`/`updateRouteUrl()` in
|
||||||
`js/ui.js` and all static assets in the HTML use **absolute** paths
|
`js/ui.js` and all static assets in the HTML use **absolute** paths
|
||||||
(`/js/...`, `/styles.css`) so they resolve on nested routes.
|
(`/js/...`, `/styles.css`) so they resolve on nested routes.
|
||||||
- `GET /load-all?department=` → `{sessions:[{filename,content}], people}` with
|
- `GET /load-all?department=` → `{weeks:[weekObj], people:[personObj]}` with the
|
||||||
`_people.csv` content in `people`; sessions sorted by filename.
|
global people list in `people`; weeks are full JSON week objects (id, label,
|
||||||
- `POST /save-csv {filename, csv, department}` → writes UTF-8 BOM CSV.
|
date, locked, people, attendance, projects), sorted by label.
|
||||||
- `POST /delete-csv {filename, department}`.
|
- `POST /save-week {department, week}` → upserts one week (JSON object, keyed by
|
||||||
|
department + label). Used by `sendWeekToServer()`.
|
||||||
|
- `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`.
|
||||||
- Valid departments hard-coded as `ELT|HLS|ARCH` in `server.js`
|
- Valid departments hard-coded as `ELT|HLS|ARCH` in `server.js`
|
||||||
(`VALID_DEPARTMENTS`); anything else silently falls back to `ELT`. Same set in
|
(`VALID_DEPARTMENTS`); anything else silently falls back to `ELT`. Same set in
|
||||||
`DEPARTMENTS` in `js/globals.js`.
|
`DEPARTMENTS` in `js/globals.js`.
|
||||||
- `safeName()` strictly validates filenames (path traversal protection);
|
- Static assets get `Cache-Control: no-store`.
|
||||||
static assets get `Cache-Control: no-store`.
|
|
||||||
- JSON body limit is 2 MB.
|
- JSON body limit is 2 MB.
|
||||||
|
|
||||||
## CSV format
|
## Persistence (SQLite)
|
||||||
|
|
||||||
- Delimiter `;`, UTF-8 with BOM. Hand-editing is possible but risky; the parser
|
- Data lives in `sessions/projektuebersicht.db` (`node:sqlite` `DatabaseSync`,
|
||||||
reconstructs objects from the fixed column layout.
|
WAL mode, `PRAGMA foreign_keys = ON`), created automatically. The schema is
|
||||||
- Week files `<Label>.csv` (e.g. `KW_30___2026.csv` — `filenameForWeek()`
|
**fully relational** (18 tables) instead of one big JSON document per week:
|
||||||
replaces non-word chars with `_`). Row layout is positional:
|
- `weeks` (department, label, week_id, date, locked; PK `(department,label)`)
|
||||||
`WOCHE` (label, date, locked flag) → `PERSONEN` (`name|role|fullName` per
|
- `week_people` / `week_people_roles` — person copies per week (`array_order`
|
||||||
column) → header row → `ANWESENHEIT` → one row per project.
|
keeps the list order, `role_order` the role array order)
|
||||||
- `_people.csv` per department: `NAME;Rollen;Voller Name;E-Mail;Abwesenheiten`
|
- `attendance` — présence per week/person
|
||||||
(absences as JSON). People are global per department; new people are copied
|
- `projects` (`array_order` = position in the JSON array, `order_value` = the
|
||||||
into the current week. Historical weeks keep their own person copies, so
|
`p.order` field, which is only unique *within a group*, not globally!)
|
||||||
deleting a global person leaves old sessions unaffected no matter what.
|
- `project_lph`, `project_assign` (person_id|role keys split into columns),
|
||||||
- Don't change the column layout without updating both `weekToCsv()` and
|
`project_aufwand` (LP effort per role), `project_aktuelle_lp`
|
||||||
`parseSessionCsv()` (`js/csv.js`), which must stay inverse of each other.
|
- `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`).
|
||||||
|
- 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.
|
||||||
|
- 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
|
## Frontend architecture
|
||||||
|
|
||||||
@@ -125,28 +148,28 @@ docker compose up --build # Docker variant (compose file is at repo root)
|
|||||||
`public/js/`; **all functions are globals on `window`**. Two HTML files include
|
`public/js/`; **all functions are globals on `window`**. Two HTML files include
|
||||||
them via `<script src>` tags; load order in the HTML defines dependencies,
|
them via `<script src>` tags; load order in the HTML defines dependencies,
|
||||||
since there is no bundler:
|
since there is no bundler:
|
||||||
- `Test.html` loads `globals → utils → ui → date-picker → csv → server →
|
- `Test.html` loads `globals → utils → ui → date-picker → server →
|
||||||
store → weeks` (shared modules), then the tab-specific folders `board →
|
store → weeks` (shared modules), then the tab-specific folders `board →
|
||||||
people → projects → load`, finally `gantt → showmode → tabs-auth → pdf →
|
people → projects → load`, finally `gantt → showmode → tabs-auth → pdf →
|
||||||
init`. See the exact list in `Test.html`.
|
init`. See the exact list in `Test.html`.
|
||||||
- `project-plan.html` loads only `globals → utils → ui → date-picker → csv →
|
- `project-plan.html` loads only `globals → utils → ui → date-picker →
|
||||||
server → gantt` plus the `js/plan/*` modules. It must NOT load modules that
|
server → gantt` plus the `js/plan/*` modules. It must NOT load modules that
|
||||||
touch main-only page elements (`store`, `weeks`, `board`, …, `init`).
|
touch main-only page elements (`store`, `weeks`, `board`, …, `init`).
|
||||||
- Shared state object `window.PlanApp` (`js/plan/plan-state.js`) holds all
|
- Shared state object `window.PlanApp` (`js/plan/plan-state.js`) holds all
|
||||||
project-plan page state (week, project, zoom level, drag/`modal`-state),
|
project-plan page state (week, project, zoom level, drag/`modal`-state),
|
||||||
`db`/`currentDepartment` live in `js/globals.js`.
|
`db`/`currentDepartment` live in `js/globals.js`.
|
||||||
- State: `let db = { weeks, currentWeek, globalPeople }` (in-memory only).
|
- State: `let db = { weeks, currentWeek, globalPeople }` (in-memory only).
|
||||||
- Project IDs are regenerated on every CSV load, so projects are addressed
|
- Project/person IDs are stable across reloads because the server splits and
|
||||||
cross-page by `department` + week `label` + `projNr` (+ readable projName path
|
reassembles week objects deterministically (JSON IDs are kept in the DB
|
||||||
segment) via URL params: `openProjectPlan()` builds
|
columns); projects are addressed cross-page by `department` + week `label` +
|
||||||
`/HLS/Projektplan/<ProjName>?weekLabel=…&projNr=…`, `findProjectForPlan`
|
`projNr` (+ readable projName path segment) via URL params:
|
||||||
resolves them.
|
`openProjectPlan()` builds `/HLS/Projektplan/<ProjName>?weekLabel=…&projNr=…`,
|
||||||
- Person IDs are stable across reloads via `personIdForName(name)` →
|
`findProjectForPlan` resolves them. Person IDs follow
|
||||||
`p_<lowercased alnum>`.
|
`personIdForName(name)` → `p_<lowercased alnum>`.
|
||||||
- `save()` debounces 500 ms then `syncAllToServer()` writes **all** weeks plus
|
- `save()` debounces 500 ms then `syncAllToServer()` writes **all** weeks (via
|
||||||
`_people.csv` — not just the changed week. Exception: `js/plan/plan-state.js`
|
`sendWeekToServer()`) plus the people list (via `sendPeopleToServer()`) — not
|
||||||
`savePlan()` bypasses `save()` and writes only the current week's CSV directly
|
just the changed week. Exception: `js/plan/plan-state.js` `savePlan()` bypasses
|
||||||
via `sendCsvToServer()`.
|
`save()` and writes only the current week directly via `sendWeekToServer()`.
|
||||||
- `switchDepartment()` cancels a pending save debounce before switching, and
|
- `switchDepartment()` cancels a pending save debounce before switching, and
|
||||||
`load()` discards stale `/load-all` responses after a department switch
|
`load()` discards stale `/load-all` responses after a department switch
|
||||||
(frozen `requestedDepartment`), so late responses can never render or persist
|
(frozen `requestedDepartment`), so late responses can never render or persist
|
||||||
@@ -185,9 +208,10 @@ docker compose up --build # Docker variant (compose file is at repo root)
|
|||||||
|
|
||||||
- `docker compose up --build` from the repo root. Compose maps `3003:3003`,
|
- `docker compose up --build` from the repo root. Compose maps `3003:3003`,
|
||||||
bind-mounts `./sessions:/app/sessions`, `restart: unless-stopped`.
|
bind-mounts `./sessions:/app/sessions`, `restart: unless-stopped`.
|
||||||
- Dockerfile: `node:20-alpine`, `npm ci --production`, runs as non-root `node`,
|
- Dockerfile: `node:22-alpine` (nötig für node:sqlite), `npm ci --production`,
|
||||||
HEALTHCHECK on `/health`. `.dockerignore` excludes `Docker/*` (except
|
runs as non-root `node`, HEALTHCHECK on `/health`. `.dockerignore` excludes
|
||||||
`Docker/Dockerfile`), `node_modules/`, `.git`, `npm-debug.log`.
|
`Docker/*` (except `Docker/Dockerfile`), `node_modules/`, `.git`,
|
||||||
|
`npm-debug.log`.
|
||||||
|
|
||||||
## Conventions & verification
|
## Conventions & verification
|
||||||
|
|
||||||
@@ -195,4 +219,4 @@ docker compose up --build # Docker variant (compose file is at repo root)
|
|||||||
`http://192.168.3.187:3000/erik/projektuebersicht.git`.
|
`http://192.168.3.187:3000/erik/projektuebersicht.git`.
|
||||||
- No linter, formatter, typecheck, or test suite. Manual verification only:
|
- No linter, formatter, typecheck, or test suite. Manual verification only:
|
||||||
start server, open `http://localhost:3003`, exercises the relevant tab,
|
start server, open `http://localhost:3003`, exercises the relevant tab,
|
||||||
confirm CSV changes in `sessions/`.
|
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
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Dieses Projekt ist eine lokale Single-Page-Webanwendung zur Verwaltung elektrote
|
|||||||
|
|
||||||
- einem Frontend in `Test.html`, `styles.css` und den JS-Modulen unter `public/js/`
|
- einem Frontend in `Test.html`, `styles.css` und den JS-Modulen unter `public/js/`
|
||||||
- einem einfachen Node.js-Server in `server.js`
|
- einem einfachen Node.js-Server in `server.js`
|
||||||
- einem Datenspeicher in CSV-Dateien unter `sessions/`
|
- einem Datenspeicher in einer SQLite-Datenbank (`node:sqlite`) unter `sessions/projektuebersicht.db`
|
||||||
- einem Docker-Setup unter `Docker/`
|
- einem Docker-Setup unter `Docker/`
|
||||||
|
|
||||||
## Datei- und Komponentenübersicht
|
## Datei- und Komponentenübersicht
|
||||||
@@ -17,9 +17,10 @@ Dieses Projekt ist eine lokale Single-Page-Webanwendung zur Verwaltung elektrote
|
|||||||
- stellt statische Dateien (`Test.html`, die Module unter `js/`, `styles.css`, etc.) bereit
|
- stellt statische Dateien (`Test.html`, die Module unter `js/`, `styles.css`, etc.) bereit
|
||||||
- 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}` → speichert/aktualisiert eine Sitzung
|
||||||
- `POST /delete-csv` → löscht eine CSV-Datei
|
- `POST /save-people {department, people}` → ersetzt die globale Personenliste einer Abteilung
|
||||||
|
- `POST /delete-week {department, label}` → löscht eine Sitzung
|
||||||
- `GET /health` → Gesundheitscheck
|
- `GET /health` → Gesundheitscheck
|
||||||
- legt das `sessions`-Verzeichnis beim Start an, falls es nicht existiert
|
- legt das `sessions`-Verzeichnis beim Start an, falls es nicht existiert
|
||||||
- beendet sich mit aussagekräftiger Fehlermeldung, wenn der Port belegt ist
|
- beendet sich mit aussagekräftiger Fehlermeldung, wenn der Port belegt ist
|
||||||
@@ -33,16 +34,15 @@ Dieses Projekt ist eine lokale Single-Page-Webanwendung zur Verwaltung elektrote
|
|||||||
|
|
||||||
- `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`):
|
||||||
`globals.js`, `utils.js`, `ui.js`, `date-picker.js`, `csv.js`, `server.js`, `store.js`, `weeks.js`,
|
`globals.js`, `utils.js`, `ui.js`, `date-picker.js`, `server.js`, `store.js`, `weeks.js`,
|
||||||
`board.js`, `people.js`, `projects.js`, `load.js`, `gantt.js`, `showmode.js`, `tabs-auth.js`, `pdf.js`,
|
`board.js`, `people.js`, `projects.js`, `load.js`, `gantt.js`, `showmode.js`, `tabs-auth.js`, `pdf.js`,
|
||||||
`init.js` sowie die Plan-Module unter `js/plan/` (`plan-state`, `plan-build-gantt`, `plan-gantt`,
|
`init.js` sowie die Plan-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
|
||||||
@@ -91,27 +91,18 @@ Dieses Projekt ist eine lokale Single-Page-Webanwendung zur Verwaltung elektrote
|
|||||||
## Speicherorte und Datenfluss
|
## Speicherorte 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) unter `sessions/projektuebersicht.db` gespeichert.
|
||||||
- Innerhalb von `sessions/` existieren Abteilungsordner:
|
- Innerhalb von `sessions/` liegen ausschließlich die SQLite-Datenbankdateien (`projektuebersicht.db` + WAL/SHM).
|
||||||
- `sessions/ELT/`
|
|
||||||
- `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?
|
### Wo wird der Server erstellt?
|
||||||
- Der Server wird in `server.js` erstellt.
|
- Der Server wird in `server.js` erstellt.
|
||||||
- `server.js` startet einen Express-Server 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 diesen Server über `window.location.origin` an.
|
- 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` ruft die Anwendung beim Speichern `/save-week` (Sitzungen) bzw. `/save-people` (globale Personen) auf.
|
||||||
- Der Server legt CSV-Dateien unter `sessions/<DEPARTMENT>/` ab.
|
- Der Server schreibt die relationalen Tabellen in `sessions/projektuebersicht.db` (eine Transaktion pro Sitzung, siehe `serializeWeek()`/`loadWeeks()` in `server.js`).
|
||||||
- Beispiel:
|
|
||||||
- `sessions/ELT/KW_32___2026.csv`
|
|
||||||
- `sessions/ELT/_people.csv`
|
|
||||||
|
|
||||||
## Betrieb
|
## Betrieb
|
||||||
|
|
||||||
@@ -141,8 +132,8 @@ 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 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 ist nur ein einfacher Client-Mechanismus im Frontend 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).
|
||||||
Vendored
BIN
Binary file not shown.
+1
-2
@@ -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
|
||||||
@@ -227,7 +227,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>
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
@@ -53,7 +53,7 @@ function formatStatus(command, value){
|
|||||||
}
|
}
|
||||||
|
|
||||||
// peopleByRole liegt jetzt im gemeinsamen Modul js/utils.js (benötigt auch
|
// peopleByRole liegt jetzt im gemeinsamen Modul js/utils.js (benötigt auch
|
||||||
// csv.js für die Projektplan-Seite, die board.js nicht lädt).
|
// die Projektplan-Seite, die board.js nicht lädt).
|
||||||
|
|
||||||
// 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`
|
||||||
@@ -85,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>`;
|
||||||
|
|||||||
@@ -1,351 +0,0 @@
|
|||||||
// -----------------------------
|
|
||||||
// Projektübersicht — CSV-Serialisierung & Parsing
|
|
||||||
// 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
|
|
||||||
// wird nach js/date-picker.js von beiden HTML-Einstiegspunkten geladen. Sie
|
|
||||||
// stellt die globalen Funktionen weekToCsv/parseSessionCsv, das Personen-CSV
|
|
||||||
// (globalPeopleToCsv/parseGlobalPeopleCsv) sowie Helfer wie weekSortKey und
|
|
||||||
// decodeWeekLabel bereit. Hauptnutzer sind js/store.js (Laden/Speichern aller
|
|
||||||
// Sitzungen), js/weeks.js (Wochen-Klonen) und js/plan/plan-state.js (savePlan),
|
|
||||||
// die die CSV-Zeichenketten über js/server.js austauschen.
|
|
||||||
|
|
||||||
// filenameForWeek: leitet aus dem Wochenlabel einen Dateinamen ab
|
|
||||||
// (alle Nicht-Wort-Zeichen werden durch _ ersetzt) und hängt .csv an.
|
|
||||||
// Parameter: label (String). Rückgabe: String wie 'KW_30___2026.csv'.
|
|
||||||
function filenameForWeek(label){
|
|
||||||
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 {
|
|
||||||
const parsed = JSON.parse(row[4]);
|
|
||||||
if(Array.isArray(parsed)) absences = parsed;
|
|
||||||
} catch(e) {
|
|
||||||
// Ignoriere fehlerhafte JSON-Daten
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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 sauber gequotetes CSV (wie es
|
|
||||||
// csvEscape/weekToCsv erzeugen). Semikolons innerhalb eines quoted Feldes sind
|
|
||||||
// Feldinhalt und kein Trennzeichen – so bleibt weekToCsv()/parseSessionCsv()
|
|
||||||
// auch bei Status-Texten, die mit ";" enden, invers.
|
|
||||||
// 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 === ';' || 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 {
|
|
||||||
const parsed = JSON.parse(row[planIdx]);
|
|
||||||
if(parsed && typeof parsed === 'object') plan = parsed;
|
|
||||||
} catch(e) {
|
|
||||||
console.warn('Plan-Daten für Projekt konnten nicht geparst werden:', nr, name, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let aufwandProLP;
|
|
||||||
if(aufwandIdx >= 0 && row[aufwandIdx]){
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(row[aufwandIdx]);
|
|
||||||
if(parsed && typeof parsed === 'object') aufwandProLP = parsed;
|
|
||||||
} catch(e) {
|
|
||||||
console.warn('LP-Aufwand für Projekt konnten nicht geparst werden:', nr, name, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
||||||
|
|||||||
@@ -114,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 = [];
|
||||||
|
|||||||
@@ -66,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.';
|
||||||
@@ -90,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);
|
||||||
@@ -179,18 +179,15 @@ window.PlanApp = {
|
|||||||
// Speichern: nur diese Sitzung; globale Personenliste nicht überschreiben
|
// Speichern: nur diese Sitzung; globale Personenliste nicht überschreiben
|
||||||
function savePlan(){
|
function savePlan(){
|
||||||
if(!app.week || !app.project) return;
|
if(!app.week || !app.project) return;
|
||||||
const filename = filenameForWeek(app.week.label);
|
sendWeekToServer(app.week).catch(err => console.error('Speichern fehlgeschlagen:', err));
|
||||||
const csv = weekToCsv(app.week);
|
|
||||||
sendCsvToServer(filename, csv).catch(err => console.error('Speichern fehlgeschlagen:', err));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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, aber nur wenn sie nicht leer ist.
|
||||||
function saveGlobalPeople(){
|
function saveGlobalPeople(){
|
||||||
const people = db.globalPeople[app.department] || [];
|
const people = db.globalPeople[app.department] || [];
|
||||||
if(people.length > 0){
|
if(people.length > 0){
|
||||||
const peopleCsv = globalPeopleToCsv(people);
|
sendPeopleToServer(people).catch(err => console.error('Speichern Personen fehlgeschlagen:', err));
|
||||||
sendCsvToServer('_people.csv', peopleCsv).catch(err => console.error('Speichern Personen fehlgeschlagen:', err));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,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); });
|
||||||
@@ -71,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}" ondragover="handleDragOver(event)" ondrop="handleDrop(event)">`;
|
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)" 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)">
|
||||||
|
|||||||
+30
-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,36 @@ 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 Server-Antwort.
|
||||||
function sendCsvToServer(filename, csvContent){
|
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})
|
||||||
}).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
|
// sendPeopleToServer: POSTet die globale Personenliste einer Abteilung als
|
||||||
// Parameter: filename (String). Rückgabe: Promise mit Server-Antwort.
|
// JSON-Array an <baseUrl>/save-people. Parameter: people (Array).
|
||||||
function deleteCsvFromServer(filename){
|
// Rückgabe: Promise mit Server-Antwort.
|
||||||
|
function sendPeopleToServer(people){
|
||||||
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() + '/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(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteWeekFromServer: fordert das Löschen einer Woche über POST /delete-week an
|
||||||
|
// Parameter: label (String, das Wochen-Label). Rückgabe: Promise mit Server-Antwort.
|
||||||
|
function deleteWeekFromServer(label){
|
||||||
|
if(!window.fetch) return Promise.reject(new Error('fetch nicht verfügbar'));
|
||||||
|
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(); });
|
||||||
}
|
}
|
||||||
+32
-31
@@ -1,18 +1,20 @@
|
|||||||
// -----------------------------
|
// -----------------------------
|
||||||
// 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: synchronisiert die aktuellen Daten zum Server.
|
||||||
// Jede Sitzung wird als eigene CSV-Datei gespeichert. Personen und Rollen werden
|
// Jede Sitzung wird als eigenes JSON-Dokument gespeichert. Personen und Rollen
|
||||||
// in der Sitzungscsv selbst abgelegt.
|
// werden in der Sitzung selbst abgelegt.
|
||||||
function save(){
|
function save(){
|
||||||
scheduleSessionSync();
|
scheduleSessionSync();
|
||||||
}
|
}
|
||||||
@@ -25,31 +27,28 @@ 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 die gesamte Datenbank als JSON-Dokumente zum Server.
|
||||||
// Zuerst wird die globale Personenliste (`_people.csv`) gespeichert, danach jede
|
// Zuerst wird die globale Personenliste (`save-people`) gespeichert, danach jede
|
||||||
// Sitzung einzeln über `filenameForWeek()`/`weekToCsv()` und `sendCsvToServer()`.
|
// Sitzung einzeln über `sendWeekToServer()`.
|
||||||
// Gibt nichts zurück; Fehler werden nur per console.error protokolliert.
|
// Gibt nichts zurück; Fehler werden nur per console.error protokolliert.
|
||||||
function syncAllToServer(){
|
function syncAllToServer(){
|
||||||
// Zuerst globale Personenliste speichern
|
// Zuerst globale Personenliste speichern
|
||||||
saveGlobalPeopleToServer();
|
saveGlobalPeopleToServer();
|
||||||
// Dann jede Sitzung speichern
|
// Dann jede Sitzung speichern
|
||||||
db.weeks.forEach(w => {
|
db.weeks.forEach(w => {
|
||||||
const filename = filenameForWeek(w.label);
|
sendWeekToServer(w)
|
||||||
const csv = weekToCsv(w);
|
.then(res => { if(res && res.ok) console.log('Sitzung gespeichert:', w.label); })
|
||||||
sendCsvToServer(filename, csv)
|
.catch(err => { console.error('Sitzung konnte nicht gespeichert werden:', w.label, err.message || err); });
|
||||||
.then(res => { if(res && res.ok) console.log('Sitzung gespeichert:', filename); })
|
|
||||||
.catch(err => { console.error('Sitzung konnte nicht gespeichert werden:', filename, err.message || err); });
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// saveGlobalPeopleToServer: serialisiert die globale Personenliste der aktuellen
|
// saveGlobalPeopleToServer: speichert die globale Personenliste der aktuellen
|
||||||
// Abteilung per `globalPeopleToCsv()` und speichert sie als `_people.csv`.
|
// Abteilung über `sendPeopleToServer()` (JSON-Array).
|
||||||
// Leere Personenlisten werden übersprungen (kein Schreibvorgang).
|
// Leere Personenlisten werden übersprungen (kein Schreibvorgang).
|
||||||
function saveGlobalPeopleToServer(){
|
function saveGlobalPeopleToServer(){
|
||||||
const people = db.globalPeople[currentDepartment] || [];
|
const people = db.globalPeople[currentDepartment] || [];
|
||||||
if(people.length === 0) return;
|
if(people.length === 0) return;
|
||||||
const csv = globalPeopleToCsv(people);
|
sendPeopleToServer(people)
|
||||||
sendCsvToServer('_people.csv', csv)
|
|
||||||
.then(res => { if(res && res.ok) console.log('Globale Personenliste gespeichert'); })
|
.then(res => { if(res && res.ok) console.log('Globale Personenliste gespeichert'); })
|
||||||
.catch(err => { console.error('Globale Personenliste konnte nicht gespeichert werden:', err.message || err); });
|
.catch(err => { console.error('Globale Personenliste konnte nicht gespeichert werden:', err.message || err); });
|
||||||
}
|
}
|
||||||
@@ -62,7 +61,7 @@ function switchDepartment(dept){
|
|||||||
if(!DEPARTMENTS[dept]) return;
|
if(!DEPARTMENTS[dept]) return;
|
||||||
// Laufenden Speicher-Debounce der alten Abteilung verwerfen, damit dessen
|
// Laufenden Speicher-Debounce der alten Abteilung verwerfen, damit dessen
|
||||||
// Sync nicht nach dem Reset mit dem neuen currentDepartment feuert und die
|
// Sync nicht nach dem Reset mit dem neuen currentDepartment feuert und die
|
||||||
// CSV der falschen Abteilung schreibt.
|
// Daten der falschen Abteilung schreibt.
|
||||||
if(saveSyncTimer){ clearTimeout(saveSyncTimer); saveSyncTimer = null; }
|
if(saveSyncTimer){ clearTimeout(saveSyncTimer); saveSyncTimer = null; }
|
||||||
currentDepartment = dept;
|
currentDepartment = dept;
|
||||||
saveUiState();
|
saveUiState();
|
||||||
@@ -84,11 +83,11 @@ 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(){
|
||||||
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.');
|
||||||
@@ -106,18 +105,20 @@ function load(){
|
|||||||
.then(data => {
|
.then(data => {
|
||||||
if(currentDepartment !== requestedDepartment) return;
|
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 {
|
||||||
|
|||||||
+14
-3
@@ -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.
|
||||||
@@ -38,8 +38,7 @@ function sortGroups(arr){
|
|||||||
// peopleByRole: gruppiert Personen nach Rolle zurück in ein Objekt.
|
// peopleByRole: gruppiert Personen nach Rolle zurück in ein Objekt.
|
||||||
// Parameter `target`: Woche(n) mit `people`-Array (oder undefined → aktuelle
|
// Parameter `target`: Woche(n) mit `people`-Array (oder undefined → aktuelle
|
||||||
// Woche). Rückgabe: Objekt `{ Rolle: [{person, role}] }` mit Deduplizierung.
|
// Woche). Rückgabe: Objekt `{ Rolle: [{person, role}] }` mit Deduplizierung.
|
||||||
// Liegt im gemeinsamen Modul, weil auch csv.js (weekToCsv für savePlan auf der
|
// Liegt im gemeinsamen Modul, weil board/load/pdf auf sie zugreifen.
|
||||||
// Projektplan-Seite) und pdf.js darauf zugreifen.
|
|
||||||
function peopleByRole(target){
|
function peopleByRole(target){
|
||||||
const m = {}; ROLES.forEach(r=>m[r]=[]);
|
const m = {}; ROLES.forEach(r=>m[r]=[]);
|
||||||
const people = [];
|
const people = [];
|
||||||
@@ -65,6 +64,18 @@ function peopleByRole(target){
|
|||||||
return m;
|
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'.
|
||||||
|
|||||||
+10
-10
@@ -124,9 +124,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 +138,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
@@ -1,19 +1,32 @@
|
|||||||
/*
|
/*
|
||||||
* Express-Server der Projektübersicht: liefert die statischen Frontend-Dateien aus
|
* Express-Server der Projektübersicht: liefert die statischen Frontend-Dateien aus
|
||||||
* public/ (Test.html unter "/") aus und stellt den Frontend-Modulen unter public/js/
|
* public/ (Test.html unter "/") und stellt den Frontend-Modulen unter public/js/
|
||||||
* eine CSV-API zum Laden und Speichern der Sitzungsdaten in sessions/ bereit.
|
* eine JSON-API zum Laden und Speichern der Sitzungsdaten bereit. Die Daten liegen
|
||||||
|
* in einer SQLite-Datenbank (node:sqlite, Node >= 22.13) unter sessions/projektuebersicht.db.
|
||||||
|
*
|
||||||
|
* Das Schema ist voll relational normalisiert: statt eines grossen JSON-Dokuments
|
||||||
|
* pro Woche gibt es eigene Tabellen für Wochen-Metadaten, Wochen-Personen, Rollen,
|
||||||
|
* Anwesenheit, Projekte, LPH-Werte, Projekt-Zuordnungen, Projektpläne, TODOs,
|
||||||
|
* Meilensteine, Abhängigkeiten sowie die globalen Personen inkl. Rollen und
|
||||||
|
* Abwesenheiten. Die HTTP-API bleibt davon unberührt: Das Frontend sendet und
|
||||||
|
* erhält weiterhin komplette Wochen- und Personen-Objekte (JSON); der Server
|
||||||
|
* verteilt sie beim Speichern auf die Tabellen und setzt sie beim Laden wieder
|
||||||
|
* Zusammen (Serializer/Deserializer).
|
||||||
|
*
|
||||||
|
* Eine bestehende Datenbank aus dem alten Ein-Dokument-pro-Woche-Format wird
|
||||||
|
* automatisch auf das relationale Schema umgestellt.
|
||||||
|
*
|
||||||
* Standardport ist 3003, überschreibbar über die Umgebungsvariable PORT.
|
* Standardport ist 3003, überschreibbar über die Umgebungsvariable PORT.
|
||||||
* Endpunkte: GET /, GET /health, GET /load-all, POST /save-csv, POST /delete-csv.
|
* Endpunkte: GET /, GET /health, GET /load-all, POST /save-week, POST /save-people,
|
||||||
* Die Daten werden als UTF-8-CSV mit BOM geschrieben; ungültige Abteilungen fallen
|
* POST /delete-week. Ungültige Abteilungen fallen auf ELT zurück.
|
||||||
* auf ELT zurück, Dateinamen werden über safeName() streng validiert.
|
|
||||||
*/
|
*/
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const { DatabaseSync } = require('node:sqlite');
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
// Wichtige Konstanten: UTF-8-BOM für CSV-Dateien, Serverport (PORT-Env) und die gültigen Abteilungsordner
|
// Wichtige Konstanten: Serverport (PORT-Env) und die gültigen Abteilungsordner
|
||||||
const BOM = Buffer.from([0xEF, 0xBB, 0xBF]);
|
|
||||||
const PORT = process.env.PORT || 3003;
|
const PORT = process.env.PORT || 3003;
|
||||||
const VALID_DEPARTMENTS = new Set(['ELT','HLS','ARCH']);
|
const VALID_DEPARTMENTS = new Set(['ELT','HLS','ARCH']);
|
||||||
|
|
||||||
@@ -30,87 +43,743 @@ app.use(express.static(path.join(__dirname, 'public'), {
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Datenverzeichnis sessions/ bei Bedarf anlegen
|
// Datenverzeichnis sessions/ bei Bedarf anlegen (bind-mounted in Docker, enthält die DB)
|
||||||
const SESSIONS_DIR = path.join(__dirname, 'sessions');
|
const SESSIONS_DIR = path.join(__dirname, 'sessions');
|
||||||
if(!fs.existsSync(SESSIONS_DIR)) fs.mkdirSync(SESSIONS_DIR, { recursive: true });
|
if(!fs.existsSync(SESSIONS_DIR)) fs.mkdirSync(SESSIONS_DIR, { recursive: true });
|
||||||
|
|
||||||
// Erlaubt ausschliesslich sichere CSV-Dateinamen im Format <Name>.csv
|
// SQLite-Datenbank öffnen (wird angelegt, falls nicht vorhanden). Der Pfad ist
|
||||||
function safeName(name){
|
// über die Umgebungsvariable DB_PATH übersteuerbar (für Tests/Backups), sonst
|
||||||
const raw = String(name || '');
|
// standardmäßig sessions/projektuebersicht.db.
|
||||||
// Pfad-Trennzeichen und Verzeichnisnavigation ablehnen
|
const DB_PATH = process.env.DB_PATH ? path.resolve(process.env.DB_PATH) : path.join(SESSIONS_DIR, 'projektuebersicht.db');
|
||||||
if(/[\\/]|\.\./.test(raw)) return null;
|
const db = new DatabaseSync(DB_PATH);
|
||||||
const base = path.basename(raw);
|
db.exec('PRAGMA journal_mode = WAL;');
|
||||||
if(!base || base.toLowerCase() === '.csv') return null;
|
db.exec('PRAGMA foreign_keys = ON;');
|
||||||
const safe = base.replace(/[^a-zA-Z0-9_\-\.]/g,'_');
|
|
||||||
if(!/^([a-zA-Z0-9_\-]+)\.csv$/i.test(safe)) return null;
|
// ---------- Schema (voll relational normalisiert) ----------
|
||||||
return safe;
|
|
||||||
|
// Der serialisierte Datenbestand einer Woche besteht aus folgenden Entitäten:
|
||||||
|
// - weeks: Metadaten (id, label, date, locked)
|
||||||
|
// - week_people / week_people_roles: personen-Kopien pro Woche
|
||||||
|
// - attendance: Anwesenheit pro Woche/Person
|
||||||
|
// - projects: Projekt-Metadaten pro Woche
|
||||||
|
// - project_lph: LPH-Fortschrittswerte pro Projekt
|
||||||
|
// - project_assign: Zuordnungen (Haupt/Optional) pro Projekt/Person/Rolle
|
||||||
|
// - project_plan: Plan-Kopf (start, end, progress) pro Projekt
|
||||||
|
// - plan_people: im Plan beteiligte Personen
|
||||||
|
// - plan_todos / plan_todo_assignees / plan_todo_deps: TODO-Listen
|
||||||
|
// - plan_milestones / plan_milestone_deps: Meilensteine
|
||||||
|
// - people / person_roles / person_absences: globale Personen pro Abteilung
|
||||||
|
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS weeks (
|
||||||
|
department TEXT NOT NULL,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
week_id TEXT NOT NULL DEFAULT '',
|
||||||
|
date TEXT NOT NULL DEFAULT '',
|
||||||
|
locked INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (department, label)
|
||||||
|
)`);
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS week_people (
|
||||||
|
department TEXT NOT NULL,
|
||||||
|
week_label TEXT NOT NULL,
|
||||||
|
person_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
full_name TEXT NOT NULL DEFAULT '',
|
||||||
|
array_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (department, week_label, person_id)
|
||||||
|
)`);
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS week_people_roles (
|
||||||
|
department TEXT NOT NULL,
|
||||||
|
week_label TEXT NOT NULL,
|
||||||
|
person_id TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
role_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (department, week_label, person_id, role)
|
||||||
|
)`);
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS attendance (
|
||||||
|
department TEXT NOT NULL,
|
||||||
|
week_label TEXT NOT NULL,
|
||||||
|
person_id TEXT NOT NULL,
|
||||||
|
present INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (department, week_label, person_id)
|
||||||
|
)`);
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS projects (
|
||||||
|
department TEXT NOT NULL,
|
||||||
|
week_label TEXT NOT NULL,
|
||||||
|
project_id TEXT NOT NULL,
|
||||||
|
nr TEXT NOT NULL DEFAULT '',
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL DEFAULT '',
|
||||||
|
"group" TEXT NOT NULL DEFAULT '',
|
||||||
|
array_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
order_value INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (department, week_label, project_id)
|
||||||
|
)`);
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS project_lph (
|
||||||
|
department TEXT NOT NULL,
|
||||||
|
week_label TEXT NOT NULL,
|
||||||
|
project_id TEXT NOT NULL,
|
||||||
|
lph TEXT NOT NULL,
|
||||||
|
value TEXT NOT NULL DEFAULT '',
|
||||||
|
PRIMARY KEY (department, week_label, project_id, lph)
|
||||||
|
)`);
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS project_assign (
|
||||||
|
department TEXT NOT NULL,
|
||||||
|
week_label TEXT NOT NULL,
|
||||||
|
project_id TEXT NOT NULL,
|
||||||
|
person_id TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT '',
|
||||||
|
state INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (department, week_label, project_id, person_id, role)
|
||||||
|
)`);
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS project_aufwand (
|
||||||
|
department TEXT NOT NULL,
|
||||||
|
week_label TEXT NOT NULL,
|
||||||
|
project_id TEXT NOT NULL,
|
||||||
|
lph TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT '',
|
||||||
|
value REAL NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (department, week_label, project_id, lph, role)
|
||||||
|
)`);
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS project_aktuelle_lp (
|
||||||
|
department TEXT NOT NULL,
|
||||||
|
week_label TEXT NOT NULL,
|
||||||
|
project_id TEXT NOT NULL,
|
||||||
|
lph TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (department, week_label, project_id, lph)
|
||||||
|
)`);
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS project_plan (
|
||||||
|
department TEXT NOT NULL,
|
||||||
|
week_label TEXT NOT NULL,
|
||||||
|
project_id TEXT NOT NULL,
|
||||||
|
start TEXT NOT NULL DEFAULT '',
|
||||||
|
end TEXT NOT NULL DEFAULT '',
|
||||||
|
progress INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (department, week_label, project_id)
|
||||||
|
)`);
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS plan_people (
|
||||||
|
department TEXT NOT NULL,
|
||||||
|
week_label TEXT NOT NULL,
|
||||||
|
project_id TEXT NOT NULL,
|
||||||
|
person_id TEXT NOT NULL,
|
||||||
|
array_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (department, week_label, project_id, person_id)
|
||||||
|
)`);
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS plan_todos (
|
||||||
|
department TEXT NOT NULL,
|
||||||
|
week_label TEXT NOT NULL,
|
||||||
|
project_id TEXT NOT NULL,
|
||||||
|
todo_id TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL DEFAULT '',
|
||||||
|
start TEXT NOT NULL DEFAULT '',
|
||||||
|
end TEXT NOT NULL DEFAULT '',
|
||||||
|
done INTEGER NOT NULL DEFAULT 0,
|
||||||
|
progress INTEGER NOT NULL DEFAULT 0,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (department, week_label, project_id, todo_id)
|
||||||
|
)`);
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS plan_todo_assignees (
|
||||||
|
department TEXT NOT NULL,
|
||||||
|
week_label TEXT NOT NULL,
|
||||||
|
project_id TEXT NOT NULL,
|
||||||
|
todo_id TEXT NOT NULL,
|
||||||
|
person_id TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (department, week_label, project_id, todo_id, person_id)
|
||||||
|
)`);
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS plan_todo_deps (
|
||||||
|
department TEXT NOT NULL,
|
||||||
|
week_label TEXT NOT NULL,
|
||||||
|
project_id TEXT NOT NULL,
|
||||||
|
todo_id TEXT NOT NULL,
|
||||||
|
depends_on_id TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (department, week_label, project_id, todo_id, depends_on_id)
|
||||||
|
)`);
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS plan_milestones (
|
||||||
|
department TEXT NOT NULL,
|
||||||
|
week_label TEXT NOT NULL,
|
||||||
|
project_id TEXT NOT NULL,
|
||||||
|
milestone_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
date TEXT NOT NULL DEFAULT '',
|
||||||
|
position TEXT NOT NULL DEFAULT '',
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (department, week_label, project_id, milestone_id)
|
||||||
|
)`);
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS plan_milestone_deps (
|
||||||
|
department TEXT NOT NULL,
|
||||||
|
week_label TEXT NOT NULL,
|
||||||
|
project_id TEXT NOT NULL,
|
||||||
|
milestone_id TEXT NOT NULL,
|
||||||
|
depends_on_id TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (department, week_label, project_id, milestone_id, depends_on_id)
|
||||||
|
)`);
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS people (
|
||||||
|
department TEXT NOT NULL,
|
||||||
|
person_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
full_name TEXT NOT NULL DEFAULT '',
|
||||||
|
email TEXT NOT NULL DEFAULT '',
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (department, person_id)
|
||||||
|
)`);
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS person_roles (
|
||||||
|
department TEXT NOT NULL,
|
||||||
|
person_id TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
role_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (department, person_id, role)
|
||||||
|
)`);
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS person_absences (
|
||||||
|
department TEXT NOT NULL,
|
||||||
|
person_id TEXT NOT NULL,
|
||||||
|
absence TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (department, person_id, absence)
|
||||||
|
)`);
|
||||||
|
|
||||||
|
// Erlaubt ausschliesslich die gültigen Abteilungen (ELT|HLS|ARCH, sonst ELT)
|
||||||
|
function departmentKey(department){
|
||||||
|
return (typeof department === 'string' && VALID_DEPARTMENTS.has(department)) ? department : 'ELT';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Liefert den Zielordner für eine Abteilung (nur ELT|HLS|ARCH, sonst ELT) und legt ihn bei Bedarf an
|
// ---------- Migration: altes Ein-Dokument-Format -> relationales Schema ----------
|
||||||
function departmentDir(department){
|
|
||||||
const dept = (typeof department === 'string' && VALID_DEPARTMENTS.has(department)) ? department : 'ELT';
|
// hasLegacyWeeksTable: erkennt das alte Schema an der Daten-Spalte in `weeks`.
|
||||||
const dir = path.join(SESSIONS_DIR, dept);
|
function hasLegacyWeeksTable(){
|
||||||
if(!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
const cols = db.prepare("SELECT name FROM pragma_table_info('weeks') WHERE name = 'data'").all();
|
||||||
return dir;
|
return cols.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route: Speichert eine CSV-Datei (mit UTF-8-BOM) für eine Abteilung im sessions/-Ordner
|
// serializeWeek: zerlegt ein Wochen-Objekt (JSON vom Frontend) in die relationalen
|
||||||
app.post('/save-csv', (req, res) => {
|
// Tabellen. Wird in einer Transaktion vom Aufrufer verwendet (deleteWeekData +
|
||||||
const { filename, csv, department } = req.body || {};
|
// serializeWeek). Ersetzt alle Zeilen der Woche.
|
||||||
if(!filename || typeof csv !== 'string') return res.status(400).json({ ok: false, error: 'Missing filename or csv' });
|
function serializeWeek(department, week){
|
||||||
const safe = safeName(filename);
|
const weekLabel = String(week.label || '');
|
||||||
if(!safe) return res.status(400).json({ ok: false, error: 'Invalid filename' });
|
const upsertWeek = db.prepare(`INSERT INTO weeks (department, label, week_id, date, locked) VALUES (?, ?, ?, ?, ?)
|
||||||
const dir = departmentDir(department);
|
ON CONFLICT(department, label) DO UPDATE SET week_id = excluded.week_id, date = excluded.date, locked = excluded.locked`);
|
||||||
const out = path.join(dir, safe);
|
upsertWeek.run(department, weekLabel, String(week.id || ''), String(week.date || ''), week.locked ? 1 : 0);
|
||||||
const data = Buffer.concat([BOM, Buffer.from(csv, 'utf8')]);
|
|
||||||
fs.writeFile(out, data, (err) => {
|
const insWeekPerson = db.prepare(`INSERT INTO week_people (department, week_label, person_id, name, full_name, array_order)
|
||||||
if(err){ console.error('Write error', err); return res.status(500).json({ ok:false, error: String(err) }); }
|
VALUES (?, ?, ?, ?, ?, ?)`);
|
||||||
console.log('Saved CSV:', out);
|
const insWeekPersonRole = db.prepare(`INSERT OR IGNORE INTO week_people_roles (department, week_label, person_id, role, role_order)
|
||||||
return res.json({ ok: true, path: out });
|
VALUES (?, ?, ?, ?, ?)`);
|
||||||
|
const insAttendance = db.prepare(`INSERT OR REPLACE INTO attendance (department, week_label, person_id, present)
|
||||||
|
VALUES (?, ?, ?, ?)`);
|
||||||
|
|
||||||
|
(Array.isArray(week.people) ? week.people : []).forEach((p, idx) => {
|
||||||
|
insWeekPerson.run(department, weekLabel, String(p.id || ''), String(p.name || ''), String(p.fullName || ''), idx);
|
||||||
|
(Array.isArray(p.role) ? p.role : []).forEach((r, ridx) => insWeekPersonRole.run(department, weekLabel, String(p.id || ''), String(r || ''), ridx));
|
||||||
|
});
|
||||||
|
const att = week.attendance || {};
|
||||||
|
Object.keys(att).forEach(pid => {
|
||||||
|
insAttendance.run(department, weekLabel, pid, att[pid] ? 1 : 0);
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
// Route: Löscht eine CSV-Datei einer Abteilung aus dem sessions/-Ordner (fehlende Datei wird toleriert)
|
const insProject = db.prepare(`INSERT INTO projects (department, week_label, project_id, nr, name, status, "group", array_order, order_value)
|
||||||
app.post('/delete-csv', (req, res) => {
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
||||||
const { filename, department } = req.body || {};
|
const insLph = db.prepare(`INSERT OR REPLACE INTO project_lph (department, week_label, project_id, lph, value)
|
||||||
if(!filename) return res.status(400).json({ ok: false, error: 'Missing filename' });
|
VALUES (?, ?, ?, ?, ?)`);
|
||||||
const safe = safeName(filename);
|
const insAssign = db.prepare(`INSERT OR REPLACE INTO project_assign (department, week_label, project_id, person_id, role, state)
|
||||||
if(!safe) return res.status(400).json({ ok: false, error: 'Invalid filename' });
|
VALUES (?, ?, ?, ?, ?, ?)`);
|
||||||
const dir = departmentDir(department);
|
const insAufwand = db.prepare(`INSERT OR REPLACE INTO project_aufwand (department, week_label, project_id, lph, role, value)
|
||||||
const out = path.join(dir, safe);
|
VALUES (?, ?, ?, ?, ?, ?)`);
|
||||||
fs.unlink(out, (err) => {
|
const insAktuelleLp = db.prepare(`INSERT OR IGNORE INTO project_aktuelle_lp (department, week_label, project_id, lph)
|
||||||
if(err){
|
VALUES (?, ?, ?, ?)`);
|
||||||
if(err.code === 'ENOENT') {
|
const insPlan = db.prepare(`INSERT OR REPLACE INTO project_plan (department, week_label, project_id, start, end, progress)
|
||||||
console.log('CSV-Datei nicht gefunden:', out);
|
VALUES (?, ?, ?, ?, ?, ?)`);
|
||||||
return res.json({ ok: true, missing: true });
|
const insPlanPerson = db.prepare(`INSERT OR IGNORE INTO plan_people (department, week_label, project_id, person_id, array_order)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`);
|
||||||
|
const insTodo = db.prepare(`INSERT INTO plan_todos (department, week_label, project_id, todo_id, title, start, end, done, progress, sort_order)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
||||||
|
const insTodoAssignee = db.prepare(`INSERT OR IGNORE INTO plan_todo_assignees (department, week_label, project_id, todo_id, person_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`);
|
||||||
|
const insTodoDep = db.prepare(`INSERT OR IGNORE INTO plan_todo_deps (department, week_label, project_id, todo_id, depends_on_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`);
|
||||||
|
const insMilestone = db.prepare(`INSERT INTO plan_milestones (department, week_label, project_id, milestone_id, name, date, position, sort_order)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
|
||||||
|
const insMilestoneDep = db.prepare(`INSERT OR IGNORE INTO plan_milestone_deps (department, week_label, project_id, milestone_id, depends_on_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`);
|
||||||
|
|
||||||
|
(Array.isArray(week.projects) ? week.projects : []).forEach((p, idx) => {
|
||||||
|
const projId = String(p.id || '');
|
||||||
|
insProject.run(department, weekLabel, projId, String(p.nr || ''), String(p.name || ''),
|
||||||
|
String(p.status || ''), String(p.group || ''), idx, typeof p.order === 'number' ? p.order : idx);
|
||||||
|
const lph = p.lph || {};
|
||||||
|
Object.keys(lph).forEach(l => insLph.run(department, weekLabel, projId, String(l || ''), String(lph[l] === undefined ? '' : lph[l])));
|
||||||
|
const assign = p.assign || {};
|
||||||
|
Object.keys(assign).forEach(key => {
|
||||||
|
// Key-Format: "personId|Rolle"
|
||||||
|
const sep = key.indexOf('|');
|
||||||
|
const pid = sep >= 0 ? key.slice(0, sep) : key;
|
||||||
|
const role = sep >= 0 ? key.slice(sep + 1) : '';
|
||||||
|
insAssign.run(department, weekLabel, projId, pid, role, Number(assign[key]) || 0);
|
||||||
|
});
|
||||||
|
const aufwand = p.aufwandProLP || {};
|
||||||
|
Object.keys(aufwand).forEach(lph => {
|
||||||
|
const roles = aufwand[lph] || {};
|
||||||
|
Object.keys(roles).forEach(role => {
|
||||||
|
const v = Number(roles[role]);
|
||||||
|
if(!isNaN(v)) insAufwand.run(department, weekLabel, projId, String(lph || ''), String(role || ''), v);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
(Array.isArray(p.aktuelleLP) ? p.aktuelleLP : []).forEach(lph => {
|
||||||
|
insAktuelleLp.run(department, weekLabel, projId, String(lph || ''));
|
||||||
|
});
|
||||||
|
const plan = p.plan || null;
|
||||||
|
if(plan){
|
||||||
|
insPlan.run(department, weekLabel, projId, String(plan.start || ''), String(plan.end || ''), Number(plan.progress) || 0);
|
||||||
|
(Array.isArray(plan.people) ? plan.people : []).forEach((pid, pidx) => insPlanPerson.run(department, weekLabel, projId, String(pid || ''), pidx));
|
||||||
|
(Array.isArray(plan.todos) ? plan.todos : []).forEach((t, tidx) => {
|
||||||
|
insTodo.run(department, weekLabel, projId, String(t.id || ''), String(t.title || ''),
|
||||||
|
String(t.start || ''), String(t.end || ''), t.done ? 1 : 0, Number(t.progress) || 0, tidx);
|
||||||
|
(Array.isArray(t.assignees) ? t.assignees : []).forEach(pid => insTodoAssignee.run(department, weekLabel, projId, String(t.id || ''), String(pid || '')));
|
||||||
|
(Array.isArray(t.dependsOn) ? t.dependsOn : []).forEach(did => insTodoDep.run(department, weekLabel, projId, String(t.id || ''), String(did || '')));
|
||||||
|
});
|
||||||
|
(Array.isArray(plan.milestones) ? plan.milestones : []).forEach((m, midx) => {
|
||||||
|
insMilestone.run(department, weekLabel, projId, String(m.id || ''), String(m.name || ''), String(m.date || ''), String(m.position || ''), midx);
|
||||||
|
(Array.isArray(m.dependsOn) ? m.dependsOn : []).forEach(did => insMilestoneDep.run(department, weekLabel, projId, String(m.id || ''), String(did || '')));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
console.error('Delete error', err);
|
});
|
||||||
return res.status(500).json({ ok:false, error: String(err) });
|
}
|
||||||
|
|
||||||
|
// deleteWeekData: entfernt alle relationalen Zeilen einer Woche (Abteilung + Label)
|
||||||
|
function deleteWeekData(department, weekLabel){
|
||||||
|
const tables = ['week_people_roles','week_people','attendance','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','projects'];
|
||||||
|
tables.forEach(t => {
|
||||||
|
db.prepare(`DELETE FROM ${t} WHERE department = ? AND week_label = ?`).run(department, weekLabel);
|
||||||
|
});
|
||||||
|
// Die Wochen-Tabelle selbst nutzt `label` statt `week_label`
|
||||||
|
db.prepare('DELETE FROM weeks WHERE department = ? AND label = ?').run(department, weekLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveWeekRelational: speichert eine Woche vollständig relational (Transaktion)
|
||||||
|
function saveWeekRelational(department, week){
|
||||||
|
const weekLabel = String(week.label || '');
|
||||||
|
const tx = db.exec('BEGIN');
|
||||||
|
try {
|
||||||
|
deleteWeekData(department, weekLabel);
|
||||||
|
serializeWeek(department, week);
|
||||||
|
db.exec('COMMIT');
|
||||||
|
} catch(err){
|
||||||
|
db.exec('ROLLBACK');
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
console.log('Deleted CSV:', out);
|
}
|
||||||
|
|
||||||
|
// serializeGlobalPeople: zerlegt die globale Personenliste (JSON-Array) in die
|
||||||
|
// Tabellen people/person_roles/person_absences. Ersetzt die gesamte Abteilungsliste.
|
||||||
|
function serializeGlobalPeople(department, people){
|
||||||
|
const del = db.prepare(`DELETE FROM people WHERE department = ?`);
|
||||||
|
const delRoles = db.prepare(`DELETE FROM person_roles WHERE department = ?`);
|
||||||
|
const delAbsences = db.prepare(`DELETE FROM person_absences WHERE department = ?`);
|
||||||
|
const tx = db.exec('BEGIN');
|
||||||
|
try {
|
||||||
|
del.run(department); delRoles.run(department); delAbsences.run(department);
|
||||||
|
const insPerson = db.prepare(`INSERT INTO people (department, person_id, name, full_name, email, sort_order)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`);
|
||||||
|
const insRole = db.prepare(`INSERT OR IGNORE INTO person_roles (department, person_id, role, role_order) VALUES (?, ?, ?, ?)`);
|
||||||
|
const insAbsence = db.prepare(`INSERT OR IGNORE INTO person_absences (department, person_id, absence) VALUES (?, ?, ?)`);
|
||||||
|
people.forEach((p, idx) => {
|
||||||
|
insPerson.run(department, String(p.id || ''), String(p.name || ''), String(p.fullName || ''), String(p.email || ''), idx);
|
||||||
|
(Array.isArray(p.role) ? p.role : []).forEach((r, ridx) => insRole.run(department, String(p.id || ''), String(r || ''), ridx));
|
||||||
|
(Array.isArray(p.absences) ? p.absences : []).forEach(a => insAbsence.run(department, String(p.id || ''), String(a || '')));
|
||||||
|
});
|
||||||
|
db.exec('COMMIT');
|
||||||
|
} catch(err){
|
||||||
|
db.exec('ROLLBACK');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadGlobalPeople: setzt die globale Personenliste einer Abteilung aus den
|
||||||
|
// Tabellen people/person_roles/person_absences wieder zusammen. Rückgabe: Array.
|
||||||
|
function loadGlobalPeople(department){
|
||||||
|
const people = db.prepare('SELECT * FROM people WHERE department = ? ORDER BY sort_order').all(department);
|
||||||
|
const roles = db.prepare('SELECT * FROM person_roles WHERE department = ? ORDER BY role_order').all(department);
|
||||||
|
const absences = db.prepare('SELECT * FROM person_absences WHERE department = ?').all(department);
|
||||||
|
const rolesByPerson = {};
|
||||||
|
const absByPerson = {};
|
||||||
|
roles.forEach(r => { (rolesByPerson[r.person_id] = rolesByPerson[r.person_id] || []).push(r.role); });
|
||||||
|
absences.forEach(a => { (absByPerson[a.person_id] = absByPerson[a.person_id] || []).push(a.absence); });
|
||||||
|
return people.map(p => ({
|
||||||
|
id: p.person_id,
|
||||||
|
name: p.name,
|
||||||
|
fullName: p.full_name,
|
||||||
|
email: p.email,
|
||||||
|
role: rolesByPerson[p.person_id] || [],
|
||||||
|
absences: absByPerson[p.person_id] || [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadWeeks: setzt alle Wochen einer Abteilung aus den relationalen Tabellen
|
||||||
|
// wieder zu kompletten Wochen-Objekten zusammen (Format wie vom Frontend erwartet).
|
||||||
|
// Rückgabe: Array von Wochen-Objekten, sortiert nach Label.
|
||||||
|
function loadWeeks(department){
|
||||||
|
const weeks = db.prepare('SELECT * FROM weeks WHERE department = ? ORDER BY label').all(department);
|
||||||
|
const weekPeople = db.prepare('SELECT * FROM week_people WHERE department = ? ORDER BY array_order').all(department);
|
||||||
|
const weekRoles = db.prepare('SELECT * FROM week_people_roles WHERE department = ? ORDER BY role_order').all(department);
|
||||||
|
const attendance = db.prepare('SELECT * FROM attendance WHERE department = ?').all(department);
|
||||||
|
const projects = db.prepare('SELECT * FROM projects WHERE department = ? ORDER BY array_order').all(department);
|
||||||
|
const lph = db.prepare('SELECT * FROM project_lph WHERE department = ?').all(department);
|
||||||
|
const assign = db.prepare('SELECT * FROM project_assign WHERE department = ?').all(department);
|
||||||
|
const aufwand = db.prepare('SELECT * FROM project_aufwand WHERE department = ?').all(department);
|
||||||
|
const aktuelleLp = db.prepare('SELECT * FROM project_aktuelle_lp WHERE department = ?').all(department);
|
||||||
|
const plans = db.prepare('SELECT * FROM project_plan WHERE department = ?').all(department);
|
||||||
|
const planPeople = db.prepare('SELECT * FROM plan_people WHERE department = ? ORDER BY array_order').all(department);
|
||||||
|
const todos = db.prepare('SELECT * FROM plan_todos WHERE department = ? ORDER BY sort_order').all(department);
|
||||||
|
const todoAssignees = db.prepare('SELECT * FROM plan_todo_assignees WHERE department = ?').all(department);
|
||||||
|
const todoDeps = db.prepare('SELECT * FROM plan_todo_deps WHERE department = ?').all(department);
|
||||||
|
const milestones = db.prepare('SELECT * FROM plan_milestones WHERE department = ? ORDER BY sort_order').all(department);
|
||||||
|
const milestoneDeps = db.prepare('SELECT * FROM plan_milestone_deps WHERE department = ?').all(department);
|
||||||
|
|
||||||
|
// Index-Karten für schnellen Zugriff
|
||||||
|
const weekKey = (label) => department + '|' + label;
|
||||||
|
const projKey = (label, pid) => department + '|' + label + '|' + pid;
|
||||||
|
const todoKey = (label, pid, tid) => department + '|' + label + '|' + pid + '|' + tid;
|
||||||
|
const msKey = (label, pid, mid) => department + '|' + label + '|' + pid + '|' + mid;
|
||||||
|
|
||||||
|
const weekPeopleMap = {};
|
||||||
|
const weekRolesMap = {};
|
||||||
|
const attendanceMap = {};
|
||||||
|
weekPeople.forEach(r => {
|
||||||
|
const k = weekKey(r.week_label);
|
||||||
|
(weekPeopleMap[k] = weekPeopleMap[k] || [])[r.array_order] = { id: r.person_id, name: r.name, fullName: r.full_name };
|
||||||
|
});
|
||||||
|
weekRoles.forEach(r => {
|
||||||
|
const k = weekKey(r.week_label);
|
||||||
|
const arr = (weekRolesMap[k] = weekRolesMap[k] || {});
|
||||||
|
(arr[r.person_id] = arr[r.person_id] || []).push(r.role);
|
||||||
|
});
|
||||||
|
attendance.forEach(r => {
|
||||||
|
attendanceMap[weekKey(r.week_label) + '|' + r.person_id] = !!r.present;
|
||||||
|
});
|
||||||
|
|
||||||
|
const projectsMap = {};
|
||||||
|
projects.forEach(r => {
|
||||||
|
projectsMap[projKey(r.week_label, r.project_id)] = {
|
||||||
|
id: r.project_id, nr: r.nr, name: r.name, status: r.status, group: r.group,
|
||||||
|
lph: {}, assign: {}, order: r.order_value, plan: null,
|
||||||
|
_arrayOrder: r.array_order,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
lph.forEach(r => { const p = projectsMap[projKey(r.week_label, r.project_id)]; if(p) p.lph[r.lph] = r.value; });
|
||||||
|
assign.forEach(r => {
|
||||||
|
const p = projectsMap[projKey(r.week_label, r.project_id)];
|
||||||
|
if(p) p.assign[r.person_id + (r.role ? '|' + r.role : '')] = r.state;
|
||||||
|
});
|
||||||
|
aufwand.forEach(r => {
|
||||||
|
const p = projectsMap[projKey(r.week_label, r.project_id)];
|
||||||
|
if(p){ p.aufwandProLP = p.aufwandProLP || {}; (p.aufwandProLP[r.lph] = p.aufwandProLP[r.lph] || {})[r.role] = r.value; }
|
||||||
|
});
|
||||||
|
aktuelleLp.forEach(r => {
|
||||||
|
const p = projectsMap[projKey(r.week_label, r.project_id)];
|
||||||
|
if(p){ p.aktuelleLP = p.aktuelleLP || []; if(!p.aktuelleLP.includes(r.lph)) p.aktuelleLP.push(r.lph); }
|
||||||
|
});
|
||||||
|
|
||||||
|
const planMap = {};
|
||||||
|
plans.forEach(r => { planMap[projKey(r.week_label, r.project_id)] = { start: r.start, end: r.end, progress: r.progress, people: [], todos: [], milestones: [] }; });
|
||||||
|
planPeople.forEach(r => {
|
||||||
|
const plan = planMap[projKey(r.week_label, r.project_id)];
|
||||||
|
if(plan) plan.people.push(r.person_id);
|
||||||
|
});
|
||||||
|
|
||||||
|
const todoMap = {};
|
||||||
|
const todoAssigneeMap = {};
|
||||||
|
const todoDepMap = {};
|
||||||
|
todos.forEach(r => {
|
||||||
|
const k = todoKey(r.week_label, r.project_id, r.todo_id);
|
||||||
|
todoMap[k] = { id: r.todo_id, title: r.title, start: r.start, end: r.end, done: !!r.done, dependsOn: [], assignees: [], progress: r.progress };
|
||||||
|
});
|
||||||
|
todoAssignees.forEach(r => {
|
||||||
|
const t = todoMap[todoKey(r.week_label, r.project_id, r.todo_id)];
|
||||||
|
if(t) t.assignees.push(r.person_id);
|
||||||
|
});
|
||||||
|
todoDeps.forEach(r => {
|
||||||
|
const t = todoMap[todoKey(r.week_label, r.project_id, r.todo_id)];
|
||||||
|
if(t) t.dependsOn.push(r.depends_on_id);
|
||||||
|
});
|
||||||
|
|
||||||
|
const msMap = {};
|
||||||
|
const msDepMap = {};
|
||||||
|
milestones.forEach(r => {
|
||||||
|
const k = msKey(r.week_label, r.project_id, r.milestone_id);
|
||||||
|
msMap[k] = { id: r.milestone_id, name: r.name, date: r.date, position: r.position, dependsOn: [] };
|
||||||
|
});
|
||||||
|
milestoneDeps.forEach(r => {
|
||||||
|
const m = msMap[msKey(r.week_label, r.project_id, r.milestone_id)];
|
||||||
|
if(m) m.dependsOn.push(r.depends_on_id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO- und Meilenstein-Objekte in die Projekt-Pläne einsortieren
|
||||||
|
Object.keys(todoMap).forEach(k => {
|
||||||
|
const parts = k.split('|');
|
||||||
|
const plan = planMap[department + '|' + parts[1] + '|' + parts[2]];
|
||||||
|
if(plan) plan.todos.push(todoMap[k]);
|
||||||
|
});
|
||||||
|
Object.keys(msMap).forEach(k => {
|
||||||
|
const parts = k.split('|');
|
||||||
|
const plan = planMap[department + '|' + parts[1] + '|' + parts[2]];
|
||||||
|
if(plan) plan.milestones.push(msMap[k]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wochen-Objekte zusammensetzen
|
||||||
|
return weeks.map(w => {
|
||||||
|
const wkey = weekKey(w.label);
|
||||||
|
const people = (weekPeopleMap[wkey] || []).filter(Boolean).map(p => {
|
||||||
|
const roles = (weekRolesMap[wkey] || {})[p.id] || [];
|
||||||
|
const full = Object.assign({}, p);
|
||||||
|
if(roles.length) full.role = roles;
|
||||||
|
return full;
|
||||||
|
});
|
||||||
|
const att = {};
|
||||||
|
(weekPeopleMap[wkey] || []).filter(Boolean).forEach(p => {
|
||||||
|
const present = attendanceMap[wkey + '|' + p.id];
|
||||||
|
if(present !== undefined) att[p.id] = present;
|
||||||
|
});
|
||||||
|
const weekProjects = Object.keys(projectsMap)
|
||||||
|
.filter(k => k.startsWith(department + '|' + w.label + '|'))
|
||||||
|
.map(k => {
|
||||||
|
const p = projectsMap[k];
|
||||||
|
const plan = planMap[k];
|
||||||
|
if(plan) p.plan = plan;
|
||||||
|
return p;
|
||||||
|
})
|
||||||
|
.sort((a,b) => a._arrayOrder - b._arrayOrder)
|
||||||
|
.map(({ _arrayOrder, ...rest }) => rest);
|
||||||
|
return {
|
||||||
|
id: w.week_id, label: w.label, date: w.date,
|
||||||
|
people, projects: weekProjects, attendance: att, locked: !!w.locked,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Migration: altes Ein-Dokument-Format -> relationales Schema ----------
|
||||||
|
|
||||||
|
// migrateLegacySchema: überführt eine bestehende Datenbank aus dem alten Format
|
||||||
|
// (eine `weeks`-Tabelle mit JSON-`data`-Spalte sowie `people` mit JSON-`data`)
|
||||||
|
// in das relationale Schema. Wird einmalig beim Start ausgeführt.
|
||||||
|
function migrateLegacySchema(){
|
||||||
|
if(!hasLegacyWeeksTable()) return;
|
||||||
|
console.log('Migriere altes Wochen-Dokument-Format in relationales Schema …');
|
||||||
|
|
||||||
|
// Alte Wochen-Dokumente lesen
|
||||||
|
const legacyWeeks = db.prepare('SELECT department, label, data FROM weeks').all();
|
||||||
|
// Alte globale Personen-Dokumente lesen (eine JSON-Array-Zeile pro Abteilung)
|
||||||
|
const legacyPeopleRows = [];
|
||||||
|
const hasLegacyPeople = db.prepare("SELECT COUNT(*) AS c FROM sqlite_master WHERE type='table' AND name='people' AND sql LIKE '%data%'").get().c > 0;
|
||||||
|
if(hasLegacyPeople){
|
||||||
|
legacyPeopleRows.push(...db.prepare('SELECT department, data FROM people').all());
|
||||||
|
}
|
||||||
|
const tx = db.exec('BEGIN');
|
||||||
|
try {
|
||||||
|
// Alte Tabellen entfernen (Reihenfolge: erst Kind-Tabellen, dann Stamm)
|
||||||
|
db.exec(`DROP TABLE IF EXISTS week_people_roles; DROP TABLE IF EXISTS week_people;
|
||||||
|
DROP TABLE IF EXISTS attendance; DROP TABLE IF EXISTS project_lph;
|
||||||
|
DROP TABLE IF EXISTS project_assign; DROP TABLE IF EXISTS project_aufwand;
|
||||||
|
DROP TABLE IF EXISTS project_aktuelle_lp; DROP TABLE IF EXISTS project_plan;
|
||||||
|
DROP TABLE IF EXISTS plan_people; DROP TABLE IF EXISTS plan_todos;
|
||||||
|
DROP TABLE IF EXISTS plan_todo_assignees; DROP TABLE IF EXISTS plan_todo_deps;
|
||||||
|
DROP TABLE IF EXISTS plan_milestones; DROP TABLE IF EXISTS plan_milestone_deps;
|
||||||
|
DROP TABLE IF EXISTS projects; DROP TABLE IF EXISTS weeks;
|
||||||
|
DROP TABLE IF EXISTS people; DROP TABLE IF EXISTS person_roles; DROP TABLE IF EXISTS person_absences;`);
|
||||||
|
|
||||||
|
// Neue Tabellen anlegen (Schema-DDL oben ausführen)
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS weeks (
|
||||||
|
department TEXT NOT NULL, label TEXT NOT NULL, week_id TEXT NOT NULL DEFAULT '',
|
||||||
|
date TEXT NOT NULL DEFAULT '', locked INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (department, label)
|
||||||
|
)`);
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS week_people (
|
||||||
|
department TEXT NOT NULL, week_label TEXT NOT NULL, person_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL DEFAULT '', full_name TEXT NOT NULL DEFAULT '',
|
||||||
|
array_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (department, week_label, person_id)
|
||||||
|
)`);
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS week_people_roles (
|
||||||
|
department TEXT NOT NULL, week_label TEXT NOT NULL, person_id TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL, role_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (department, week_label, person_id, role)
|
||||||
|
)`);
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS attendance (
|
||||||
|
department TEXT NOT NULL, week_label TEXT NOT NULL, person_id TEXT NOT NULL,
|
||||||
|
present INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (department, week_label, person_id)
|
||||||
|
)`);
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS projects (
|
||||||
|
department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL,
|
||||||
|
nr TEXT NOT NULL DEFAULT '', name TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT '',
|
||||||
|
"group" TEXT NOT NULL DEFAULT '', array_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
order_value INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (department, week_label, project_id)
|
||||||
|
)`);
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS project_lph (
|
||||||
|
department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL,
|
||||||
|
lph TEXT NOT NULL, value TEXT NOT NULL DEFAULT '',
|
||||||
|
PRIMARY KEY (department, week_label, project_id, lph)
|
||||||
|
)`);
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS project_assign (
|
||||||
|
department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL,
|
||||||
|
person_id TEXT NOT NULL, role TEXT NOT NULL DEFAULT '', state INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (department, week_label, project_id, person_id, role)
|
||||||
|
)`);
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS project_aufwand (
|
||||||
|
department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL,
|
||||||
|
lph TEXT NOT NULL, role TEXT NOT NULL DEFAULT '', value REAL NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (department, week_label, project_id, lph, role)
|
||||||
|
)`);
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS project_aktuelle_lp (
|
||||||
|
department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL,
|
||||||
|
lph TEXT NOT NULL, PRIMARY KEY (department, week_label, project_id, lph)
|
||||||
|
)`);
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS project_plan (
|
||||||
|
department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL,
|
||||||
|
start TEXT NOT NULL DEFAULT '', end TEXT NOT NULL DEFAULT '', progress INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (department, week_label, project_id)
|
||||||
|
)`);
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS plan_people (
|
||||||
|
department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL,
|
||||||
|
person_id TEXT NOT NULL, array_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (department, week_label, project_id, person_id)
|
||||||
|
)`);
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS plan_todos (
|
||||||
|
department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL,
|
||||||
|
todo_id TEXT NOT NULL, title TEXT NOT NULL DEFAULT '', start TEXT NOT NULL DEFAULT '',
|
||||||
|
end TEXT NOT NULL DEFAULT '', done INTEGER NOT NULL DEFAULT 0, progress INTEGER NOT NULL DEFAULT 0,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (department, week_label, project_id, todo_id)
|
||||||
|
)`);
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS plan_todo_assignees (
|
||||||
|
department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL,
|
||||||
|
todo_id TEXT NOT NULL, person_id TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (department, week_label, project_id, todo_id, person_id)
|
||||||
|
)`);
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS plan_todo_deps (
|
||||||
|
department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL,
|
||||||
|
todo_id TEXT NOT NULL, depends_on_id TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (department, week_label, project_id, todo_id, depends_on_id)
|
||||||
|
)`);
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS plan_milestones (
|
||||||
|
department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL,
|
||||||
|
milestone_id TEXT NOT NULL, name TEXT NOT NULL DEFAULT '', date TEXT NOT NULL DEFAULT '',
|
||||||
|
position TEXT NOT NULL DEFAULT '', sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (department, week_label, project_id, milestone_id)
|
||||||
|
)`);
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS plan_milestone_deps (
|
||||||
|
department TEXT NOT NULL, week_label TEXT NOT NULL, project_id TEXT NOT NULL,
|
||||||
|
milestone_id TEXT NOT NULL, depends_on_id TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (department, week_label, project_id, milestone_id, depends_on_id)
|
||||||
|
)`);
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS people (
|
||||||
|
department TEXT NOT NULL, person_id TEXT NOT NULL, name TEXT NOT NULL DEFAULT '',
|
||||||
|
full_name TEXT NOT NULL DEFAULT '', email TEXT NOT NULL DEFAULT '',
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (department, person_id)
|
||||||
|
)`);
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS person_roles (
|
||||||
|
department TEXT NOT NULL, person_id TEXT NOT NULL, role TEXT NOT NULL,
|
||||||
|
role_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (department, person_id, role)
|
||||||
|
)`);
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS person_absences (
|
||||||
|
department TEXT NOT NULL, person_id TEXT NOT NULL, absence TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (department, person_id, absence)
|
||||||
|
)`);
|
||||||
|
|
||||||
|
// Alte Dokumente einlesen und relational speichern. Die Tabellen wurden
|
||||||
|
// gerade neu angelegt und sind leer; `serializeWeek` genügt (kein eigener
|
||||||
|
// Transaktions-Wrapper, da die äußere BEGIN/COMMIT der Migration aktiv ist).
|
||||||
|
legacyWeeks.forEach(row => {
|
||||||
|
let week;
|
||||||
|
try { week = JSON.parse(row.data); } catch(e){ console.error('Kaputte Woche bei Migration:', row.label, e.message); return; }
|
||||||
|
if(!week || !week.label) return;
|
||||||
|
serializeWeek(row.department, week);
|
||||||
|
});
|
||||||
|
// Globale Personen aus dem alten Dokument-Format migrieren. serializeGlobalPeople
|
||||||
|
// startet selbst eine Transaktion, daher werden die Zeilen hier direkt in die
|
||||||
|
// neuen Tabellen eingefügt (serializeGlobalPeople ist in Transaktionen nicht
|
||||||
|
// aufrufbar, weil BEGIN innerhalb BEGIN nicht erlaubt ist).
|
||||||
|
legacyPeopleRows.forEach(row => {
|
||||||
|
let people;
|
||||||
|
try { people = JSON.parse(row.data); } catch(e){ console.error('Kaputte Personen-Liste bei Migration:', row.department, e.message); return; }
|
||||||
|
if(!Array.isArray(people)) return;
|
||||||
|
const insPerson = db.prepare(`INSERT INTO people (department, person_id, name, full_name, email, sort_order)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`);
|
||||||
|
const insRole = db.prepare(`INSERT OR IGNORE INTO person_roles (department, person_id, role, role_order) VALUES (?, ?, ?, ?)`);
|
||||||
|
const insAbsence = db.prepare(`INSERT OR IGNORE INTO person_absences (department, person_id, absence) VALUES (?, ?, ?)`);
|
||||||
|
people.forEach((p, idx) => {
|
||||||
|
insPerson.run(row.department, String(p.id || ''), String(p.name || ''), String(p.fullName || ''), String(p.email || ''), idx);
|
||||||
|
(Array.isArray(p.role) ? p.role : []).forEach((r, ridx) => insRole.run(row.department, String(p.id || ''), String(r || ''), ridx));
|
||||||
|
(Array.isArray(p.absences) ? p.absences : []).forEach(a => insAbsence.run(row.department, String(p.id || ''), String(a || '')));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
db.exec('COMMIT');
|
||||||
|
console.log('Migration abgeschlossen (' + legacyWeeks.length + ' Wochen).');
|
||||||
|
} catch(err){
|
||||||
|
db.exec('ROLLBACK');
|
||||||
|
console.error('Migration fehlgeschlagen:', err.message);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration beim Serverstart ausführen
|
||||||
|
migrateLegacySchema();
|
||||||
|
|
||||||
|
// Route: Speichert eine komplette Woche (als JSON-Objekt) für eine Abteilung
|
||||||
|
app.post('/save-week', (req, res) => {
|
||||||
|
const { department, week } = req.body || {};
|
||||||
|
if(!week || typeof week.label !== 'string' || !week.label) return res.status(400).json({ ok: false, error: 'Missing week or label' });
|
||||||
|
const dept = departmentKey(department);
|
||||||
|
try {
|
||||||
|
saveWeekRelational(dept, week);
|
||||||
|
console.log('Saved week:', dept + ' / ' + week.label);
|
||||||
return res.json({ ok: true });
|
return res.json({ ok: true });
|
||||||
});
|
} catch(err){
|
||||||
|
console.error('Save week error', err);
|
||||||
|
return res.status(500).json({ ok: false, error: String(err) });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Route: Lädt alle Sitzungs-CSV-Dateien einer Abteilung plus _people.csv (nach Dateiname sortiert)
|
// Route: Speichert die globale Personenliste einer Abteilung (als JSON-Array)
|
||||||
|
app.post('/save-people', (req, res) => {
|
||||||
|
const { department, people } = req.body || {};
|
||||||
|
if(!Array.isArray(people)) return res.status(400).json({ ok: false, error: 'Missing people array' });
|
||||||
|
const dept = departmentKey(department);
|
||||||
|
try {
|
||||||
|
serializeGlobalPeople(dept, people);
|
||||||
|
console.log('Saved people:', dept, '(' + people.length + ' Personen)');
|
||||||
|
return res.json({ ok: true });
|
||||||
|
} catch(err){
|
||||||
|
console.error('Save people error', err);
|
||||||
|
return res.status(500).json({ ok: false, error: String(err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route: Löscht eine Woche einer Abteilung (fehlende Woche wird toleriert)
|
||||||
|
app.post('/delete-week', (req, res) => {
|
||||||
|
const { department, label } = req.body || {};
|
||||||
|
if(!label) return res.status(400).json({ ok: false, error: 'Missing label' });
|
||||||
|
const dept = departmentKey(department);
|
||||||
|
try {
|
||||||
|
const row = db.prepare('SELECT 1 FROM weeks WHERE department = ? AND label = ?').get(dept, String(label));
|
||||||
|
const existed = !!row;
|
||||||
|
deleteWeekData(dept, String(label));
|
||||||
|
console.log('Deleted week:', dept + ' / ' + label, existed ? '' : '(nicht vorhanden)');
|
||||||
|
return res.json({ ok: true, deleted: existed });
|
||||||
|
} catch(err){
|
||||||
|
console.error('Delete week error', err);
|
||||||
|
return res.status(500).json({ ok: false, error: String(err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route: Lädt alle Wochen plus globale Personenliste einer Abteilung.
|
||||||
|
// Liefert strukturierte JSON-Objekte (aus den relationalen Tabellen
|
||||||
|
// zusammengesetzt); nach Label sortiert.
|
||||||
app.get('/load-all', (req, res) => {
|
app.get('/load-all', (req, res) => {
|
||||||
const dir = departmentDir(req.query.department);
|
try {
|
||||||
fs.readdir(dir, (err, files) => {
|
const dept = departmentKey(req.query.department);
|
||||||
if(err){ console.error('Read error', err); return res.status(500).json({ ok:false, error: String(err) }); }
|
const weeks = loadWeeks(dept);
|
||||||
const csvFiles = files.filter(f => f.toLowerCase().endsWith('.csv'));
|
const people = loadGlobalPeople(dept);
|
||||||
const result = { ok: true, sessions: [], people: null };
|
res.json({ ok: true, weeks, people });
|
||||||
csvFiles.forEach(filename => {
|
} catch(err){
|
||||||
if(filename.toLowerCase() === '_people.csv'){
|
console.error('Load error', err);
|
||||||
result.people = fs.readFileSync(path.join(dir, filename), 'utf8');
|
res.status(500).json({ ok: false, error: String(err) });
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
const content = fs.readFileSync(path.join(dir, filename), 'utf8');
|
|
||||||
result.sessions.push({ filename, content });
|
|
||||||
});
|
|
||||||
// Sortiere Sitzungen nach Dateiname, damit die Reihenfolge stabil ist
|
|
||||||
result.sessions.sort((a,b) => a.filename.localeCompare(b.filename));
|
|
||||||
res.json(result);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Route: Healthcheck-Endpunkt für Docker/Überwachung
|
// Route: Healthcheck-Endpunkt für Docker/Überwachung
|
||||||
@@ -150,7 +819,7 @@ app.get('/:department/Projektplan/:projName', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Serverstart mit Fehlerbehandlung (z.B. Port bereits belegt)
|
// Serverstart mit Fehlerbehandlung (z.B. Port bereits belegt)
|
||||||
const server = app.listen(PORT, ()=> console.log(`CSV-Session server running on http://localhost:${PORT}`));
|
const server = app.listen(PORT, ()=> console.log(`Projektübersicht server running on http://localhost:${PORT}`));
|
||||||
server.on('error', (err) => {
|
server.on('error', (err) => {
|
||||||
console.error('Server konnte nicht starten:', err.message);
|
console.error('Server konnte nicht starten:', err.message);
|
||||||
if(err.code === 'EADDRINUSE'){
|
if(err.code === 'EADDRINUSE'){
|
||||||
|
|||||||
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;7;;;
|
|
||||||
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;11;;;
|
|
||||||
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 5""]"
|
|
||||||
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;8;;;
|
|
||||||
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;9;;;
|
|
||||||
23-4072;S-K OGS-Kaldenkirchen Schulanbau;- Feininstallation beginnt, Fertigstellung voraussichtlich <font>01.08</font>;;;;;;;;;;;;;;;;;;;;;;;;;;70%;;Aktive Projekte;10;;;
|
|
||||||
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;12;;;
|
|
||||||
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;13;;;
|
|
||||||
25-4003;Märkische Bank Boele;- Feininstallation<div>- Ab KW30 Einbringung Tresore</div>;;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;80%;;Aktive Projekte;14;;;
|
|
||||||
25-4028;TenBrinke, Amprion, Wietmarschen;;;;;;;;;;;;;;;;;;;;100%;100%;100%;;95%;;;;;Aktive Projekte;15;;;
|
|
||||||
25-4028-1;TenBrinke, Amprion, Meerbusch;;;;;;;;;;;;;;;;;;;;100%;100%;100%;;95%;;;;;Aktive Projekte;16;;;
|
|
||||||
25-4028-2;TenBrinke, Amprion, Gelsenkirchen;;;;;;;;;;;;;;;;;;;;100%;100%;100%;;95%;;;;;Aktive Projekte;17;;;
|
|
||||||
26-4018;TUI Immobilien GmbH Salzgitter;;;;;;;;;;;;;;;;;;;;50%;;;;;;;;;Aktive Projekte;18;;;
|
|
||||||
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;19;;;
|
|
||||||
26-4026;Stadt Hemer, Anbau OGS Oesetalschule;"<font color=""#1a1a1a"">- Bearbeitung LPH3 in Absprache mit Achitektur S+D</font>";;;;;;;;;;;;;;;;;;;100%;100%;;;;;;;;Aktive Projekte;20;;;
|
|
||||||
26-4046;AXA Außenbeleuchtung;- Warten auf Unterlagen (Grundlagen & Vorplanung);;;;;;;;;;;;;;;;;;;10%;;;;;;;;;Aktive Projekte;21;;;
|
|
||||||
;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;[];[]
|
|
||||||
|
Reference in new issue
Block a user