540 lines
25 KiB
HTML
540 lines
25 KiB
HTML
<!doctype html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>Jahresplanung & Kapazitaet</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||
<link
|
||
href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600&family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap"
|
||
rel="stylesheet"
|
||
/>
|
||
<link rel="stylesheet" href="/css/styles.css" />
|
||
</head>
|
||
<body>
|
||
<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>
|