Umbau auf Alpine.js

This commit is contained in:
2026-07-03 00:17:27 +02:00
parent 31b3f58f3c
commit 2d88811b12
21 changed files with 993 additions and 1267 deletions
+523 -2
View File
@@ -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 &amp; 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 &middot; <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--">&larr;</button>
<strong style="font-family: var(--font-mono); font-size: 15px;" x-text="timelineYear"></strong>
<button class="btn btn-small" @click="timelineYear++">&rarr;</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()">&larr;</button>
<strong style="font-family: var(--font-mono); font-size: 15px;" x-text="capacityYear"></strong>
<button class="btn btn-small" @click="capacityNextYear()">&rarr;</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 &amp; 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 &bdquo;Projekt anlegen&ldquo;.
</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>