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
+52
View File
@@ -0,0 +1,52 @@
const BASE = "/api";
async function request(path, options = {}) {
const res = await fetch(`${BASE}${path}`, {
headers: { "Content-Type": "application/json" },
...options
});
if (!res.ok) {
let message = `Fehler ${res.status}`;
try {
const body = await res.json();
if (body.error) message = body.error;
} catch (_) {
/* ignore */
}
throw new Error(message);
}
if (res.status === 204) return null;
return res.json();
}
export const api = {
people: {
list: () => request("/people"),
create: (data) => request("/people", { method: "POST", body: JSON.stringify(data) }),
update: (id, data) =>
request(`/people/${id}`, { method: "PUT", body: JSON.stringify(data) }),
remove: (id) => request(`/people/${id}`, { method: "DELETE" })
},
projects: {
list: () => request("/projects"),
create: (data) => request("/projects", { method: "POST", body: JSON.stringify(data) }),
update: (id, data) =>
request(`/projects/${id}`, { method: "PUT", body: JSON.stringify(data) }),
remove: (id) => request(`/projects/${id}`, { method: "DELETE" })
},
tasks: {
list: () => request("/tasks"),
create: (data) => request("/tasks", { method: "POST", body: JSON.stringify(data) }),
update: (id, data) =>
request(`/tasks/${id}`, { method: "PUT", body: JSON.stringify(data) }),
remove: (id) => request(`/tasks/${id}`, { method: "DELETE" })
},
absences: {
list: () => request("/absences"),
create: (data) => request("/absences", { method: "POST", body: JSON.stringify(data) }),
remove: (id) => request(`/absences/${id}`, { method: "DELETE" })
},
capacity: {
get: (from, to) => request(`/capacity?from=${from}&to=${to}`)
}
};
+425
View File
@@ -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)];
}
}));
});
File diff suppressed because one or more lines are too long