This commit is contained in:
2026-07-02 22:13:33 +02:00
parent b343ad8a38
commit ba6c35f76d
30 changed files with 2752 additions and 2 deletions
+18
View File
@@ -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 &amp; 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>
+19
View File
@@ -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"
}
}
+109
View File
@@ -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 &amp; 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 &middot; {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>
);
}
+52
View File
@@ -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}`)
}
};
+95
View File
@@ -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>
);
}
+134
View File
@@ -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)}>
&larr;
</button>
<strong style={{ fontFamily: "var(--font-mono)", fontSize: 15 }}>{year}</strong>
<button className="btn btn-small" onClick={() => setYear((y) => y + 1)}>
&rarr;
</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>
);
}
+167
View File
@@ -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>
);
}
+108
View File
@@ -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>
);
}
+94
View File
@@ -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>
);
}
+178
View File
@@ -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 &amp; 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 &bdquo;Projekt anlegen&ldquo;.
</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>
);
}
+177
View File
@@ -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>
);
}
+121
View File
@@ -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)}>
&larr;
</button>
<strong style={{ fontFamily: "var(--font-mono)", fontSize: 15 }}>{year}</strong>
<button className="btn btn-small" onClick={() => setYear((y) => y + 1)}>
&rarr;
</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"} &middot;{" "}
{task.estimatedHours}h
</span>
</div>
<div className="timeline-track">
<div className="timeline-bar" style={barStyle(task)} title={task.name} />
</div>
</div>
))}
</div>
))}
</div>
</div>
);
}
+10
View File
@@ -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>
);
+617
View File
@@ -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;
}
}
+15
View File
@@ -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"
}
}
});