diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..10eba53 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +# ---- Stage 1: Frontend bauen ---- +FROM node:20-slim AS frontend-build +WORKDIR /app/frontend +COPY frontend/package.json ./ +RUN npm install +COPY frontend/ ./ +RUN npm run build + +# ---- Stage 2: Backend + gebautes Frontend ---- +FROM node:20-slim AS runtime +WORKDIR /app + +COPY backend/package.json ./ +RUN npm install --omit=dev + +COPY backend/ ./ +# Vom Build-Stage kommt das fertige Frontend nach backend/public +COPY --from=frontend-build /app/backend/public ./public + +ENV NODE_ENV=production +ENV PORT=3000 + +EXPOSE 3000 + +CMD ["node", "src/index.js"] diff --git a/README.md b/README.md index f38c042..90a8473 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,106 @@ -# ksr +# Jahresplanung & Kapazitaetsplanung -Projekt- und Ressourcenplanung für den Kundenservice DBI \ No newline at end of file +Eine schlanke Web-App zur Jahresplanung mehrerer Projekte mit integrierter +Kapazitaetsplanung pro Person. + +## Funktionen + +- **Projekte & Aufgaben**: Projekte in Aufgaben mit Start-/Enddatum, Aufwand + (Stunden), Zustaendigkeit, Prioritaet und optionaler Abhaengigkeit zerlegen. +- **Team**: Personen mit Wochenkapazitaet (Stunden) und Abwesenheiten + (Urlaub, Feiertage) pflegen. +- **Zeitachse**: Alle Aufgaben eines Jahres als Balken je Projekt, jahrweise + navigierbar. +- **Kapazitaet**: Automatische Auslastungsberechnung je Person und Monat. + Der Aufwand jeder Aufgabe wird gleichmaessig ueber ihre Werktage verteilt + und den betroffenen Monaten anteilig zugerechnet; Abwesenheiten reduzieren + die verfuegbare Nettokapazitaet. Ueberlast wird farblich markiert. + +## Architektur + +- **Backend**: Node.js + Express, liefert eine REST-API (`/api/...`) und das + gebaute Frontend aus. +- **Datenhaltung**: MongoDB (eigener Container, offizieller Node-Treiber + `mongodb`). Vier Collections: `people`, `projects`, `tasks`, `absences`. + Die Mongo-`_id` wird beim Ausliefern an das Frontend in ein einfaches + `id`-Feld (String) umgewandelt; Referenzen zwischen Entitaeten (z. B. + `assigneeId`, `projectId`) werden als dieser String gespeichert. +- **Frontend**: React + Vite, wird beim Docker-Build in `backend/public` + gebaut und vom Backend ausgeliefert. + +## Lokale Entwicklung (optional) + +```bash +# MongoDB lokal starten (z. B. per Docker) +docker run -d --name jp-mongo -p 27017:27017 mongo:7 + +# Backend +cd backend +npm install +MONGODB_URI=mongodb://localhost:27017/jahresplanung npm start # http://localhost:3000 + +# Frontend (in zweitem Terminal) +cd frontend +npm install +npm run dev # http://localhost:5173, proxied /api zu :3000 +``` + +## Deployment in Coolify + +1. **Repository verbinden**: Diesen Ordner in ein Git-Repository pushen + (GitHub/GitLab/Gitea) und in Coolify als neue Ressource hinzufuegen + (New Resource → Application → dein Repo). +2. **Build-Pack**: **Docker Compose** waehlen. Das mitgelieferte + `docker-compose.yml` startet automatisch zwei Container: die App und + MongoDB, inklusive persistentem Volume fuer die Datenbank. +3. **Port**: Der App-Container lauscht auf Port `3000` (im + `docker-compose.yml` gemappt). In Coolify unter "Domains" die gewuenschte + Domain/Subdomain zuweisen. Der MongoDB-Container hat bewusst **keinen** + nach aussen gemappten Port und ist nur innerhalb des Compose-Netzwerks + fuer die App erreichbar. +4. **Persistenz**: Das Volume `jahresplanung-mongo-data` sichert die + MongoDB-Datendateien. Bei Redeploys bleiben die Daten erhalten, solange + das Volume nicht geloescht wird. +5. **Deploy** klicken. Coolify baut das App-Image und startet beide + Container. Die App verbindet sich beim Start einmalig mit MongoDB; falls + das noch nicht bereit ist, beendet sich der App-Container mit einer + Fehlermeldung im Log und Coolify startet ihn (je nach Restart-Policy) + erneut — meist reicht ein manueller Restart des App-Containers, falls er + schneller hochkommt als MongoDB. + +### Absicherung (optional, empfohlen fuer produktiven Einsatz) + +Der MongoDB-Container laeuft standardmaessig **ohne Authentifizierung**, +ist aber nicht nach aussen exponiert (kein `ports:`-Eintrag), sondern nur +innerhalb des internen Compose-Netzwerks erreichbar. Fuer zusaetzliche +Absicherung kannst du `MONGO_INITDB_ROOT_USERNAME` / +`MONGO_INITDB_ROOT_PASSWORD` beim `mongo`-Service setzen und die +`MONGODB_URI` beim App-Service entsprechend um die Zugangsdaten ergaenzen +(`mongodb://user:pass@mongo:27017/jahresplanung?authSource=admin`). + +### Ohne docker-compose (alternativ) + +Falls du in Coolify statt "Docker Compose" nur "Dockerfile" als Build-Pack +waehlst, musst du MongoDB als separate Coolify-Ressource (oder externen +Dienst) anlegen und die Umgebungsvariable `MONGODB_URI` am App-Container +manuell auf die MongoDB-Verbindung zeigen lassen. + +## Backup + +`mongodump` / `mongorestore` gegen den Mongo-Container, oder Coolifys +eingebaute Backup-Funktion fuer das Volume `jahresplanung-mongo-data` +verwenden. + +## Datenmodell (Kurzueberblick) + +| Entitaet | Felder | +|---|---| +| Person | name, role, weeklyHours, color | +| Projekt | name, description, color | +| Aufgabe | projectId, name, startDate, endDate, estimatedHours, assigneeId, priority, status, dependsOn | +| Abwesenheit | personId, startDate, endDate, note | + +Die Kapazitaetsberechnung (`GET /api/capacity?from=...&to=...`) aggregiert +je Person und Monat: verfuegbare Werktage minus Abwesenheiten × Tageskapazitaet +als `capacityHours`, sowie anteilig verteilte `assignedHours` aus allen +zugewiesenen Aufgaben. diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..78fcbf2 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,18 @@ +{ + "name": "jahresplanung-backend", + "version": "1.0.0", + "private": true, + "description": "API und Static-Server fuer die Jahresplanung-App", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js" + }, + "dependencies": { + "express": "^4.19.2", + "cors": "^2.8.5", + "mongodb": "^6.3.0" + }, + "engines": { + "node": ">=18" + } +} diff --git a/backend/scripts/migrate-json-to-mongo.js b/backend/scripts/migrate-json-to-mongo.js new file mode 100644 index 0000000..87cccfa --- /dev/null +++ b/backend/scripts/migrate-json-to-mongo.js @@ -0,0 +1,72 @@ +// Einmaliges Migrationsskript: liest eine alte data.json (aus der +// JSON-Datei-Version der App) und schreibt die Inhalte nach MongoDB. +// +// Nutzung: +// MONGODB_URI=mongodb://user:pass@host:27017 \ +// node scripts/migrate-json-to-mongo.js /pfad/zu/data.json +// +// Das Skript ist idempotent-freundlich: bestehende Dokumente mit gleicher +// _id werden ueberschrieben (upsert), es entstehen keine Duplikate bei +// mehrfacher Ausfuehrung. + +const fs = require("fs"); +const { MongoClient } = require("mongodb"); + +async function main() { + const filePath = process.argv[2]; + if (!filePath) { + console.error("Bitte Pfad zur data.json angeben."); + console.error("Beispiel: node scripts/migrate-json-to-mongo.js ./data.json"); + process.exit(1); + } + if (!fs.existsSync(filePath)) { + console.error(`Datei nicht gefunden: ${filePath}`); + process.exit(1); + } + + const raw = fs.readFileSync(filePath, "utf-8"); + const data = JSON.parse(raw); + + const uri = process.env.MONGODB_URI || "mongodb://localhost:27017"; + const dbName = process.env.MONGODB_DB || "jahresplanung"; + + const client = new MongoClient(uri); + await client.connect(); + const db = client.db(dbName); + + const collections = { + people: data.people || [], + projects: data.projects || [], + tasks: data.tasks || [], + absences: data.absences || [] + }; + + for (const [name, items] of Object.entries(collections)) { + if (items.length === 0) { + console.log(`- ${name}: keine Eintraege, uebersprungen`); + continue; + } + const ops = items.map((item) => { + const { id, ...rest } = item; // "id" -> "_id" umbenennen + return { + replaceOne: { + filter: { _id: id }, + replacement: { _id: id, ...rest }, + upsert: true + } + }; + }); + const result = await db.collection(name).bulkWrite(ops); + console.log( + `- ${name}: ${result.upsertedCount} neu, ${result.modifiedCount} aktualisiert` + ); + } + + await client.close(); + console.log("Migration abgeschlossen."); +} + +main().catch((err) => { + console.error("Migration fehlgeschlagen:", err); + process.exit(1); +}); diff --git a/backend/src/db.js b/backend/src/db.js new file mode 100644 index 0000000..2108154 --- /dev/null +++ b/backend/src/db.js @@ -0,0 +1,36 @@ +const { MongoClient, ObjectId } = require("mongodb"); + +const uri = process.env.MONGODB_URI || "mongodb://localhost:27017/jahresplanung"; + +let client; +let dbPromise; + +// Stellt sicher, dass nur eine Verbindung aufgebaut wird und wiederverwendet wird. +function connect() { + if (!dbPromise) { + client = new MongoClient(uri); + dbPromise = client + .connect() + .then((c) => c.db()) + .catch((err) => { + dbPromise = null; // Bei Fehler erneuten Verbindungsversuch beim naechsten Aufruf erlauben + throw err; + }); + } + return dbPromise; +} + +// Wandelt eine String-ID in eine ObjectId um, oder liefert null bei ungueltigem Format. +function toId(idStr) { + if (!idStr || !ObjectId.isValid(idStr)) return null; + return new ObjectId(idStr); +} + +// Wandelt ein Mongo-Dokument (_id) in die vom Frontend erwartete Form (id) um. +function serialize(doc) { + if (!doc) return null; + const { _id, ...rest } = doc; + return { id: _id.toString(), ...rest }; +} + +module.exports = { connect, toId, serialize, ObjectId }; diff --git a/backend/src/db/helpers.js b/backend/src/db/helpers.js new file mode 100644 index 0000000..7d97f85 --- /dev/null +++ b/backend/src/db/helpers.js @@ -0,0 +1,19 @@ +const crypto = require("crypto"); + +function newId() { + return crypto.randomUUID(); +} + +// Wir speichern unsere eigene UUID direkt als Mongo _id, damit das Frontend +// weiterhin ein einfaches "id"-Feld sieht und keine ObjectId-Logik braucht. +function toClient(doc) { + if (!doc) return null; + const { _id, ...rest } = doc; + return { id: _id, ...rest }; +} + +function toClientList(docs) { + return docs.map(toClient); +} + +module.exports = { newId, toClient, toClientList }; diff --git a/backend/src/db/mongo.js b/backend/src/db/mongo.js new file mode 100644 index 0000000..7cecbd4 --- /dev/null +++ b/backend/src/db/mongo.js @@ -0,0 +1,45 @@ +const { MongoClient } = require("mongodb"); + +const uri = process.env.MONGODB_URI || "mongodb://localhost:27017"; +const dbName = process.env.MONGODB_DB || "jahresplanung"; + +let client; +let db; + +async function connect(retries = 10, delayMs = 2000) { + if (db) return db; + + for (let attempt = 1; attempt <= retries; attempt++) { + try { + client = new MongoClient(uri, { serverSelectionTimeoutMS: 5000 }); + await client.connect(); + db = client.db(dbName); + + await db.collection("tasks").createIndex({ projectId: 1 }); + await db.collection("tasks").createIndex({ assigneeId: 1 }); + await db.collection("absences").createIndex({ personId: 1 }); + + console.log(`Verbunden mit MongoDB-Datenbank "${dbName}"`); + return db; + } catch (err) { + console.warn( + `MongoDB-Verbindungsversuch ${attempt}/${retries} fehlgeschlagen: ${err.message}` + ); + if (attempt === retries) throw err; + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } +} + +function getDb() { + if (!db) { + throw new Error("MongoDB ist noch nicht verbunden. connect() muss zuerst aufgerufen werden."); + } + return db; +} + +async function close() { + if (client) await client.close(); +} + +module.exports = { connect, getDb, close }; diff --git a/backend/src/index.js b/backend/src/index.js new file mode 100644 index 0000000..34da026 --- /dev/null +++ b/backend/src/index.js @@ -0,0 +1,53 @@ +const path = require("path"); +const express = require("express"); +const cors = require("cors"); +const { connect } = require("./db"); + +const peopleRouter = require("./routes/people"); +const projectsRouter = require("./routes/projects"); +const tasksRouter = require("./routes/tasks"); +const absencesRouter = require("./routes/absences"); +const capacityRouter = require("./routes/capacity"); + +const app = express(); +const PORT = process.env.PORT || 3000; + +app.use(cors()); +app.use(express.json()); + +app.get("/api/health", (req, res) => res.json({ status: "ok" })); + +app.use("/api/people", peopleRouter); +app.use("/api/projects", projectsRouter); +app.use("/api/tasks", tasksRouter); +app.use("/api/absences", absencesRouter); +app.use("/api/capacity", capacityRouter); + +// Gebautes Frontend (Vite -> dist) ausliefern +const frontendDist = path.join(__dirname, "..", "public"); +app.use(express.static(frontendDist)); + +// SPA-Fallback: alles, was nicht /api ist, liefert die index.html aus +app.get(/^(?!\/api).*/, (req, res) => { + res.sendFile(path.join(frontendDist, "index.html")); +}); + +app.use((err, req, res, next) => { + console.error(err); + res.status(500).json({ error: "Interner Serverfehler" }); +}); + +async function start() { + try { + await connect(); + app.listen(PORT, () => { + console.log(`Jahresplanung-Server laeuft auf Port ${PORT}`); + }); + } catch (err) { + console.error("Konnte keine Verbindung zu MongoDB herstellen:", err); + process.exit(1); + } +} + +start(); + diff --git a/backend/src/routes/absences.js b/backend/src/routes/absences.js new file mode 100644 index 0000000..fe27903 --- /dev/null +++ b/backend/src/routes/absences.js @@ -0,0 +1,40 @@ +const express = require("express"); +const { connect, toId, serialize } = require("../db"); + +const router = express.Router(); + +router.get("/", async (req, res) => { + const db = await connect(); + const absences = await db.collection("absences").find().sort({ startDate: 1 }).toArray(); + res.json(absences.map(serialize)); +}); + +router.post("/", async (req, res) => { + const { personId, startDate, endDate, note } = req.body; + if (!personId || !startDate || !endDate) { + return res + .status(400) + .json({ error: "personId, startDate und endDate sind erforderlich" }); + } + const db = await connect(); + const personObjectId = toId(personId); + if (!personObjectId || !(await db.collection("people").findOne({ _id: personObjectId }))) { + return res.status(400).json({ error: "Unbekannte Person" }); + } + const doc = { personId, startDate, endDate, note: note || "" }; + const result = await db.collection("absences").insertOne(doc); + res.status(201).json(serialize({ _id: result.insertedId, ...doc })); +}); + +router.delete("/:id", async (req, res) => { + const _id = toId(req.params.id); + if (!_id) return res.status(404).json({ error: "Abwesenheit nicht gefunden" }); + const db = await connect(); + const result = await db.collection("absences").deleteOne({ _id }); + if (result.deletedCount === 0) { + return res.status(404).json({ error: "Abwesenheit nicht gefunden" }); + } + res.status(204).end(); +}); + +module.exports = router; diff --git a/backend/src/routes/capacity.js b/backend/src/routes/capacity.js new file mode 100644 index 0000000..dae1e51 --- /dev/null +++ b/backend/src/routes/capacity.js @@ -0,0 +1,86 @@ +const express = require("express"); +const { connect } = require("../db"); +const { + countWorkdays, + overlapWorkdays, + monthsInRange, + monthBounds +} = require("../utils/dateUtils"); + +const router = express.Router(); + +// GET /api/capacity?from=YYYY-MM-DD&to=YYYY-MM-DD +// Liefert pro Person und Monat: Kapazitaet (Stunden), zugewiesene Stunden, +// Auslastung in Prozent und den Ueberlast-Status. +router.get("/", async (req, res) => { + const db = await connect(); + const from = req.query.from || `${new Date().getFullYear()}-01-01`; + const to = req.query.to || `${new Date().getFullYear()}-12-31`; + const months = monthsInRange(from, to); + + const [people, absences, tasks] = await Promise.all([ + db.collection("people").find().sort({ name: 1 }).toArray(), + db.collection("absences").find().toArray(), + db.collection("tasks").find().toArray() + ]); + + const result = people.map((person) => { + const personId = person._id.toString(); + const dailyHours = person.weeklyHours / 5; + + const personAbsences = absences.filter((a) => a.personId === personId); + const personTasks = tasks.filter((t) => t.assigneeId === personId); + + const monthsData = months.map((month) => { + const { start: mStart, end: mEnd } = monthBounds(month); + const workdaysInMonth = countWorkdays(mStart, mEnd); + + let absenceWorkdays = 0; + personAbsences.forEach((a) => { + absenceWorkdays += overlapWorkdays(a.startDate, a.endDate, mStart, mEnd); + }); + absenceWorkdays = Math.min(absenceWorkdays, workdaysInMonth); + + const capacityHours = Math.max( + 0, + (workdaysInMonth - absenceWorkdays) * dailyHours + ); + + let assignedHours = 0; + personTasks.forEach((task) => { + const taskWorkdays = countWorkdays(task.startDate, task.endDate); + if (taskWorkdays === 0) return; + const dailyRate = task.estimatedHours / taskWorkdays; + const overlap = overlapWorkdays(task.startDate, task.endDate, mStart, mEnd); + assignedHours += dailyRate * overlap; + }); + + const utilization = + capacityHours > 0 + ? Math.round((assignedHours / capacityHours) * 100) + : assignedHours > 0 + ? 999 + : 0; + + return { + month, + capacityHours: Math.round(capacityHours * 10) / 10, + assignedHours: Math.round(assignedHours * 10) / 10, + utilization, + overloaded: assignedHours > capacityHours + 0.5 + }; + }); + + return { + personId, + name: person.name, + color: person.color, + weeklyHours: person.weeklyHours, + months: monthsData + }; + }); + + res.json({ from, to, months, people: result }); +}); + +module.exports = router; diff --git a/backend/src/routes/people.js b/backend/src/routes/people.js new file mode 100644 index 0000000..c2126e8 --- /dev/null +++ b/backend/src/routes/people.js @@ -0,0 +1,68 @@ +const express = require("express"); +const { connect, toId, serialize } = require("../db"); + +const router = express.Router(); + +router.get("/", async (req, res) => { + const db = await connect(); + const people = await db.collection("people").find().sort({ name: 1 }).toArray(); + res.json(people.map(serialize)); +}); + +router.post("/", async (req, res) => { + const { name, weeklyHours, color, role } = req.body; + if (!name || typeof name !== "string") { + return res.status(400).json({ error: "name ist erforderlich" }); + } + const db = await connect(); + const doc = { + name, + role: role || "", + weeklyHours: Number(weeklyHours) || 40, + color: color || "#2F6F4F" + }; + const result = await db.collection("people").insertOne(doc); + res.status(201).json(serialize({ _id: result.insertedId, ...doc })); +}); + +router.put("/:id", async (req, res) => { + const _id = toId(req.params.id); + if (!_id) return res.status(404).json({ error: "Person nicht gefunden" }); + + const { name, weeklyHours, color, role } = req.body; + const update = {}; + if (name !== undefined) update.name = name; + if (role !== undefined) update.role = role; + if (weeklyHours !== undefined) update.weeklyHours = Number(weeklyHours); + if (color !== undefined) update.color = color; + + const db = await connect(); + const updated = await db + .collection("people") + .findOneAndUpdate({ _id }, { $set: update }, { returnDocument: "after" }); + + if (!updated) return res.status(404).json({ error: "Person nicht gefunden" }); + res.json(serialize(updated)); +}); + +router.delete("/:id", async (req, res) => { + const _id = toId(req.params.id); + if (!_id) return res.status(404).json({ error: "Person nicht gefunden" }); + + const db = await connect(); + const result = await db.collection("people").deleteOne({ _id }); + if (result.deletedCount === 0) { + return res.status(404).json({ error: "Person nicht gefunden" }); + } + + const idStr = req.params.id; + // Zuweisungen und Abwesenheiten der geloeschten Person aufraeumen + await db + .collection("tasks") + .updateMany({ assigneeId: idStr }, { $set: { assigneeId: null } }); + await db.collection("absences").deleteMany({ personId: idStr }); + + res.status(204).end(); +}); + +module.exports = router; diff --git a/backend/src/routes/projects.js b/backend/src/routes/projects.js new file mode 100644 index 0000000..cae6a97 --- /dev/null +++ b/backend/src/routes/projects.js @@ -0,0 +1,59 @@ +const express = require("express"); +const { connect, toId, serialize } = require("../db"); + +const router = express.Router(); + +router.get("/", async (req, res) => { + const db = await connect(); + const projects = await db.collection("projects").find().sort({ name: 1 }).toArray(); + res.json(projects.map(serialize)); +}); + +router.post("/", async (req, res) => { + const { name, description, color } = req.body; + if (!name || typeof name !== "string") { + return res.status(400).json({ error: "name ist erforderlich" }); + } + const db = await connect(); + const doc = { + name, + description: description || "", + color: color || "#3E5C50" + }; + const result = await db.collection("projects").insertOne(doc); + res.status(201).json(serialize({ _id: result.insertedId, ...doc })); +}); + +router.put("/:id", async (req, res) => { + const _id = toId(req.params.id); + if (!_id) return res.status(404).json({ error: "Projekt nicht gefunden" }); + + const { name, description, color } = req.body; + const update = {}; + if (name !== undefined) update.name = name; + if (description !== undefined) update.description = description; + if (color !== undefined) update.color = color; + + const db = await connect(); + const updated = await db + .collection("projects") + .findOneAndUpdate({ _id }, { $set: update }, { returnDocument: "after" }); + + if (!updated) return res.status(404).json({ error: "Projekt nicht gefunden" }); + res.json(serialize(updated)); +}); + +router.delete("/:id", async (req, res) => { + const _id = toId(req.params.id); + if (!_id) return res.status(404).json({ error: "Projekt nicht gefunden" }); + + const db = await connect(); + const result = await db.collection("projects").deleteOne({ _id }); + if (result.deletedCount === 0) { + return res.status(404).json({ error: "Projekt nicht gefunden" }); + } + await db.collection("tasks").deleteMany({ projectId: req.params.id }); + res.status(204).end(); +}); + +module.exports = router; diff --git a/backend/src/routes/tasks.js b/backend/src/routes/tasks.js new file mode 100644 index 0000000..be69657 --- /dev/null +++ b/backend/src/routes/tasks.js @@ -0,0 +1,110 @@ +const express = require("express"); +const { connect, toId, serialize } = require("../db"); + +const router = express.Router(); + +router.get("/", async (req, res) => { + const db = await connect(); + const tasks = await db.collection("tasks").find().sort({ startDate: 1 }).toArray(); + res.json(tasks.map(serialize)); +}); + +router.post("/", async (req, res) => { + const { + projectId, + name, + startDate, + endDate, + estimatedHours, + assigneeId, + status, + priority, + dependsOn + } = req.body; + + if (!projectId || !name || !startDate || !endDate) { + return res + .status(400) + .json({ error: "projectId, name, startDate und endDate sind erforderlich" }); + } + if (startDate > endDate) { + return res.status(400).json({ error: "startDate muss vor endDate liegen" }); + } + + const db = await connect(); + const projectObjectId = toId(projectId); + if (!projectObjectId || !(await db.collection("projects").findOne({ _id: projectObjectId }))) { + return res.status(400).json({ error: "Unbekanntes Projekt" }); + } + + const doc = { + projectId, + name, + startDate, + endDate, + estimatedHours: Number(estimatedHours) || 0, + assigneeId: assigneeId || null, + status: status || "geplant", + priority: priority || "mittel", + dependsOn: dependsOn || null + }; + const result = await db.collection("tasks").insertOne(doc); + res.status(201).json(serialize({ _id: result.insertedId, ...doc })); +}); + +router.put("/:id", async (req, res) => { + const _id = toId(req.params.id); + if (!_id) return res.status(404).json({ error: "Aufgabe nicht gefunden" }); + + const db = await connect(); + const existing = await db.collection("tasks").findOne({ _id }); + if (!existing) return res.status(404).json({ error: "Aufgabe nicht gefunden" }); + + const fields = [ + "projectId", + "name", + "startDate", + "endDate", + "estimatedHours", + "assigneeId", + "status", + "priority", + "dependsOn" + ]; + const update = {}; + fields.forEach((f) => { + if (req.body[f] !== undefined) { + update[f] = f === "estimatedHours" ? Number(req.body[f]) : req.body[f]; + } + }); + + const merged = { ...existing, ...update }; + if (merged.startDate > merged.endDate) { + return res.status(400).json({ error: "startDate muss vor endDate liegen" }); + } + + const updated = await db + .collection("tasks") + .findOneAndUpdate({ _id }, { $set: update }, { returnDocument: "after" }); + + res.json(serialize(updated)); +}); + +router.delete("/:id", async (req, res) => { + const _id = toId(req.params.id); + if (!_id) return res.status(404).json({ error: "Aufgabe nicht gefunden" }); + + const db = await connect(); + const result = await db.collection("tasks").deleteOne({ _id }); + if (result.deletedCount === 0) { + return res.status(404).json({ error: "Aufgabe nicht gefunden" }); + } + // Abhaengigkeiten auf geloeschte Aufgabe aufraeumen + await db + .collection("tasks") + .updateMany({ dependsOn: req.params.id }, { $set: { dependsOn: null } }); + + res.status(204).end(); +}); + +module.exports = router; diff --git a/backend/src/utils/dateUtils.js b/backend/src/utils/dateUtils.js new file mode 100644 index 0000000..9da5ec3 --- /dev/null +++ b/backend/src/utils/dateUtils.js @@ -0,0 +1,78 @@ +// Alle Datumswerte werden als "YYYY-MM-DD" Strings gehandhabt und als UTC +// Datum interpretiert, damit Zeitzonen keine Off-by-one Fehler verursachen. + +function parseDate(str) { + const [y, m, d] = str.split("-").map(Number); + return new Date(Date.UTC(y, m - 1, d)); +} + +function formatDate(date) { + return date.toISOString().slice(0, 10); +} + +function addDays(date, days) { + const d = new Date(date); + d.setUTCDate(d.getUTCDate() + days); + return d; +} + +function isWeekday(date) { + const day = date.getUTCDay(); + return day !== 0 && day !== 6; +} + +// Anzahl Werktage (Mo-Fr) zwischen start und end (inklusive beider Enden) +function countWorkdays(startStr, endStr) { + let start = parseDate(startStr); + let end = parseDate(endStr); + if (start > end) return 0; + let count = 0; + let cur = start; + while (cur <= end) { + if (isWeekday(cur)) count++; + cur = addDays(cur, 1); + } + return count; +} + +// Ueberschneidung zweier Datumsbereiche in Werktagen +function overlapWorkdays(aStart, aEnd, bStart, bEnd) { + const start = parseDate(aStart) > parseDate(bStart) ? aStart : bStart; + const end = parseDate(aEnd) < parseDate(bEnd) ? aEnd : bEnd; + if (parseDate(start) > parseDate(end)) return 0; + return countWorkdays(start, end); +} + +// Liste von Monaten (YYYY-MM) zwischen start und end (inklusive) +function monthsInRange(startStr, endStr) { + const start = parseDate(startStr); + const end = parseDate(endStr); + const months = []; + let cur = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), 1)); + const last = new Date(Date.UTC(end.getUTCFullYear(), end.getUTCMonth(), 1)); + while (cur <= last) { + const y = cur.getUTCFullYear(); + const m = String(cur.getUTCMonth() + 1).padStart(2, "0"); + months.push(`${y}-${m}`); + cur = new Date(Date.UTC(y, cur.getUTCMonth() + 1, 1)); + } + return months; +} + +function monthBounds(monthStr) { + const [y, m] = monthStr.split("-").map(Number); + const start = new Date(Date.UTC(y, m - 1, 1)); + const end = new Date(Date.UTC(y, m, 0)); + return { start: formatDate(start), end: formatDate(end) }; +} + +module.exports = { + parseDate, + formatDate, + addDays, + isWeekday, + countWorkdays, + overlapWorkdays, + monthsInRange, + monthBounds +}; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c6a054b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,24 @@ +services: + jahresplanung: + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + ports: + - "3000:3000" + environment: + - PORT=3000 + - MONGODB_URI=mongodb://mongo:27017/jahresplanung + depends_on: + - mongo + + mongo: + image: mongo:7 + restart: unless-stopped + # Kein "ports:" Eintrag: MongoDB ist nur innerhalb des Compose-Netzwerks + # erreichbar (fuer den jahresplanung-Service), nicht von aussen. + volumes: + - jahresplanung-mongo-data:/data/db + +volumes: + jahresplanung-mongo-data: diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..ff5b3c5 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,18 @@ + + + + + + Jahresplanung & Kapazitaet + + + + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..c7883f1 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,19 @@ +{ + "name": "jahresplanung-frontend", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.1", + "vite": "^5.4.1" + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..a4e9dbd --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,109 @@ +import React, { useEffect, useState, useCallback } from "react"; +import { api } from "./api"; +import ProjectsTab from "./components/ProjectsTab.jsx"; +import PeopleTab from "./components/PeopleTab.jsx"; +import TimelineTab from "./components/TimelineTab.jsx"; +import CapacityTab from "./components/CapacityTab.jsx"; + +const NAV = [ + { key: "timeline", index: "01", label: "Zeitachse" }, + { key: "capacity", index: "02", label: "Kapazitaet" }, + { key: "projects", index: "03", label: "Projekte & Aufgaben" }, + { key: "people", index: "04", label: "Team" } +]; + +export default function App() { + const [view, setView] = useState("timeline"); + const [people, setPeople] = useState([]); + const [projects, setProjects] = useState([]); + const [tasks, setTasks] = useState([]); + const [absences, setAbsences] = useState([]); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + + const reloadAll = useCallback(async () => { + try { + const [p, pr, t, a] = await Promise.all([ + api.people.list(), + api.projects.list(), + api.tasks.list(), + api.absences.list() + ]); + setPeople(p); + setProjects(pr); + setTasks(t); + setAbsences(a); + setLoadError(null); + } catch (err) { + setLoadError(err.message); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + reloadAll(); + }, [reloadAll]); + + const currentYear = new Date().getFullYear(); + + return ( +
+ + +
+ {loadError && ( +
+

+ Daten konnten nicht geladen werden: {loadError} +

+
+ )} + + {!loading && view === "timeline" && ( + + )} + + {!loading && view === "capacity" && ( + + )} + + {!loading && view === "projects" && ( + + )} + + {!loading && view === "people" && ( + + )} +
+
+ ); +} diff --git a/frontend/src/api.js b/frontend/src/api.js new file mode 100644 index 0000000..e1fe19f --- /dev/null +++ b/frontend/src/api.js @@ -0,0 +1,52 @@ +const BASE = "/api"; + +async function request(path, options = {}) { + const res = await fetch(`${BASE}${path}`, { + headers: { "Content-Type": "application/json" }, + ...options + }); + if (!res.ok) { + let message = `Fehler ${res.status}`; + try { + const body = await res.json(); + if (body.error) message = body.error; + } catch (_) { + /* ignore */ + } + throw new Error(message); + } + if (res.status === 204) return null; + return res.json(); +} + +export const api = { + people: { + list: () => request("/people"), + create: (data) => request("/people", { method: "POST", body: JSON.stringify(data) }), + update: (id, data) => + request(`/people/${id}`, { method: "PUT", body: JSON.stringify(data) }), + remove: (id) => request(`/people/${id}`, { method: "DELETE" }) + }, + projects: { + list: () => request("/projects"), + create: (data) => request("/projects", { method: "POST", body: JSON.stringify(data) }), + update: (id, data) => + request(`/projects/${id}`, { method: "PUT", body: JSON.stringify(data) }), + remove: (id) => request(`/projects/${id}`, { method: "DELETE" }) + }, + tasks: { + list: () => request("/tasks"), + create: (data) => request("/tasks", { method: "POST", body: JSON.stringify(data) }), + update: (id, data) => + request(`/tasks/${id}`, { method: "PUT", body: JSON.stringify(data) }), + remove: (id) => request(`/tasks/${id}`, { method: "DELETE" }) + }, + absences: { + list: () => request("/absences"), + create: (data) => request("/absences", { method: "POST", body: JSON.stringify(data) }), + remove: (id) => request(`/absences/${id}`, { method: "DELETE" }) + }, + capacity: { + get: (from, to) => request(`/capacity?from=${from}&to=${to}`) + } +}; diff --git a/frontend/src/components/AbsenceForm.jsx b/frontend/src/components/AbsenceForm.jsx new file mode 100644 index 0000000..1e96f9e --- /dev/null +++ b/frontend/src/components/AbsenceForm.jsx @@ -0,0 +1,95 @@ +import React, { useState } from "react"; +import { api } from "../api"; + +export default function AbsenceForm({ people, onClose, onSaved }) { + const [personId, setPersonId] = useState(people[0]?.id || ""); + const [startDate, setStartDate] = useState(""); + const [endDate, setEndDate] = useState(""); + const [note, setNote] = useState(""); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + + async function submit(e) { + e.preventDefault(); + setError(null); + if (!personId || !startDate || !endDate) { + setError("Bitte Person, Start- und Enddatum angeben."); + return; + } + if (startDate > endDate) { + setError("Das Startdatum muss vor dem Enddatum liegen."); + return; + } + setSaving(true); + try { + await api.absences.create({ personId, startDate, endDate, note }); + onSaved(); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + return ( +
+
e.stopPropagation()}> +

Abwesenheit eintragen

+
+
+ + +
+
+
+ + setStartDate(e.target.value)} + /> +
+
+ + setEndDate(e.target.value)} + /> +
+
+
+ + setNote(e.target.value)} + placeholder="z. B. Urlaub, Feiertag" + /> +
+ {error &&

{error}

} +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/components/CapacityTab.jsx b/frontend/src/components/CapacityTab.jsx new file mode 100644 index 0000000..c8a56d4 --- /dev/null +++ b/frontend/src/components/CapacityTab.jsx @@ -0,0 +1,134 @@ +import React, { useState, useEffect, useCallback } from "react"; +import { api } from "../api"; + +const MONTH_LABELS = { + "01": "Jan", "02": "Feb", "03": "Mär", "04": "Apr", "05": "Mai", "06": "Jun", + "07": "Jul", "08": "Aug", "09": "Sep", "10": "Okt", "11": "Nov", "12": "Dez" +}; + +function barClass(month) { + if (month.overloaded) return "danger"; + if (month.utilization >= 90) return "warn"; + return "ok"; +} + +export default function CapacityTab({ people }) { + const [year, setYear] = useState(new Date().getFullYear()); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + try { + const res = await api.capacity.get(`${year}-01-01`, `${year}-12-31`); + setData(res); + setError(null); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }, [year]); + + useEffect(() => { + load(); + }, [load]); + + return ( +
+
+

Kapazitaet

+

+ Zugewiesene Stunden je Person und Monat gegen die verfuegbare Nettokapazitaet + (Wochenstunden minus Abwesenheiten). Die gestrichelte Linie markiert 100 % + Auslastung. +

+
+ +
+
+
+ + {year} + +
+
+ + {error &&

{error}

} + + {!loading && people.length === 0 && ( +

+ Noch keine Personen angelegt. Lege zuerst Team-Mitglieder mit Wochenkapazitaet an. +

+ )} + + {!loading && data && people.length > 0 && ( +
+ {data.people.map((person) => { + const maxScale = Math.max( + 1, + ...person.months.map((m) => Math.max(m.capacityHours, m.assignedHours)) + ); + return ( +
+
+ {person.name} + {person.weeklyHours}h / Woche +
+
+ {person.months.map((m) => { + const capPct = (m.capacityHours / maxScale) * 100; + const assignedPct = (m.assignedHours / maxScale) * 100; + const cls = barClass(m); + return ( +
+
{MONTH_LABELS[m.month.slice(5)]}
+
+
+
+
+
+ {m.utilization}% +
+
+ ); + })} +
+
+ ); + })} +
+ )} + +
+ + Im Rahmen + + + Ab 90 % ausgelastet + + + Ueberlast + + + + 100 % Kapazitaet + +
+
+
+ ); +} diff --git a/frontend/src/components/PeopleTab.jsx b/frontend/src/components/PeopleTab.jsx new file mode 100644 index 0000000..32456fe --- /dev/null +++ b/frontend/src/components/PeopleTab.jsx @@ -0,0 +1,167 @@ +import React, { useState } from "react"; +import { api } from "../api"; +import PersonForm from "./PersonForm.jsx"; +import AbsenceForm from "./AbsenceForm.jsx"; + +export default function PeopleTab({ people, absences, onChange }) { + const [editingPerson, setEditingPerson] = useState(undefined); // undefined = closed + const [showAbsenceForm, setShowAbsenceForm] = useState(false); + + async function removePerson(id) { + if (!confirm("Diese Person inklusive ihrer Abwesenheiten wirklich entfernen?")) return; + await api.people.remove(id); + onChange(); + } + + async function removeAbsence(id) { + await api.absences.remove(id); + onChange(); + } + + function personName(id) { + return people.find((p) => p.id === id)?.name || "Unbekannt"; + } + + const sortedAbsences = [...absences].sort((a, b) => + a.startDate.localeCompare(b.startDate) + ); + + return ( +
+
+

Team

+

+ Wochenkapazitaet pro Person hinterlegen. Sie bildet die Grundlage fuer die + Auslastungsberechnung auf der Kapazitaet-Ansicht. +

+
+ +
+
+

+ Personen ({people.length}) +

+ +
+ + + + + + + + + + + {people.length === 0 && ( + + + + )} + {people.map((p) => ( + + + + + + + ))} + +
NameRolleStd. / Woche
+ Noch keine Personen angelegt. +
+ + + {p.name} + + {p.role || "\u2013"}{p.weeklyHours}h + + +
+
+ +
+
+

+ Abwesenheiten ({absences.length}) +

+ +
+ + + + + + + + + + + + {sortedAbsences.length === 0 && ( + + + + )} + {sortedAbsences.map((a) => ( + + + + + + + + ))} + +
PersonVonBisNotiz
+ Keine Abwesenheiten hinterlegt (Urlaub, Feiertage, etc.). +
{personName(a.personId)}{a.startDate}{a.endDate}{a.note || "\u2013"} + +
+
+ + {editingPerson !== undefined && ( + setEditingPerson(undefined)} + onSaved={() => { + setEditingPerson(undefined); + onChange(); + }} + /> + )} + + {showAbsenceForm && ( + setShowAbsenceForm(false)} + onSaved={() => { + setShowAbsenceForm(false); + onChange(); + }} + /> + )} +
+ ); +} diff --git a/frontend/src/components/PersonForm.jsx b/frontend/src/components/PersonForm.jsx new file mode 100644 index 0000000..c073b32 --- /dev/null +++ b/frontend/src/components/PersonForm.jsx @@ -0,0 +1,108 @@ +import React, { useState } from "react"; +import { api } from "../api"; + +const PALETTE = ["#2F6F4F", "#C4622D", "#3E5C50", "#8A6D3B", "#4C5B7A", "#A63B2A"]; + +export default function PersonForm({ person, onClose, onSaved }) { + const [name, setName] = useState(person?.name || ""); + const [role, setRole] = useState(person?.role || ""); + const [weeklyHours, setWeeklyHours] = useState(person?.weeklyHours ?? 40); + const [color, setColor] = useState(person?.color || PALETTE[0]); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + + async function submit(e) { + e.preventDefault(); + setError(null); + if (!name.trim()) { + setError("Bitte einen Namen eingeben."); + return; + } + setSaving(true); + try { + const payload = { name, role, weeklyHours: Number(weeklyHours), color }; + if (person) { + await api.people.update(person.id, payload); + } else { + await api.people.create(payload); + } + onSaved(); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + return ( +
+
e.stopPropagation()}> +

{person ? "Person bearbeiten" : "Person hinzufuegen"}

+
+
+ + setName(e.target.value)} + autoFocus + /> +
+
+
+ + setRole(e.target.value)} + placeholder="z. B. Entwicklerin" + /> +
+
+ + setWeeklyHours(e.target.value)} + /> +
+
+
+ +
+ {PALETTE.map((c) => ( +
+
+ {error &&

{error}

} +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/components/ProjectForm.jsx b/frontend/src/components/ProjectForm.jsx new file mode 100644 index 0000000..62bc1fc --- /dev/null +++ b/frontend/src/components/ProjectForm.jsx @@ -0,0 +1,94 @@ +import React, { useState } from "react"; +import { api } from "../api"; + +const PALETTE = ["#3E5C50", "#2F6F4F", "#8A6D3B", "#4C5B7A", "#A63B2A", "#C4622D"]; + +export default function ProjectForm({ project, onClose, onSaved }) { + const [name, setName] = useState(project?.name || ""); + const [description, setDescription] = useState(project?.description || ""); + const [color, setColor] = useState(project?.color || PALETTE[0]); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + + async function submit(e) { + e.preventDefault(); + setError(null); + if (!name.trim()) { + setError("Bitte einen Projektnamen eingeben."); + return; + } + setSaving(true); + try { + const payload = { name, description, color }; + if (project) { + await api.projects.update(project.id, payload); + } else { + await api.projects.create(payload); + } + onSaved(); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + return ( +
+
e.stopPropagation()}> +

{project ? "Projekt bearbeiten" : "Projekt anlegen"}

+
+
+ + setName(e.target.value)} + autoFocus + /> +
+
+ +