initial
This commit is contained in:
+25
@@ -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"]
|
||||||
@@ -1,3 +1,106 @@
|
|||||||
# ksr
|
# Jahresplanung & Kapazitaetsplanung
|
||||||
|
|
||||||
Projekt- und Ressourcenplanung für den Kundenservice DBI
|
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.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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();
|
||||||
|
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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:
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Jahresplanung & Kapazitaet</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600&family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="app-shell">
|
||||||
|
<aside className="sidebar">
|
||||||
|
<div className="brand">
|
||||||
|
<span className="brand-mark">Jahresplan</span>
|
||||||
|
<span className="brand-sub">Planung & Kapazitaet {currentYear}</span>
|
||||||
|
</div>
|
||||||
|
<ul className="nav-list">
|
||||||
|
{NAV.map((item) => (
|
||||||
|
<li key={item.key}>
|
||||||
|
<button
|
||||||
|
className={`nav-item ${view === item.key ? "active" : ""}`}
|
||||||
|
onClick={() => setView(item.key)}
|
||||||
|
>
|
||||||
|
<span className="nav-index">{item.index}</span>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="sidebar-foot">
|
||||||
|
{people.length} Personen · {projects.length} Projekte
|
||||||
|
<br />
|
||||||
|
{tasks.length} Aufgaben insgesamt
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="main">
|
||||||
|
{loadError && (
|
||||||
|
<div className="panel" style={{ borderColor: "var(--danger)" }}>
|
||||||
|
<p className="error-text" style={{ margin: 0 }}>
|
||||||
|
Daten konnten nicht geladen werden: {loadError}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && view === "timeline" && (
|
||||||
|
<TimelineTab projects={projects} tasks={tasks} people={people} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && view === "capacity" && (
|
||||||
|
<CapacityTab people={people} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && view === "projects" && (
|
||||||
|
<ProjectsTab
|
||||||
|
projects={projects}
|
||||||
|
tasks={tasks}
|
||||||
|
people={people}
|
||||||
|
onChange={reloadAll}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && view === "people" && (
|
||||||
|
<PeopleTab people={people} absences={absences} onChange={reloadAll} />
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}`)
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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 (
|
||||||
|
<div className="form-modal-backdrop" onMouseDown={onClose}>
|
||||||
|
<div className="form-modal" onMouseDown={(e) => e.stopPropagation()}>
|
||||||
|
<h3>Abwesenheit eintragen</h3>
|
||||||
|
<form onSubmit={submit}>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="a-person">Person</label>
|
||||||
|
<select
|
||||||
|
id="a-person"
|
||||||
|
value={personId}
|
||||||
|
onChange={(e) => setPersonId(e.target.value)}
|
||||||
|
>
|
||||||
|
{people.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="field-row">
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="a-start">Von</label>
|
||||||
|
<input
|
||||||
|
id="a-start"
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="a-end">Bis</label>
|
||||||
|
<input
|
||||||
|
id="a-end"
|
||||||
|
type="date"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="a-note">Notiz (optional)</label>
|
||||||
|
<input
|
||||||
|
id="a-note"
|
||||||
|
value={note}
|
||||||
|
onChange={(e) => setNote(e.target.value)}
|
||||||
|
placeholder="z. B. Urlaub, Feiertag"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="error-text">{error}</p>}
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="button" className="btn" onClick={onClose}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||||
|
{saving ? "Speichert..." : "Eintragen"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div>
|
||||||
|
<div className="view-header">
|
||||||
|
<h1 className="view-title">Kapazitaet</h1>
|
||||||
|
<p className="view-desc">
|
||||||
|
Zugewiesene Stunden je Person und Monat gegen die verfuegbare Nettokapazitaet
|
||||||
|
(Wochenstunden minus Abwesenheiten). Die gestrichelte Linie markiert 100 %
|
||||||
|
Auslastung.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="panel">
|
||||||
|
<div className="toolbar">
|
||||||
|
<div className="range-picker">
|
||||||
|
<button className="btn btn-small" onClick={() => setYear((y) => y - 1)}>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
<strong style={{ fontFamily: "var(--font-mono)", fontSize: 15 }}>{year}</strong>
|
||||||
|
<button className="btn btn-small" onClick={() => setYear((y) => y + 1)}>
|
||||||
|
→
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="error-text">{error}</p>}
|
||||||
|
|
||||||
|
{!loading && people.length === 0 && (
|
||||||
|
<p className="empty-row">
|
||||||
|
Noch keine Personen angelegt. Lege zuerst Team-Mitglieder mit Wochenkapazitaet an.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && data && people.length > 0 && (
|
||||||
|
<div className="ledger">
|
||||||
|
{data.people.map((person) => {
|
||||||
|
const maxScale = Math.max(
|
||||||
|
1,
|
||||||
|
...person.months.map((m) => Math.max(m.capacityHours, m.assignedHours))
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className="ledger-person" key={person.personId}>
|
||||||
|
<div className="ledger-person-head">
|
||||||
|
<span className="name">{person.name}</span>
|
||||||
|
<span className="capacity-note">{person.weeklyHours}h / Woche</span>
|
||||||
|
</div>
|
||||||
|
<div className="ledger-months">
|
||||||
|
{person.months.map((m) => {
|
||||||
|
const capPct = (m.capacityHours / maxScale) * 100;
|
||||||
|
const assignedPct = (m.assignedHours / maxScale) * 100;
|
||||||
|
const cls = barClass(m);
|
||||||
|
return (
|
||||||
|
<div className="ledger-month" key={m.month}>
|
||||||
|
<div className="ledger-month-label">{MONTH_LABELS[m.month.slice(5)]}</div>
|
||||||
|
<div className="ledger-bar-well">
|
||||||
|
<div
|
||||||
|
className="ledger-baseline"
|
||||||
|
style={{ top: `${100 - capPct}%` }}
|
||||||
|
title={`Kapazitaet: ${m.capacityHours}h`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`ledger-bar-fill ${cls}`}
|
||||||
|
style={{ height: `${Math.min(assignedPct, 100)}%` }}
|
||||||
|
title={`${m.assignedHours}h zugewiesen`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={`ledger-pct ${m.overloaded ? "danger" : ""}`}>
|
||||||
|
{m.utilization}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="legend">
|
||||||
|
<span className="legend-item">
|
||||||
|
<span className="legend-swatch" style={{ background: "var(--accent)" }} /> Im Rahmen
|
||||||
|
</span>
|
||||||
|
<span className="legend-item">
|
||||||
|
<span className="legend-swatch" style={{ background: "var(--warn)" }} /> Ab 90 % ausgelastet
|
||||||
|
</span>
|
||||||
|
<span className="legend-item">
|
||||||
|
<span className="legend-swatch" style={{ background: "var(--danger)" }} /> Ueberlast
|
||||||
|
</span>
|
||||||
|
<span className="legend-item">
|
||||||
|
<span style={{ borderTop: "1.5px dashed var(--ink-soft)", width: 14, display: "inline-block" }} />
|
||||||
|
100 % Kapazitaet
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div>
|
||||||
|
<div className="view-header">
|
||||||
|
<h1 className="view-title">Team</h1>
|
||||||
|
<p className="view-desc">
|
||||||
|
Wochenkapazitaet pro Person hinterlegen. Sie bildet die Grundlage fuer die
|
||||||
|
Auslastungsberechnung auf der Kapazitaet-Ansicht.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="panel">
|
||||||
|
<div className="toolbar">
|
||||||
|
<h2 className="panel-title" style={{ margin: 0 }}>
|
||||||
|
Personen ({people.length})
|
||||||
|
</h2>
|
||||||
|
<button className="btn btn-primary" onClick={() => setEditingPerson(null)}>
|
||||||
|
+ Person hinzufuegen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Rolle</th>
|
||||||
|
<th>Std. / Woche</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{people.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="empty-row">
|
||||||
|
Noch keine Personen angelegt.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{people.map((p) => (
|
||||||
|
<tr key={p.id}>
|
||||||
|
<td>
|
||||||
|
<span className="tag">
|
||||||
|
<span className="dot" style={{ background: p.color }} />
|
||||||
|
{p.name}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{p.role || "\u2013"}</td>
|
||||||
|
<td className="num">{p.weeklyHours}h</td>
|
||||||
|
<td style={{ textAlign: "right" }}>
|
||||||
|
<button className="btn btn-ghost btn-small" onClick={() => setEditingPerson(p)}>
|
||||||
|
Bearbeiten
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-danger-ghost btn-small"
|
||||||
|
onClick={() => removePerson(p.id)}
|
||||||
|
>
|
||||||
|
Entfernen
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="panel">
|
||||||
|
<div className="toolbar">
|
||||||
|
<h2 className="panel-title" style={{ margin: 0 }}>
|
||||||
|
Abwesenheiten ({absences.length})
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => setShowAbsenceForm(true)}
|
||||||
|
disabled={people.length === 0}
|
||||||
|
title={people.length === 0 ? "Zuerst eine Person anlegen" : ""}
|
||||||
|
>
|
||||||
|
+ Abwesenheit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Person</th>
|
||||||
|
<th>Von</th>
|
||||||
|
<th>Bis</th>
|
||||||
|
<th>Notiz</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sortedAbsences.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="empty-row">
|
||||||
|
Keine Abwesenheiten hinterlegt (Urlaub, Feiertage, etc.).
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{sortedAbsences.map((a) => (
|
||||||
|
<tr key={a.id}>
|
||||||
|
<td>{personName(a.personId)}</td>
|
||||||
|
<td className="num">{a.startDate}</td>
|
||||||
|
<td className="num">{a.endDate}</td>
|
||||||
|
<td>{a.note || "\u2013"}</td>
|
||||||
|
<td style={{ textAlign: "right" }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-danger-ghost btn-small"
|
||||||
|
onClick={() => removeAbsence(a.id)}
|
||||||
|
>
|
||||||
|
Entfernen
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editingPerson !== undefined && (
|
||||||
|
<PersonForm
|
||||||
|
person={editingPerson}
|
||||||
|
onClose={() => setEditingPerson(undefined)}
|
||||||
|
onSaved={() => {
|
||||||
|
setEditingPerson(undefined);
|
||||||
|
onChange();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showAbsenceForm && (
|
||||||
|
<AbsenceForm
|
||||||
|
people={people}
|
||||||
|
onClose={() => setShowAbsenceForm(false)}
|
||||||
|
onSaved={() => {
|
||||||
|
setShowAbsenceForm(false);
|
||||||
|
onChange();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="form-modal-backdrop" onMouseDown={onClose}>
|
||||||
|
<div className="form-modal" onMouseDown={(e) => e.stopPropagation()}>
|
||||||
|
<h3>{person ? "Person bearbeiten" : "Person hinzufuegen"}</h3>
|
||||||
|
<form onSubmit={submit}>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="p-name">Name</label>
|
||||||
|
<input
|
||||||
|
id="p-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field-row">
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="p-role">Rolle</label>
|
||||||
|
<input
|
||||||
|
id="p-role"
|
||||||
|
value={role}
|
||||||
|
onChange={(e) => setRole(e.target.value)}
|
||||||
|
placeholder="z. B. Entwicklerin"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="p-hours">Stunden / Woche</label>
|
||||||
|
<input
|
||||||
|
id="p-hours"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="60"
|
||||||
|
value={weeklyHours}
|
||||||
|
onChange={(e) => setWeeklyHours(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Farbe</label>
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
{PALETTE.map((c) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={c}
|
||||||
|
onClick={() => setColor(c)}
|
||||||
|
aria-label={`Farbe ${c} waehlen`}
|
||||||
|
style={{
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: c,
|
||||||
|
border:
|
||||||
|
color === c ? "2px solid var(--ink)" : "2px solid transparent",
|
||||||
|
cursor: "pointer"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && <p className="error-text">{error}</p>}
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="button" className="btn" onClick={onClose}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||||
|
{saving ? "Speichert..." : "Speichern"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="form-modal-backdrop" onMouseDown={onClose}>
|
||||||
|
<div className="form-modal" onMouseDown={(e) => e.stopPropagation()}>
|
||||||
|
<h3>{project ? "Projekt bearbeiten" : "Projekt anlegen"}</h3>
|
||||||
|
<form onSubmit={submit}>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="pr-name">Projektname</label>
|
||||||
|
<input
|
||||||
|
id="pr-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="pr-desc">Beschreibung (optional)</label>
|
||||||
|
<textarea
|
||||||
|
id="pr-desc"
|
||||||
|
rows={2}
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Farbe</label>
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
{PALETTE.map((c) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={c}
|
||||||
|
onClick={() => setColor(c)}
|
||||||
|
aria-label={`Farbe ${c} waehlen`}
|
||||||
|
style={{
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: c,
|
||||||
|
border:
|
||||||
|
color === c ? "2px solid var(--ink)" : "2px solid transparent",
|
||||||
|
cursor: "pointer"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && <p className="error-text">{error}</p>}
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="button" className="btn" onClick={onClose}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||||
|
{saving ? "Speichert..." : "Speichern"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { api } from "../api";
|
||||||
|
import ProjectForm from "./ProjectForm.jsx";
|
||||||
|
import TaskForm from "./TaskForm.jsx";
|
||||||
|
|
||||||
|
const PRIORITY_LABEL = { hoch: "Hoch", mittel: "Mittel", niedrig: "Niedrig" };
|
||||||
|
|
||||||
|
export default function ProjectsTab({ projects, tasks, people, onChange }) {
|
||||||
|
const [editingProject, setEditingProject] = useState(undefined);
|
||||||
|
const [editingTask, setEditingTask] = useState(undefined);
|
||||||
|
const [taskProjectHint, setTaskProjectHint] = useState(null);
|
||||||
|
|
||||||
|
async function removeProject(id) {
|
||||||
|
if (!confirm("Projekt inklusive aller zugehoerigen Aufgaben wirklich loeschen?")) return;
|
||||||
|
await api.projects.remove(id);
|
||||||
|
onChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeTask(id) {
|
||||||
|
await api.tasks.remove(id);
|
||||||
|
onChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
function personName(id) {
|
||||||
|
return people.find((p) => p.id === id)?.name || "\u2013";
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasksByProject = (projectId) =>
|
||||||
|
tasks
|
||||||
|
.filter((t) => t.projectId === projectId)
|
||||||
|
.sort((a, b) => a.startDate.localeCompare(b.startDate));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="view-header">
|
||||||
|
<h1 className="view-title">Projekte & Aufgaben</h1>
|
||||||
|
<p className="view-desc">
|
||||||
|
Projekte in Aufgaben mit Start- und Enddatum, Aufwand und Zustaendigkeit
|
||||||
|
zerlegen. Abhaengigkeiten koennen pro Aufgabe hinterlegt werden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="toolbar">
|
||||||
|
<div />
|
||||||
|
<button className="btn btn-primary" onClick={() => setEditingProject(null)}>
|
||||||
|
+ Projekt anlegen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{projects.length === 0 && (
|
||||||
|
<div className="panel">
|
||||||
|
<p className="empty-row" style={{ padding: 0 }}>
|
||||||
|
Noch keine Projekte angelegt. Starte mit „Projekt anlegen“.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{projects.map((project) => (
|
||||||
|
<div className="panel" key={project.id}>
|
||||||
|
<div className="toolbar">
|
||||||
|
<div>
|
||||||
|
<h2 className="panel-title" style={{ margin: 0, display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<span className="dot" style={{ background: project.color, width: 10, height: 10 }} />
|
||||||
|
{project.name}
|
||||||
|
</h2>
|
||||||
|
{project.description && (
|
||||||
|
<p style={{ margin: "4px 0 0", color: "var(--ink-soft)", fontSize: 13 }}>
|
||||||
|
{project.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 6 }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-small"
|
||||||
|
onClick={() => {
|
||||||
|
setTaskProjectHint(project.id);
|
||||||
|
setEditingTask(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ Aufgabe
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-ghost btn-small" onClick={() => setEditingProject(project)}>
|
||||||
|
Bearbeiten
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-danger-ghost btn-small"
|
||||||
|
onClick={() => removeProject(project.id)}
|
||||||
|
>
|
||||||
|
Loeschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Aufgabe</th>
|
||||||
|
<th>Start</th>
|
||||||
|
<th>Ende</th>
|
||||||
|
<th>Aufwand</th>
|
||||||
|
<th>Zustaendig</th>
|
||||||
|
<th>Prioritaet</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{tasksByProject(project.id).length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="empty-row">
|
||||||
|
Noch keine Aufgaben in diesem Projekt.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{tasksByProject(project.id).map((task) => (
|
||||||
|
<tr key={task.id}>
|
||||||
|
<td>{task.name}</td>
|
||||||
|
<td className="num">{task.startDate}</td>
|
||||||
|
<td className="num">{task.endDate}</td>
|
||||||
|
<td className="num">{task.estimatedHours}h</td>
|
||||||
|
<td>{personName(task.assigneeId)}</td>
|
||||||
|
<td>{PRIORITY_LABEL[task.priority] || task.priority}</td>
|
||||||
|
<td>{task.status}</td>
|
||||||
|
<td style={{ textAlign: "right", whiteSpace: "nowrap" }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost btn-small"
|
||||||
|
onClick={() => {
|
||||||
|
setTaskProjectHint(project.id);
|
||||||
|
setEditingTask(task);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Bearbeiten
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-danger-ghost btn-small"
|
||||||
|
onClick={() => removeTask(task.id)}
|
||||||
|
>
|
||||||
|
Loeschen
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{editingProject !== undefined && (
|
||||||
|
<ProjectForm
|
||||||
|
project={editingProject}
|
||||||
|
onClose={() => setEditingProject(undefined)}
|
||||||
|
onSaved={() => {
|
||||||
|
setEditingProject(undefined);
|
||||||
|
onChange();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editingTask !== undefined && (
|
||||||
|
<TaskForm
|
||||||
|
task={editingTask || (taskProjectHint ? { projectId: taskProjectHint } : undefined)}
|
||||||
|
projects={projects}
|
||||||
|
people={people}
|
||||||
|
tasks={tasks}
|
||||||
|
onClose={() => {
|
||||||
|
setEditingTask(undefined);
|
||||||
|
setTaskProjectHint(null);
|
||||||
|
}}
|
||||||
|
onSaved={() => {
|
||||||
|
setEditingTask(undefined);
|
||||||
|
setTaskProjectHint(null);
|
||||||
|
onChange();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { api } from "../api";
|
||||||
|
|
||||||
|
export default function TaskForm({ task, projects, people, tasks, onClose, onSaved }) {
|
||||||
|
const [projectId, setProjectId] = useState(task?.projectId || projects[0]?.id || "");
|
||||||
|
const [name, setName] = useState(task?.name || "");
|
||||||
|
const [startDate, setStartDate] = useState(task?.startDate || "");
|
||||||
|
const [endDate, setEndDate] = useState(task?.endDate || "");
|
||||||
|
const [estimatedHours, setEstimatedHours] = useState(task?.estimatedHours ?? 8);
|
||||||
|
const [assigneeId, setAssigneeId] = useState(task?.assigneeId || "");
|
||||||
|
const [priority, setPriority] = useState(task?.priority || "mittel");
|
||||||
|
const [status, setStatus] = useState(task?.status || "geplant");
|
||||||
|
const [dependsOn, setDependsOn] = useState(task?.dependsOn || "");
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const dependencyOptions = tasks.filter((t) => t.id !== task?.id);
|
||||||
|
|
||||||
|
async function submit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
if (!projectId || !name.trim() || !startDate || !endDate) {
|
||||||
|
setError("Projekt, Name, Start- und Enddatum sind erforderlich.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (startDate > endDate) {
|
||||||
|
setError("Das Startdatum muss vor dem Enddatum liegen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
projectId,
|
||||||
|
name,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
estimatedHours: Number(estimatedHours),
|
||||||
|
assigneeId: assigneeId || null,
|
||||||
|
priority,
|
||||||
|
status,
|
||||||
|
dependsOn: dependsOn || null
|
||||||
|
};
|
||||||
|
if (task?.id) {
|
||||||
|
await api.tasks.update(task.id, payload);
|
||||||
|
} else {
|
||||||
|
await api.tasks.create(payload);
|
||||||
|
}
|
||||||
|
onSaved();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="form-modal-backdrop" onMouseDown={onClose}>
|
||||||
|
<div className="form-modal" onMouseDown={(e) => e.stopPropagation()}>
|
||||||
|
<h3>{task?.id ? "Aufgabe bearbeiten" : "Aufgabe anlegen"}</h3>
|
||||||
|
<form onSubmit={submit}>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="t-project">Projekt</label>
|
||||||
|
<select
|
||||||
|
id="t-project"
|
||||||
|
value={projectId}
|
||||||
|
onChange={(e) => setProjectId(e.target.value)}
|
||||||
|
>
|
||||||
|
{projects.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="t-name">Aufgabe</label>
|
||||||
|
<input id="t-name" value={name} onChange={(e) => setName(e.target.value)} autoFocus />
|
||||||
|
</div>
|
||||||
|
<div className="field-row">
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="t-start">Start</label>
|
||||||
|
<input
|
||||||
|
id="t-start"
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="t-end">Ende / Faellig</label>
|
||||||
|
<input
|
||||||
|
id="t-end"
|
||||||
|
type="date"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="field-row">
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="t-hours">Aufwand (Std.)</label>
|
||||||
|
<input
|
||||||
|
id="t-hours"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.5"
|
||||||
|
value={estimatedHours}
|
||||||
|
onChange={(e) => setEstimatedHours(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="t-assignee">Zustaendig</label>
|
||||||
|
<select
|
||||||
|
id="t-assignee"
|
||||||
|
value={assigneeId}
|
||||||
|
onChange={(e) => setAssigneeId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Nicht zugewiesen</option>
|
||||||
|
{people.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="field-row">
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="t-priority">Prioritaet</label>
|
||||||
|
<select
|
||||||
|
id="t-priority"
|
||||||
|
value={priority}
|
||||||
|
onChange={(e) => setPriority(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="hoch">Hoch</option>
|
||||||
|
<option value="mittel">Mittel</option>
|
||||||
|
<option value="niedrig">Niedrig</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="t-status">Status</label>
|
||||||
|
<select id="t-status" value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||||
|
<option value="geplant">Geplant</option>
|
||||||
|
<option value="in Arbeit">In Arbeit</option>
|
||||||
|
<option value="erledigt">Erledigt</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="t-depends">Abhaengig von (optional)</label>
|
||||||
|
<select
|
||||||
|
id="t-depends"
|
||||||
|
value={dependsOn}
|
||||||
|
onChange={(e) => setDependsOn(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Keine Abhaengigkeit</option>
|
||||||
|
{dependencyOptions.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>
|
||||||
|
{t.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{error && <p className="error-text">{error}</p>}
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="button" className="btn" onClick={onClose}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||||
|
{saving ? "Speichert..." : "Speichern"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import React, { useState, useMemo } from "react";
|
||||||
|
|
||||||
|
const MONTH_LABELS = [
|
||||||
|
"Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"
|
||||||
|
];
|
||||||
|
|
||||||
|
function dayOfYear(dateStr, year) {
|
||||||
|
const d = new Date(`${dateStr}T00:00:00Z`);
|
||||||
|
const start = new Date(Date.UTC(year, 0, 1));
|
||||||
|
return Math.floor((d - start) / 86400000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLeap(year) {
|
||||||
|
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TimelineTab({ projects, tasks, people }) {
|
||||||
|
const [year, setYear] = useState(new Date().getFullYear());
|
||||||
|
const daysInYear = isLeap(year) ? 366 : 365;
|
||||||
|
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
return projects.map((project) => ({
|
||||||
|
project,
|
||||||
|
tasks: tasks
|
||||||
|
.filter((t) => t.projectId === project.id)
|
||||||
|
.filter((t) => {
|
||||||
|
const startYear = Number(t.startDate.slice(0, 4));
|
||||||
|
const endYear = Number(t.endDate.slice(0, 4));
|
||||||
|
return startYear <= year && endYear >= year;
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.startDate.localeCompare(b.startDate))
|
||||||
|
}));
|
||||||
|
}, [projects, tasks, year]);
|
||||||
|
|
||||||
|
function personOf(id) {
|
||||||
|
return people.find((p) => p.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function barStyle(task) {
|
||||||
|
const yearStart = `${year}-01-01`;
|
||||||
|
const yearEnd = `${year}-12-31`;
|
||||||
|
const clippedStart = task.startDate < yearStart ? yearStart : task.startDate;
|
||||||
|
const clippedEnd = task.endDate > yearEnd ? yearEnd : task.endDate;
|
||||||
|
const startDay = dayOfYear(clippedStart, year);
|
||||||
|
const endDay = dayOfYear(clippedEnd, year);
|
||||||
|
const leftPct = (startDay / daysInYear) * 100;
|
||||||
|
const widthPct = Math.max(((endDay - startDay + 1) / daysInYear) * 100, 0.6);
|
||||||
|
const person = personOf(task.assigneeId);
|
||||||
|
const project = projects.find((p) => p.id === task.projectId);
|
||||||
|
return {
|
||||||
|
left: `${leftPct}%`,
|
||||||
|
width: `${widthPct}%`,
|
||||||
|
background: person?.color || project?.color || "var(--accent)"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAnyTask = grouped.some((g) => g.tasks.length > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="view-header">
|
||||||
|
<h1 className="view-title">Zeitachse</h1>
|
||||||
|
<p className="view-desc">
|
||||||
|
Alle Aufgaben eines Jahres auf einen Blick, gruppiert nach Projekt. Balkenfarbe
|
||||||
|
folgt der zustaendigen Person (falls zugewiesen), sonst der Projektfarbe.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="panel">
|
||||||
|
<div className="toolbar">
|
||||||
|
<div className="range-picker">
|
||||||
|
<button className="btn btn-small" onClick={() => setYear((y) => y - 1)}>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
<strong style={{ fontFamily: "var(--font-mono)", fontSize: 15 }}>{year}</strong>
|
||||||
|
<button className="btn btn-small" onClick={() => setYear((y) => y + 1)}>
|
||||||
|
→
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="timeline-scale">
|
||||||
|
<div />
|
||||||
|
{MONTH_LABELS.map((m) => (
|
||||||
|
<div className="month-label" key={m}>
|
||||||
|
{m}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!hasAnyTask && (
|
||||||
|
<p className="empty-row">Keine Aufgaben in {year}. Lege Projekte und Aufgaben an.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{grouped
|
||||||
|
.filter((g) => g.tasks.length > 0)
|
||||||
|
.map((g) => (
|
||||||
|
<div key={g.project.id}>
|
||||||
|
<div className="timeline-group-title" style={{ color: g.project.color }}>
|
||||||
|
{g.project.name}
|
||||||
|
</div>
|
||||||
|
{g.tasks.map((task) => (
|
||||||
|
<div className="timeline-row" key={task.id}>
|
||||||
|
<div className="timeline-row-label">
|
||||||
|
{task.name}
|
||||||
|
<span className="proj-name">
|
||||||
|
{personOf(task.assigneeId)?.name || "nicht zugewiesen"} ·{" "}
|
||||||
|
{task.estimatedHours}h
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="timeline-track">
|
||||||
|
<div className="timeline-bar" style={barStyle(task)} title={task.name} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import App from "./App.jsx";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,617 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #f6f3ec;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-alt: #efeae0;
|
||||||
|
--ink: #20281f;
|
||||||
|
--ink-soft: #5b6355;
|
||||||
|
--line: #dad3c4;
|
||||||
|
--accent: #2f6f4f;
|
||||||
|
--accent-soft: #dce9de;
|
||||||
|
--warn: #c4622d;
|
||||||
|
--warn-soft: #f4e3d4;
|
||||||
|
--danger: #a63b2a;
|
||||||
|
--danger-soft: #f3dcd4;
|
||||||
|
--radius: 3px;
|
||||||
|
--font-display: "Fraunces", "Iowan Old Style", serif;
|
||||||
|
--font-body: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
--font-mono: "IBM Plex Mono", ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Layout ---------- */
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 232px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--surface);
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
padding: 28px 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-sub {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 14px;
|
||||||
|
transition: background 0.12s ease, color 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:hover {
|
||||||
|
background: var(--surface-alt);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-index {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
width: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active .nav-index {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-foot {
|
||||||
|
margin-top: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
padding-top: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
flex: 1;
|
||||||
|
padding: 36px 44px 60px;
|
||||||
|
max-width: 1180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-header {
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 6px;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-desc {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
margin: 0;
|
||||||
|
max-width: 640px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Cards / panels ---------- */
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 20px 22px;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Buttons ---------- */
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--ink);
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: border-color 0.12s ease, background 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--ink);
|
||||||
|
border-color: var(--ink);
|
||||||
|
color: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
border-color: transparent;
|
||||||
|
background: transparent;
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost:hover {
|
||||||
|
background: var(--surface-alt);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger-ghost {
|
||||||
|
border-color: transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--danger);
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger-ghost:hover {
|
||||||
|
background: var(--danger-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small {
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Forms ---------- */
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input,
|
||||||
|
.field select,
|
||||||
|
.field textarea {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(32, 40, 31, 0.35);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 8vh;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-modal {
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
padding: 26px 28px 22px;
|
||||||
|
width: 460px;
|
||||||
|
max-width: calc(100vw - 40px);
|
||||||
|
box-shadow: 0 18px 40px rgba(32, 40, 31, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-modal h3 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
margin: 0 0 18px;
|
||||||
|
font-size: 19px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 12px;
|
||||||
|
margin: -6px 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Tables ---------- */
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 10px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.num {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-row {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
text-align: center;
|
||||||
|
padding: 28px 10px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 3px 9px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--surface-alt);
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Timeline ---------- */
|
||||||
|
|
||||||
|
.timeline-scale {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 220px repeat(12, 1fr);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
padding-bottom: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-scale .month-label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 220px 1fr;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 40px;
|
||||||
|
border-bottom: 1px solid var(--surface-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-row-label {
|
||||||
|
padding-right: 14px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-row-label .proj-name {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 11px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-track {
|
||||||
|
position: relative;
|
||||||
|
height: 24px;
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
to right,
|
||||||
|
transparent,
|
||||||
|
transparent calc(100% / 12 - 1px),
|
||||||
|
var(--line) calc(100% / 12 - 1px),
|
||||||
|
var(--line) calc(100% / 12)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-bar {
|
||||||
|
position: absolute;
|
||||||
|
top: 3px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 3px;
|
||||||
|
min-width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-group-title {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--accent);
|
||||||
|
margin: 22px 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Capacity ledger (signature element) ---------- */
|
||||||
|
|
||||||
|
.ledger {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-person {
|
||||||
|
border-bottom: 1px solid var(--surface-alt);
|
||||||
|
padding-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-person:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-person-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-person-head .name {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-person-head .capacity-note {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-months {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(12, 1fr);
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-month {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-month-label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-bar-well {
|
||||||
|
position: relative;
|
||||||
|
height: 74px;
|
||||||
|
background: var(--surface-alt);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-baseline {
|
||||||
|
position: absolute;
|
||||||
|
left: -3px;
|
||||||
|
right: -3px;
|
||||||
|
border-top: 1.5px dashed var(--ink-soft);
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-bar-fill {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
border-radius: 2px 2px 0 0;
|
||||||
|
transition: height 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-bar-fill.ok {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-bar-fill.warn {
|
||||||
|
background: var(--warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-bar-fill.danger {
|
||||||
|
background: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-pct {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-pct.danger {
|
||||||
|
color: var(--danger);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 18px;
|
||||||
|
margin-top: 18px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-swatch {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Range picker ---------- */
|
||||||
|
|
||||||
|
.range-picker {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range-picker input {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 880px) {
|
||||||
|
.app-shell {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.sidebar {
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
padding: 14px 18px;
|
||||||
|
gap: 18px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.sidebar-foot {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.main {
|
||||||
|
padding: 24px 18px 60px;
|
||||||
|
}
|
||||||
|
.field-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.ledger-months {
|
||||||
|
grid-template-columns: repeat(6, 1fr);
|
||||||
|
row-gap: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
build: {
|
||||||
|
outDir: "../backend/public",
|
||||||
|
emptyOutDir: true
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/api": "http://localhost:3000"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user