Umbau auf Alpine.js
This commit is contained in:
@@ -1,3 +1,7 @@
|
||||
[x-cloak] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #f6f3ec;
|
||||
--surface: #ffffff;
|
||||
+523
-2
@@ -10,9 +10,530 @@
|
||||
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"
|
||||
/>
|
||||
<link rel="stylesheet" href="/css/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
<div id="app" x-data="app()" class="app-shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<span class="brand-mark">Jahresplan</span>
|
||||
<span class="brand-sub" x-text="`Planung & Kapazitaet ${currentYear}`"></span>
|
||||
</div>
|
||||
<ul class="nav-list">
|
||||
<template x-for="item in nav" :key="item.key">
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
:class="`nav-item ${view === item.key ? 'active' : ''}`"
|
||||
@click="setView(item.key)"
|
||||
>
|
||||
<span class="nav-index" x-text="item.index"></span>
|
||||
<span x-text="item.label"></span>
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
<div class="sidebar-foot">
|
||||
<span x-text="people.length"></span> Personen · <span x-text="projects.length"></span> Projekte
|
||||
<br />
|
||||
<span x-text="tasks.length"></span> Aufgaben insgesamt
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
<div class="panel" style="border-color: var(--danger);" x-show="loadError" x-cloak>
|
||||
<p class="error-text" style="margin: 0;" x-text="`Daten konnten nicht geladen werden: ${loadError}`"></p>
|
||||
</div>
|
||||
|
||||
<!-- ---------- Zeitachse ---------- -->
|
||||
<template x-if="!loading && view === 'timeline'">
|
||||
<div>
|
||||
<div class="view-header">
|
||||
<h1 class="view-title">Zeitachse</h1>
|
||||
<p class="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 class="panel">
|
||||
<div class="toolbar">
|
||||
<div class="range-picker">
|
||||
<button class="btn btn-small" @click="timelineYear--">←</button>
|
||||
<strong style="font-family: var(--font-mono); font-size: 15px;" x-text="timelineYear"></strong>
|
||||
<button class="btn btn-small" @click="timelineYear++">→</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="timeline-scale">
|
||||
<div></div>
|
||||
<template x-for="m in monthLabels" :key="m">
|
||||
<div class="month-label" x-text="m"></div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<p class="empty-row" x-show="timelineGrouped().length === 0" x-text="`Keine Aufgaben in ${timelineYear}. Lege Projekte und Aufgaben an.`"></p>
|
||||
|
||||
<template x-for="g in timelineGrouped()" :key="g.project.id">
|
||||
<div>
|
||||
<div class="timeline-group-title" :style="`color: ${g.project.color}`" x-text="g.project.name"></div>
|
||||
<template x-for="task in g.tasks" :key="task.id">
|
||||
<div class="timeline-row">
|
||||
<div class="timeline-row-label">
|
||||
<span x-text="task.name"></span>
|
||||
<span class="proj-name" x-text="`${personById(task.assigneeId)?.name || 'nicht zugewiesen'} · ${task.estimatedHours}h`"></span>
|
||||
</div>
|
||||
<div class="timeline-track">
|
||||
<div class="timeline-bar" :style="timelineBarStyle(task)" :title="task.name"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ---------- Kapazitaet ---------- -->
|
||||
<template x-if="!loading && view === 'capacity'">
|
||||
<div>
|
||||
<div class="view-header">
|
||||
<h1 class="view-title">Kapazitaet</h1>
|
||||
<p class="view-desc">
|
||||
Zugewiesene Stunden je Person und Monat gegen die verfuegbare Nettokapazitaet
|
||||
(Wochenstunden minus Abwesenheiten). Die gestrichelte Linie markiert 100 %
|
||||
Auslastung.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="toolbar">
|
||||
<div class="range-picker">
|
||||
<button class="btn btn-small" @click="capacityPrevYear()">←</button>
|
||||
<strong style="font-family: var(--font-mono); font-size: 15px;" x-text="capacityYear"></strong>
|
||||
<button class="btn btn-small" @click="capacityNextYear()">→</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="error-text" x-show="capacityError" x-text="capacityError"></p>
|
||||
|
||||
<p class="empty-row" x-show="!capacityLoading && people.length === 0">
|
||||
Noch keine Personen angelegt. Lege zuerst Team-Mitglieder mit Wochenkapazitaet an.
|
||||
</p>
|
||||
|
||||
<div class="ledger" x-show="!capacityLoading && capacityData && people.length > 0">
|
||||
<template x-for="person in (capacityData ? capacityData.people : [])" :key="person.personId">
|
||||
<div class="ledger-person">
|
||||
<div class="ledger-person-head">
|
||||
<span class="name" x-text="person.name"></span>
|
||||
<span class="capacity-note" x-text="`${person.weeklyHours}h / Woche`"></span>
|
||||
</div>
|
||||
<div class="ledger-months">
|
||||
<template x-for="m in person.months" :key="m.month">
|
||||
<div class="ledger-month">
|
||||
<div class="ledger-month-label" x-text="monthLabel(m.month)"></div>
|
||||
<div class="ledger-bar-well">
|
||||
<div
|
||||
class="ledger-baseline"
|
||||
:style="`top: ${100 - (m.capacityHours / capacityMaxScale(person)) * 100}%`"
|
||||
:title="`Kapazitaet: ${m.capacityHours}h`"
|
||||
></div>
|
||||
<div
|
||||
:class="`ledger-bar-fill ${capacityBarClass(m)}`"
|
||||
:style="`height: ${Math.min((m.assignedHours / capacityMaxScale(person)) * 100, 100)}%`"
|
||||
:title="`${m.assignedHours}h zugewiesen`"
|
||||
></div>
|
||||
</div>
|
||||
<div :class="`ledger-pct ${m.overloaded ? 'danger' : ''}`" x-text="`${m.utilization}%`"></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="legend">
|
||||
<span class="legend-item">
|
||||
<span class="legend-swatch" style="background: var(--accent)"></span> Im Rahmen
|
||||
</span>
|
||||
<span class="legend-item">
|
||||
<span class="legend-swatch" style="background: var(--warn)"></span> Ab 90 % ausgelastet
|
||||
</span>
|
||||
<span class="legend-item">
|
||||
<span class="legend-swatch" style="background: var(--danger)"></span> Ueberlast
|
||||
</span>
|
||||
<span class="legend-item">
|
||||
<span style="border-top: 1.5px dashed var(--ink-soft); width: 14px; display: inline-block;"></span>
|
||||
100 % Kapazitaet
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ---------- Projekte & Aufgaben ---------- -->
|
||||
<template x-if="!loading && view === 'projects'">
|
||||
<div>
|
||||
<div class="view-header">
|
||||
<h1 class="view-title">Projekte & Aufgaben</h1>
|
||||
<p class="view-desc">
|
||||
Projekte in Aufgaben mit Start- und Enddatum, Aufwand und Zustaendigkeit
|
||||
zerlegen. Abhaengigkeiten koennen pro Aufgabe hinterlegt werden.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<div></div>
|
||||
<button class="btn btn-primary" @click="openProjectForm()">+ Projekt anlegen</button>
|
||||
</div>
|
||||
|
||||
<div class="panel" x-show="projects.length === 0">
|
||||
<p class="empty-row" style="padding: 0;">
|
||||
Noch keine Projekte angelegt. Starte mit „Projekt anlegen“.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template x-for="project in projects" :key="project.id">
|
||||
<div class="panel">
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
<h2 class="panel-title" style="margin: 0; display: flex; align-items: center; gap: 8px;">
|
||||
<span class="dot" :style="`background: ${project.color}; width: 10px; height: 10px;`"></span>
|
||||
<span x-text="project.name"></span>
|
||||
</h2>
|
||||
<p x-show="project.description" style="margin: 4px 0 0; color: var(--ink-soft); font-size: 13px;" x-text="project.description"></p>
|
||||
</div>
|
||||
<div style="display: flex; gap: 6px;">
|
||||
<button class="btn btn-small" @click="openTaskForm(null, project.id)">+ Aufgabe</button>
|
||||
<button class="btn btn-ghost btn-small" @click="openProjectForm(project)">Bearbeiten</button>
|
||||
<button class="btn btn-danger-ghost btn-small" @click="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>
|
||||
<tr x-show="tasksByProject(project.id).length === 0">
|
||||
<td colspan="8" class="empty-row">Noch keine Aufgaben in diesem Projekt.</td>
|
||||
</tr>
|
||||
<template x-for="task in tasksByProject(project.id)" :key="task.id">
|
||||
<tr>
|
||||
<td x-text="task.name"></td>
|
||||
<td class="num" x-text="task.startDate"></td>
|
||||
<td class="num" x-text="task.endDate"></td>
|
||||
<td class="num" x-text="`${task.estimatedHours}h`"></td>
|
||||
<td x-text="assigneeName(task.assigneeId)"></td>
|
||||
<td x-text="priorityLabel[task.priority] || task.priority"></td>
|
||||
<td x-text="task.status"></td>
|
||||
<td style="text-align: right; white-space: nowrap;">
|
||||
<button class="btn btn-ghost btn-small" @click="openTaskForm(task, project.id)">Bearbeiten</button>
|
||||
<button class="btn btn-danger-ghost btn-small" @click="removeTask(task.id)">Loeschen</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ---------- Team ---------- -->
|
||||
<template x-if="!loading && view === 'people'">
|
||||
<div>
|
||||
<div class="view-header">
|
||||
<h1 class="view-title">Team</h1>
|
||||
<p class="view-desc">
|
||||
Wochenkapazitaet pro Person hinterlegen. Sie bildet die Grundlage fuer die
|
||||
Auslastungsberechnung auf der Kapazitaet-Ansicht.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="toolbar">
|
||||
<h2 class="panel-title" style="margin: 0;" x-text="`Personen (${people.length})`"></h2>
|
||||
<button class="btn btn-primary" @click="openPersonForm()">+ Person hinzufuegen</button>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Rolle</th>
|
||||
<th>Std. / Woche</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr x-show="people.length === 0">
|
||||
<td colspan="4" class="empty-row">Noch keine Personen angelegt.</td>
|
||||
</tr>
|
||||
<template x-for="p in people" :key="p.id">
|
||||
<tr>
|
||||
<td>
|
||||
<span class="tag">
|
||||
<span class="dot" :style="`background: ${p.color}`"></span>
|
||||
<span x-text="p.name"></span>
|
||||
</span>
|
||||
</td>
|
||||
<td x-text="p.role || '–'"></td>
|
||||
<td class="num" x-text="`${p.weeklyHours}h`"></td>
|
||||
<td style="text-align: right;">
|
||||
<button class="btn btn-ghost btn-small" @click="openPersonForm(p)">Bearbeiten</button>
|
||||
<button class="btn btn-danger-ghost btn-small" @click="removePerson(p.id)">Entfernen</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="toolbar">
|
||||
<h2 class="panel-title" style="margin: 0;" x-text="`Abwesenheiten (${absences.length})`"></h2>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
@click="openAbsenceForm()"
|
||||
: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>
|
||||
<tr x-show="sortedAbsences().length === 0">
|
||||
<td colspan="5" class="empty-row">Keine Abwesenheiten hinterlegt (Urlaub, Feiertage, etc.).</td>
|
||||
</tr>
|
||||
<template x-for="a in sortedAbsences()" :key="a.id">
|
||||
<tr>
|
||||
<td x-text="personNameOrUnknown(a.personId)"></td>
|
||||
<td class="num" x-text="a.startDate"></td>
|
||||
<td class="num" x-text="a.endDate"></td>
|
||||
<td x-text="a.note || '–'"></td>
|
||||
<td style="text-align: right;">
|
||||
<button class="btn btn-danger-ghost btn-small" @click="removeAbsence(a.id)">Entfernen</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</main>
|
||||
|
||||
<!-- ---------- Person-Modal ---------- -->
|
||||
<div class="form-modal-backdrop" x-show="personFormOpen" x-cloak @mousedown.self="closePersonForm()">
|
||||
<div class="form-modal" @mousedown.stop>
|
||||
<h3 x-text="personEditingId ? 'Person bearbeiten' : 'Person hinzufuegen'"></h3>
|
||||
<form @submit.prevent="savePerson()">
|
||||
<div class="field">
|
||||
<label for="p-name">Name</label>
|
||||
<input id="p-name" x-model="personDraft.name" autofocus />
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label for="p-role">Rolle</label>
|
||||
<input id="p-role" x-model="personDraft.role" placeholder="z. B. Entwicklerin" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="p-hours">Stunden / Woche</label>
|
||||
<input id="p-hours" type="number" min="1" max="60" x-model="personDraft.weeklyHours" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Farbe</label>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<template x-for="c in personPalette" :key="c">
|
||||
<button
|
||||
type="button"
|
||||
@click="personDraft.color = c"
|
||||
:aria-label="`Farbe ${c} waehlen`"
|
||||
:style="`width: 24px; height: 24px; border-radius: 50%; background: ${c}; border: ${personDraft.color === c ? '2px solid var(--ink)' : '2px solid transparent'}; cursor: pointer;`"
|
||||
></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<p class="error-text" x-show="modalError" x-text="modalError"></p>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn" @click="closePersonForm()">Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="modalSaving" x-text="modalSaving ? 'Speichert...' : 'Speichern'"></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ---------- Abwesenheit-Modal ---------- -->
|
||||
<div class="form-modal-backdrop" x-show="absenceFormOpen" x-cloak @mousedown.self="closeAbsenceForm()">
|
||||
<div class="form-modal" @mousedown.stop>
|
||||
<h3>Abwesenheit eintragen</h3>
|
||||
<form @submit.prevent="saveAbsence()">
|
||||
<div class="field">
|
||||
<label for="a-person">Person</label>
|
||||
<select id="a-person" x-model="absenceDraft.personId">
|
||||
<template x-for="p in people" :key="p.id">
|
||||
<option :value="p.id" x-text="p.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label for="a-start">Von</label>
|
||||
<input id="a-start" type="date" x-model="absenceDraft.startDate" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="a-end">Bis</label>
|
||||
<input id="a-end" type="date" x-model="absenceDraft.endDate" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="a-note">Notiz (optional)</label>
|
||||
<input id="a-note" x-model="absenceDraft.note" placeholder="z. B. Urlaub, Feiertag" />
|
||||
</div>
|
||||
<p class="error-text" x-show="modalError" x-text="modalError"></p>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn" @click="closeAbsenceForm()">Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="modalSaving" x-text="modalSaving ? 'Speichert...' : 'Eintragen'"></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ---------- Projekt-Modal ---------- -->
|
||||
<div class="form-modal-backdrop" x-show="projectFormOpen" x-cloak @mousedown.self="closeProjectForm()">
|
||||
<div class="form-modal" @mousedown.stop>
|
||||
<h3 x-text="projectEditingId ? 'Projekt bearbeiten' : 'Projekt anlegen'"></h3>
|
||||
<form @submit.prevent="saveProject()">
|
||||
<div class="field">
|
||||
<label for="pr-name">Projektname</label>
|
||||
<input id="pr-name" x-model="projectDraft.name" autofocus />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="pr-desc">Beschreibung (optional)</label>
|
||||
<textarea id="pr-desc" rows="2" x-model="projectDraft.description"></textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Farbe</label>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<template x-for="c in projectPalette" :key="c">
|
||||
<button
|
||||
type="button"
|
||||
@click="projectDraft.color = c"
|
||||
:aria-label="`Farbe ${c} waehlen`"
|
||||
:style="`width: 24px; height: 24px; border-radius: 50%; background: ${c}; border: ${projectDraft.color === c ? '2px solid var(--ink)' : '2px solid transparent'}; cursor: pointer;`"
|
||||
></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<p class="error-text" x-show="modalError" x-text="modalError"></p>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn" @click="closeProjectForm()">Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="modalSaving" x-text="modalSaving ? 'Speichert...' : 'Speichern'"></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ---------- Aufgabe-Modal ---------- -->
|
||||
<div class="form-modal-backdrop" x-show="taskFormOpen" x-cloak @mousedown.self="closeTaskForm()">
|
||||
<div class="form-modal" @mousedown.stop>
|
||||
<h3 x-text="taskEditingId ? 'Aufgabe bearbeiten' : 'Aufgabe anlegen'"></h3>
|
||||
<form @submit.prevent="saveTask()">
|
||||
<div class="field">
|
||||
<label for="t-project">Projekt</label>
|
||||
<select id="t-project" x-model="taskDraft.projectId">
|
||||
<template x-for="p in projects" :key="p.id">
|
||||
<option :value="p.id" x-text="p.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="t-name">Aufgabe</label>
|
||||
<input id="t-name" x-model="taskDraft.name" autofocus />
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label for="t-start">Start</label>
|
||||
<input id="t-start" type="date" x-model="taskDraft.startDate" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="t-end">Ende / Faellig</label>
|
||||
<input id="t-end" type="date" x-model="taskDraft.endDate" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label for="t-hours">Aufwand (Std.)</label>
|
||||
<input id="t-hours" type="number" min="0" step="0.5" x-model="taskDraft.estimatedHours" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="t-assignee">Zustaendig</label>
|
||||
<select id="t-assignee" x-model="taskDraft.assigneeId">
|
||||
<option value="">Nicht zugewiesen</option>
|
||||
<template x-for="p in people" :key="p.id">
|
||||
<option :value="p.id" x-text="p.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label for="t-priority">Prioritaet</label>
|
||||
<select id="t-priority" x-model="taskDraft.priority">
|
||||
<option value="hoch">Hoch</option>
|
||||
<option value="mittel">Mittel</option>
|
||||
<option value="niedrig">Niedrig</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="t-status">Status</label>
|
||||
<select id="t-status" x-model="taskDraft.status">
|
||||
<option value="geplant">Geplant</option>
|
||||
<option value="in Arbeit">In Arbeit</option>
|
||||
<option value="erledigt">Erledigt</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="t-depends">Abhaengig von (optional)</label>
|
||||
<select id="t-depends" x-model="taskDraft.dependsOn">
|
||||
<option value="">Keine Abhaengigkeit</option>
|
||||
<template x-for="t in taskDependencyOptions()" :key="t.id">
|
||||
<option :value="t.id" x-text="t.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<p class="error-text" x-show="modalError" x-text="modalError"></p>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn" @click="closeTaskForm()">Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="modalSaving" x-text="modalSaving ? 'Speichert...' : 'Speichern'"></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script defer src="/js/vendor/alpine-3.15.12.min.js"></script>
|
||||
<script type="module" src="/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
import { api } from "./api.js";
|
||||
|
||||
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" }
|
||||
];
|
||||
|
||||
const PERSON_PALETTE = ["#2F6F4F", "#C4622D", "#3E5C50", "#8A6D3B", "#4C5B7A", "#A63B2A"];
|
||||
const PROJECT_PALETTE = ["#3E5C50", "#2F6F4F", "#8A6D3B", "#4C5B7A", "#A63B2A", "#C4622D"];
|
||||
const PRIORITY_LABEL = { hoch: "Hoch", mittel: "Mittel", niedrig: "Niedrig" };
|
||||
|
||||
const MONTH_LABELS = ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"];
|
||||
const MONTH_LABELS_BY_KEY = {
|
||||
"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 isLeap(year) {
|
||||
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
||||
}
|
||||
|
||||
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 capacityBarClassFor(month) {
|
||||
if (month.overloaded) return "danger";
|
||||
if (month.utilization >= 90) return "warn";
|
||||
return "ok";
|
||||
}
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data("app", () => ({
|
||||
// ---------- global state ----------
|
||||
currentYear: new Date().getFullYear(),
|
||||
nav: NAV,
|
||||
view: "timeline",
|
||||
people: [],
|
||||
projects: [],
|
||||
tasks: [],
|
||||
absences: [],
|
||||
loading: true,
|
||||
loadError: null,
|
||||
|
||||
personPalette: PERSON_PALETTE,
|
||||
projectPalette: PROJECT_PALETTE,
|
||||
priorityLabel: PRIORITY_LABEL,
|
||||
monthLabels: MONTH_LABELS,
|
||||
|
||||
modalError: null,
|
||||
modalSaving: false,
|
||||
|
||||
// ---------- person form ----------
|
||||
personFormOpen: false,
|
||||
personEditingId: null,
|
||||
personDraft: { name: "", role: "", weeklyHours: 40, color: PERSON_PALETTE[0] },
|
||||
|
||||
// ---------- absence form ----------
|
||||
absenceFormOpen: false,
|
||||
absenceDraft: { personId: "", startDate: "", endDate: "", note: "" },
|
||||
|
||||
// ---------- project form ----------
|
||||
projectFormOpen: false,
|
||||
projectEditingId: null,
|
||||
projectDraft: { name: "", description: "", color: PROJECT_PALETTE[0] },
|
||||
|
||||
// ---------- task form ----------
|
||||
taskFormOpen: false,
|
||||
taskEditingId: null,
|
||||
taskDraft: {
|
||||
projectId: "",
|
||||
name: "",
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
estimatedHours: 8,
|
||||
assigneeId: "",
|
||||
priority: "mittel",
|
||||
status: "geplant",
|
||||
dependsOn: ""
|
||||
},
|
||||
|
||||
// ---------- timeline ----------
|
||||
timelineYear: new Date().getFullYear(),
|
||||
|
||||
// ---------- capacity ----------
|
||||
capacityYear: new Date().getFullYear(),
|
||||
capacityData: null,
|
||||
capacityLoading: true,
|
||||
capacityError: null,
|
||||
|
||||
init() {
|
||||
this.reloadAll();
|
||||
},
|
||||
|
||||
async reloadAll() {
|
||||
try {
|
||||
const [p, pr, t, a] = await Promise.all([
|
||||
api.people.list(),
|
||||
api.projects.list(),
|
||||
api.tasks.list(),
|
||||
api.absences.list()
|
||||
]);
|
||||
this.people = p;
|
||||
this.projects = pr;
|
||||
this.tasks = t;
|
||||
this.absences = a;
|
||||
this.loadError = null;
|
||||
} catch (err) {
|
||||
this.loadError = err.message;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
setView(key) {
|
||||
this.view = key;
|
||||
if (key === "capacity") this.loadCapacity();
|
||||
},
|
||||
|
||||
personById(id) {
|
||||
return this.people.find((p) => p.id === id);
|
||||
},
|
||||
|
||||
personNameOrUnknown(id) {
|
||||
return this.personById(id)?.name || "Unbekannt";
|
||||
},
|
||||
|
||||
assigneeName(id) {
|
||||
return this.personById(id)?.name || "–";
|
||||
},
|
||||
|
||||
sortedAbsences() {
|
||||
return [...this.absences].sort((a, b) => a.startDate.localeCompare(b.startDate));
|
||||
},
|
||||
|
||||
tasksByProject(projectId) {
|
||||
return this.tasks
|
||||
.filter((t) => t.projectId === projectId)
|
||||
.sort((a, b) => a.startDate.localeCompare(b.startDate));
|
||||
},
|
||||
|
||||
// ---------- person CRUD ----------
|
||||
openPersonForm(person = null) {
|
||||
this.personEditingId = person ? person.id : null;
|
||||
this.personDraft = person
|
||||
? { name: person.name, role: person.role || "", weeklyHours: person.weeklyHours ?? 40, color: person.color || PERSON_PALETTE[0] }
|
||||
: { name: "", role: "", weeklyHours: 40, color: PERSON_PALETTE[0] };
|
||||
this.modalError = null;
|
||||
this.personFormOpen = true;
|
||||
this.$nextTick(() => document.getElementById("p-name")?.focus());
|
||||
},
|
||||
|
||||
closePersonForm() {
|
||||
this.personFormOpen = false;
|
||||
},
|
||||
|
||||
async savePerson() {
|
||||
this.modalError = null;
|
||||
if (!this.personDraft.name.trim()) {
|
||||
this.modalError = "Bitte einen Namen eingeben.";
|
||||
return;
|
||||
}
|
||||
this.modalSaving = true;
|
||||
try {
|
||||
const payload = {
|
||||
name: this.personDraft.name,
|
||||
role: this.personDraft.role,
|
||||
weeklyHours: Number(this.personDraft.weeklyHours),
|
||||
color: this.personDraft.color
|
||||
};
|
||||
if (this.personEditingId) {
|
||||
await api.people.update(this.personEditingId, payload);
|
||||
} else {
|
||||
await api.people.create(payload);
|
||||
}
|
||||
this.personFormOpen = false;
|
||||
await this.reloadAll();
|
||||
} catch (err) {
|
||||
this.modalError = err.message;
|
||||
} finally {
|
||||
this.modalSaving = false;
|
||||
}
|
||||
},
|
||||
|
||||
async removePerson(id) {
|
||||
if (!confirm("Diese Person inklusive ihrer Abwesenheiten wirklich entfernen?")) return;
|
||||
await api.people.remove(id);
|
||||
await this.reloadAll();
|
||||
},
|
||||
|
||||
// ---------- absence CRUD ----------
|
||||
openAbsenceForm() {
|
||||
this.absenceDraft = { personId: this.people[0]?.id || "", startDate: "", endDate: "", note: "" };
|
||||
this.modalError = null;
|
||||
this.absenceFormOpen = true;
|
||||
},
|
||||
|
||||
closeAbsenceForm() {
|
||||
this.absenceFormOpen = false;
|
||||
},
|
||||
|
||||
async saveAbsence() {
|
||||
this.modalError = null;
|
||||
const d = this.absenceDraft;
|
||||
if (!d.personId || !d.startDate || !d.endDate) {
|
||||
this.modalError = "Bitte Person, Start- und Enddatum angeben.";
|
||||
return;
|
||||
}
|
||||
if (d.startDate > d.endDate) {
|
||||
this.modalError = "Das Startdatum muss vor dem Enddatum liegen.";
|
||||
return;
|
||||
}
|
||||
this.modalSaving = true;
|
||||
try {
|
||||
await api.absences.create({ personId: d.personId, startDate: d.startDate, endDate: d.endDate, note: d.note });
|
||||
this.absenceFormOpen = false;
|
||||
await this.reloadAll();
|
||||
} catch (err) {
|
||||
this.modalError = err.message;
|
||||
} finally {
|
||||
this.modalSaving = false;
|
||||
}
|
||||
},
|
||||
|
||||
async removeAbsence(id) {
|
||||
await api.absences.remove(id);
|
||||
await this.reloadAll();
|
||||
},
|
||||
|
||||
// ---------- project CRUD ----------
|
||||
openProjectForm(project = null) {
|
||||
this.projectEditingId = project ? project.id : null;
|
||||
this.projectDraft = project
|
||||
? { name: project.name, description: project.description || "", color: project.color || PROJECT_PALETTE[0] }
|
||||
: { name: "", description: "", color: PROJECT_PALETTE[0] };
|
||||
this.modalError = null;
|
||||
this.projectFormOpen = true;
|
||||
this.$nextTick(() => document.getElementById("pr-name")?.focus());
|
||||
},
|
||||
|
||||
closeProjectForm() {
|
||||
this.projectFormOpen = false;
|
||||
},
|
||||
|
||||
async saveProject() {
|
||||
this.modalError = null;
|
||||
if (!this.projectDraft.name.trim()) {
|
||||
this.modalError = "Bitte einen Projektnamen eingeben.";
|
||||
return;
|
||||
}
|
||||
this.modalSaving = true;
|
||||
try {
|
||||
const payload = {
|
||||
name: this.projectDraft.name,
|
||||
description: this.projectDraft.description,
|
||||
color: this.projectDraft.color
|
||||
};
|
||||
if (this.projectEditingId) {
|
||||
await api.projects.update(this.projectEditingId, payload);
|
||||
} else {
|
||||
await api.projects.create(payload);
|
||||
}
|
||||
this.projectFormOpen = false;
|
||||
await this.reloadAll();
|
||||
} catch (err) {
|
||||
this.modalError = err.message;
|
||||
} finally {
|
||||
this.modalSaving = false;
|
||||
}
|
||||
},
|
||||
|
||||
async removeProject(id) {
|
||||
if (!confirm("Projekt inklusive aller zugehoerigen Aufgaben wirklich loeschen?")) return;
|
||||
await api.projects.remove(id);
|
||||
await this.reloadAll();
|
||||
},
|
||||
|
||||
// ---------- task CRUD ----------
|
||||
openTaskForm(task = null, projectHint = null) {
|
||||
this.taskEditingId = task ? task.id : null;
|
||||
this.taskDraft = {
|
||||
projectId: task?.projectId || projectHint || this.projects[0]?.id || "",
|
||||
name: task?.name || "",
|
||||
startDate: task?.startDate || "",
|
||||
endDate: task?.endDate || "",
|
||||
estimatedHours: task?.estimatedHours ?? 8,
|
||||
assigneeId: task?.assigneeId || "",
|
||||
priority: task?.priority || "mittel",
|
||||
status: task?.status || "geplant",
|
||||
dependsOn: task?.dependsOn || ""
|
||||
};
|
||||
this.modalError = null;
|
||||
this.taskFormOpen = true;
|
||||
this.$nextTick(() => document.getElementById("t-name")?.focus());
|
||||
},
|
||||
|
||||
closeTaskForm() {
|
||||
this.taskFormOpen = false;
|
||||
},
|
||||
|
||||
taskDependencyOptions() {
|
||||
return this.tasks.filter((t) => t.id !== this.taskEditingId);
|
||||
},
|
||||
|
||||
async saveTask() {
|
||||
this.modalError = null;
|
||||
const d = this.taskDraft;
|
||||
if (!d.projectId || !d.name.trim() || !d.startDate || !d.endDate) {
|
||||
this.modalError = "Projekt, Name, Start- und Enddatum sind erforderlich.";
|
||||
return;
|
||||
}
|
||||
if (d.startDate > d.endDate) {
|
||||
this.modalError = "Das Startdatum muss vor dem Enddatum liegen.";
|
||||
return;
|
||||
}
|
||||
this.modalSaving = true;
|
||||
try {
|
||||
const payload = {
|
||||
projectId: d.projectId,
|
||||
name: d.name,
|
||||
startDate: d.startDate,
|
||||
endDate: d.endDate,
|
||||
estimatedHours: Number(d.estimatedHours),
|
||||
assigneeId: d.assigneeId || null,
|
||||
priority: d.priority,
|
||||
status: d.status,
|
||||
dependsOn: d.dependsOn || null
|
||||
};
|
||||
if (this.taskEditingId) {
|
||||
await api.tasks.update(this.taskEditingId, payload);
|
||||
} else {
|
||||
await api.tasks.create(payload);
|
||||
}
|
||||
this.taskFormOpen = false;
|
||||
await this.reloadAll();
|
||||
} catch (err) {
|
||||
this.modalError = err.message;
|
||||
} finally {
|
||||
this.modalSaving = false;
|
||||
}
|
||||
},
|
||||
|
||||
async removeTask(id) {
|
||||
await api.tasks.remove(id);
|
||||
await this.reloadAll();
|
||||
},
|
||||
|
||||
// ---------- timeline ----------
|
||||
timelineDaysInYear() {
|
||||
return isLeap(this.timelineYear) ? 366 : 365;
|
||||
},
|
||||
|
||||
timelineGrouped() {
|
||||
return this.projects
|
||||
.map((project) => ({
|
||||
project,
|
||||
tasks: this.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 <= this.timelineYear && endYear >= this.timelineYear;
|
||||
})
|
||||
.sort((a, b) => a.startDate.localeCompare(b.startDate))
|
||||
}))
|
||||
.filter((g) => g.tasks.length > 0);
|
||||
},
|
||||
|
||||
timelineBarStyle(task) {
|
||||
const year = this.timelineYear;
|
||||
const daysInYear = this.timelineDaysInYear();
|
||||
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 = this.personById(task.assigneeId);
|
||||
const project = this.projects.find((p) => p.id === task.projectId);
|
||||
return `left: ${leftPct}%; width: ${widthPct}%; background: ${person?.color || project?.color || "var(--accent)"};`;
|
||||
},
|
||||
|
||||
// ---------- capacity ----------
|
||||
async loadCapacity() {
|
||||
this.capacityLoading = true;
|
||||
try {
|
||||
const res = await api.capacity.get(`${this.capacityYear}-01-01`, `${this.capacityYear}-12-31`);
|
||||
this.capacityData = res;
|
||||
this.capacityError = null;
|
||||
} catch (err) {
|
||||
this.capacityError = err.message;
|
||||
} finally {
|
||||
this.capacityLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
capacityPrevYear() {
|
||||
this.capacityYear -= 1;
|
||||
this.loadCapacity();
|
||||
},
|
||||
|
||||
capacityNextYear() {
|
||||
this.capacityYear += 1;
|
||||
this.loadCapacity();
|
||||
},
|
||||
|
||||
capacityMaxScale(person) {
|
||||
return Math.max(1, ...person.months.map((m) => Math.max(m.capacityHours, m.assignedHours)));
|
||||
},
|
||||
|
||||
capacityBarClass(month) {
|
||||
return capacityBarClassFor(month);
|
||||
},
|
||||
|
||||
monthLabel(monthKey) {
|
||||
return MONTH_LABELS_BY_KEY[monthKey.slice(5)];
|
||||
}
|
||||
}));
|
||||
});
|
||||
+5
File diff suppressed because one or more lines are too long
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
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>
|
||||
);
|
||||
@@ -1,15 +0,0 @@
|
||||
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