Files
2026-07-03 00:17:27 +02:00

426 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)];
}
}));
});