Files
oikos/public/pages/tasks.js
T
ulsklyc 433124790f feat: Phase 2 Schritt 9 — Aufgaben-Modul (CRUD + Listenansicht + Subtasks)
Backend:
- GET /tasks mit Filtern (status, priority, assigned_to, category)
- GET /tasks/:id mit Subtasks
- POST /tasks mit Tiefenlimit (max. 2 Ebenen)
- PUT /tasks/:id, PATCH /tasks/:id/status, DELETE /tasks/:id
- GET /tasks/meta/options für Dropdown-Daten
- Sortierung: Priorität → Fälligkeit, done-Tasks ans Ende

Frontend:
- Listenansicht gruppiert nach Kategorie oder Fälligkeit (umschaltbar)
- Filter-Chips: Status, Priorität, Person (horizontal scrollbar)
- Task-Card: Prioritäts-Badge, Fälligkeitsdatum, Avatar, Edit-Button
- Status-Toggle per Checkbox (open ↔ done)
- Subtask-Fortschrittsbalken + ein-/ausklappbare Subtask-Liste
- Subtask inline abhaken oder neu erstellen
- Overdue-Badge in der Navigation
- CRUD-Modal: Titel, Beschreibung, Priorität, Kategorie, Datum, Zuweisung
- Skeleton-Loading während API-Call

Tests: 17/17 bestanden (54 gesamt)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 18:02:59 +01:00

675 lines
24 KiB
JavaScript
Raw 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.
/**
* Modul: Aufgaben (Tasks)
* Zweck: Listenansicht mit Filtern, Gruppierung, CRUD-Modal, Subtask-Verwaltung
* Abhängigkeiten: /api.js
*/
import { api } from '/api.js';
// --------------------------------------------------------
// Konstanten
// --------------------------------------------------------
const PRIORITIES = [
{ value: 'urgent', label: 'Dringend', color: 'var(--color-priority-urgent)' },
{ value: 'high', label: 'Hoch', color: 'var(--color-priority-high)' },
{ value: 'medium', label: 'Mittel', color: 'var(--color-priority-medium)' },
{ value: 'low', label: 'Niedrig', color: 'var(--color-priority-low)' },
];
const STATUSES = [
{ value: 'open', label: 'Offen' },
{ value: 'in_progress', label: 'In Bearbeitung'},
{ value: 'done', label: 'Erledigt' },
];
const CATEGORIES = [
'Haushalt','Schule','Einkauf','Reparatur',
'Gesundheit','Finanzen','Freizeit','Sonstiges',
];
const PRIORITY_LABELS = Object.fromEntries(PRIORITIES.map((p) => [p.value, p.label]));
const STATUS_LABELS = Object.fromEntries(STATUSES.map((s) => [s.value, s.label]));
// --------------------------------------------------------
// Hilfsfunktionen
// --------------------------------------------------------
function initials(name = '') {
return name.split(' ').map((w) => w[0]).join('').slice(0, 2).toUpperCase();
}
function formatDueDate(dateStr) {
if (!dateStr) return null;
const due = new Date(dateStr);
const now = new Date();
now.setHours(0, 0, 0, 0);
const diffDays = Math.round((due - now) / 86400000);
if (diffDays < 0) return { label: `${Math.abs(diffDays)}d überfällig`, cls: 'due-date--overdue' };
if (diffDays === 0) return { label: 'Heute fällig', cls: 'due-date--today' };
if (diffDays === 1) return { label: 'Morgen fällig', cls: '' };
return { label: due.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }), cls: '' };
}
function groupBy(tasks, mode) {
const groups = {};
if (mode === 'category') {
for (const t of tasks) {
const key = t.category || 'Sonstiges';
(groups[key] = groups[key] || []).push(t);
}
return Object.entries(groups).sort(([a], [b]) => a.localeCompare(b, 'de'));
}
// mode === 'due'
for (const t of tasks) {
let key;
if (!t.due_date) key = 'Kein Datum';
else {
const diff = Math.round((new Date(t.due_date) - new Date().setHours(0,0,0,0)) / 86400000);
if (diff < 0) key = 'Überfällig';
else if (diff === 0) key = 'Heute';
else if (diff <= 3) key = 'Diese Woche';
else if (diff <= 7) key = 'Nächste Woche';
else key = 'Später';
}
(groups[key] = groups[key] || []).push(t);
}
const order = ['Überfällig', 'Heute', 'Diese Woche', 'Nächste Woche', 'Später', 'Kein Datum'];
return order.filter((k) => groups[k]).map((k) => [k, groups[k]]);
}
// --------------------------------------------------------
// Render-Bausteine
// --------------------------------------------------------
function renderPriorityBadge(priority) {
return `<span class="priority-badge priority-badge--${priority}">
<span class="priority-dot priority-dot--${priority}"></span>
${PRIORITY_LABELS[priority] ?? priority}
</span>`;
}
function renderDueDate(dateStr) {
const d = formatDueDate(dateStr);
if (!d) return '';
return `<span class="due-date ${d.cls}">
<i data-lucide="clock" style="width:11px;height:11px"></i> ${d.label}
</span>`;
}
function renderTaskCard(task, opts = {}) {
const { expandedSubtasks = false } = opts;
const isDone = task.status === 'done';
const progress = task.subtask_total > 0
? Math.round((task.subtask_done / task.subtask_total) * 100)
: null;
const subtasksHtml = task.subtasks?.length
? task.subtasks.map((s) => `
<div class="subtask-item ${s.status === 'done' ? 'subtask-item--done' : ''}"
data-subtask-id="${s.id}">
<button class="subtask-item__checkbox ${s.status === 'done' ? 'subtask-item__checkbox--done' : ''}"
data-action="toggle-subtask" data-id="${s.id}"
data-status="${s.status}" aria-label="${s.title} als erledigt markieren">
${s.status === 'done' ? '<i data-lucide="check" style="width:10px;height:10px;color:#fff"></i>' : ''}
</button>
<span class="subtask-item__title">${s.title}</span>
</div>`).join('')
: '';
return `
<div class="task-card ${isDone ? 'task-card--done' : ''}" data-task-id="${task.id}">
<div class="task-card__main">
<button class="task-status-btn task-status-btn--${task.status}"
data-action="toggle-status" data-id="${task.id}" data-status="${task.status}"
aria-label="${task.title} als erledigt markieren">
<i data-lucide="check" class="task-status-btn__check"></i>
</button>
<div class="task-card__body">
<div class="task-card__title" data-action="open-task" data-id="${task.id}">
${task.title}
</div>
<div class="task-card__meta">
${renderPriorityBadge(task.priority)}
${renderDueDate(task.due_date)}
${task.category !== 'Sonstiges' ? `<span class="due-date">${task.category}</span>` : ''}
</div>
</div>
${task.assigned_color ? `
<div class="task-avatar" style="background-color:${task.assigned_color}"
title="${task.assigned_name ?? ''}">
${initials(task.assigned_name ?? '')}
</div>` : ''}
<button class="btn btn--ghost btn--icon" data-action="edit-task" data-id="${task.id}"
aria-label="Aufgabe bearbeiten" style="min-height:unset;width:36px;height:36px">
<i data-lucide="pencil" style="width:16px;height:16px"></i>
</button>
</div>
${progress !== null ? `
<div class="subtask-progress" data-action="toggle-subtasks" data-id="${task.id}"
aria-label="Teilaufgaben anzeigen">
<div class="subtask-progress__bar-wrap">
<div class="subtask-progress__bar-fill" style="width:${progress}%"></div>
</div>
<span class="subtask-progress__text">${task.subtask_done}/${task.subtask_total}</span>
</div>` : ''}
${task.subtasks !== undefined ? `
<div class="subtask-list ${expandedSubtasks ? 'subtask-list--visible' : ''}"
id="subtasks-${task.id}">
${subtasksHtml}
<button class="subtask-item__add" data-action="add-subtask" data-parent="${task.id}">
+ Teilaufgabe hinzufügen
</button>
</div>` : ''}
</div>`;
}
function renderTaskGroups(tasks, groupMode) {
if (!tasks.length) {
return `<div class="tasks-empty">
<i data-lucide="check-circle-2" class="tasks-empty__icon"></i>
<div class="tasks-empty__title">Keine Aufgaben</div>
<div class="tasks-empty__desc">Erstelle eine neue Aufgabe mit dem + Button.</div>
</div>`;
}
const groups = groupBy(tasks, groupMode);
return groups.map(([name, groupTasks]) => `
<div class="task-group">
<div class="task-group__header">
<span class="task-group__title">${name}</span>
<span class="task-group__count">${groupTasks.length}</span>
</div>
${groupTasks.map((t) => renderTaskCard(t)).join('')}
</div>`).join('');
}
// --------------------------------------------------------
// Task-Modal (Erstellen / Bearbeiten)
// --------------------------------------------------------
function renderModal({ task = null, users = [] } = {}) {
const isEdit = !!task;
const title = isEdit ? 'Aufgabe bearbeiten' : 'Neue Aufgabe';
const userOptions = users.map((u) =>
`<option value="${u.id}" ${task?.assigned_to === u.id ? 'selected' : ''}>${u.display_name}</option>`
).join('');
const categoryOptions = CATEGORIES.map((c) =>
`<option value="${c}" ${(task?.category ?? 'Sonstiges') === c ? 'selected' : ''}>${c}</option>`
).join('');
const priorityOptions = PRIORITIES.map((p) =>
`<option value="${p.value}" ${(task?.priority ?? 'medium') === p.value ? 'selected' : ''}>${p.label}</option>`
).join('');
return `
<div class="modal-backdrop" id="task-modal-backdrop">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">
<div class="modal__header">
<h2 class="modal__title" id="modal-title">${title}</h2>
<button class="modal__close" data-action="close-modal" aria-label="Schließen">
<i data-lucide="x" style="width:18px;height:18px"></i>
</button>
</div>
<form id="task-form" novalidate>
<input type="hidden" id="task-id" value="${task?.id ?? ''}">
<div class="form-group">
<label class="label" for="task-title">Titel *</label>
<input class="input" type="text" id="task-title" name="title"
value="${task?.title ?? ''}" placeholder="Was muss erledigt werden?"
required autocomplete="off">
</div>
<div class="form-group">
<label class="label" for="task-description">Notiz</label>
<textarea class="input" id="task-description" name="description"
rows="2" placeholder="Optionale Details…"
style="resize:vertical">${task?.description ?? ''}</textarea>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group" style="margin-bottom:0">
<label class="label" for="task-priority">Priorität</label>
<select class="input" id="task-priority" name="priority" style="min-height:44px">
${priorityOptions}
</select>
</div>
<div class="form-group" style="margin-bottom:0">
<label class="label" for="task-category">Kategorie</label>
<select class="input" id="task-category" name="category" style="min-height:44px">
${categoryOptions}
</select>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);margin-top:var(--space-4)">
<div class="form-group" style="margin-bottom:0">
<label class="label" for="task-due-date">Fälligkeit</label>
<input class="input" type="date" id="task-due-date" name="due_date"
value="${task?.due_date ?? ''}">
</div>
<div class="form-group" style="margin-bottom:0">
<label class="label" for="task-due-time">Uhrzeit</label>
<input class="input" type="time" id="task-due-time" name="due_time"
value="${task?.due_time ?? ''}">
</div>
</div>
<div class="form-group" style="margin-top:var(--space-4)">
<label class="label" for="task-assigned">Zugewiesen an</label>
<select class="input" id="task-assigned" name="assigned_to" style="min-height:44px">
<option value="">— Niemand —</option>
${userOptions}
</select>
</div>
${isEdit ? `
<div class="form-group">
<label class="label" for="task-status">Status</label>
<select class="input" id="task-status" name="status" style="min-height:44px">
${STATUSES.map((s) =>
`<option value="${s.value}" ${task.status === s.value ? 'selected' : ''}>${s.label}</option>`
).join('')}
</select>
</div>` : ''}
<div id="task-form-error" class="login-error" hidden></div>
<div class="modal__actions">
${isEdit ? `
<button type="button" class="btn btn--danger" data-action="delete-task"
data-id="${task.id}">Löschen</button>` : ''}
<button type="submit" class="btn btn--primary" id="task-submit-btn">
${isEdit ? 'Speichern' : 'Erstellen'}
</button>
</div>
</form>
</div>
</div>`;
}
// --------------------------------------------------------
// Seiten-State
// --------------------------------------------------------
let state = {
tasks: [],
users: [],
filters: { status: '', priority: '', assigned_to: '' },
groupMode: 'category', // 'category' | 'due'
expandedTasks: new Set(),
};
// --------------------------------------------------------
// API-Aktionen
// --------------------------------------------------------
async function loadTasks(container) {
const params = new URLSearchParams();
if (state.filters.status) params.set('status', state.filters.status);
if (state.filters.priority) params.set('priority', state.filters.priority);
if (state.filters.assigned_to) params.set('assigned_to', state.filters.assigned_to);
const query = params.toString() ? `?${params}` : '';
const data = await api.get(`/tasks${query}`);
state.tasks = data.data ?? [];
renderTaskList(container);
}
async function toggleTaskStatus(id, currentStatus) {
const next = currentStatus === 'done' ? 'open' : 'done';
await api.patch(`/tasks/${id}/status`, { status: next });
}
async function toggleSubtaskStatus(id, currentStatus) {
const next = currentStatus === 'done' ? 'open' : 'done';
await api.patch(`/tasks/${id}/status`, { status: next });
}
async function loadTaskForEdit(id) {
const data = await api.get(`/tasks/${id}`);
return data.data;
}
// --------------------------------------------------------
// Modal-Verwaltung
// --------------------------------------------------------
function openModal(html) {
document.body.insertAdjacentHTML('beforeend', html);
if (window.lucide) window.lucide.createIcons();
// Fokus auf erstes Eingabefeld
setTimeout(() => document.getElementById('task-title')?.focus(), 50);
// Backdrop-Klick schließt Modal
document.getElementById('task-modal-backdrop')
.addEventListener('click', (e) => {
if (e.target.id === 'task-modal-backdrop') closeModal();
});
// Escape schließt Modal
document.addEventListener('keydown', onEscape);
}
function closeModal() {
document.getElementById('task-modal-backdrop')?.remove();
document.removeEventListener('keydown', onEscape);
}
function onEscape(e) {
if (e.key === 'Escape') closeModal();
}
// --------------------------------------------------------
// Formular-Handler
// --------------------------------------------------------
async function handleFormSubmit(e, container) {
e.preventDefault();
const form = e.target;
const errorEl = document.getElementById('task-form-error');
const submitBtn = document.getElementById('task-submit-btn');
const taskId = document.getElementById('task-id').value;
errorEl.hidden = true;
submitBtn.disabled = true;
submitBtn.textContent = 'Wird gespeichert…';
const body = {
title: form.title.value.trim(),
description: form.description.value.trim() || null,
priority: form.priority.value,
category: form.category.value,
due_date: form.due_date?.value || null,
due_time: form.due_time?.value || null,
assigned_to: form.assigned_to.value ? Number(form.assigned_to.value) : null,
};
if (form.status) body.status = form.status.value;
try {
if (taskId) {
await api.put(`/tasks/${taskId}`, body);
window.oikos.showToast('Aufgabe gespeichert.', 'success');
} else {
await api.post('/tasks', body);
window.oikos.showToast('Aufgabe erstellt.', 'success');
}
closeModal();
await loadTasks(container);
} catch (err) {
errorEl.textContent = err.message;
errorEl.hidden = false;
submitBtn.disabled = false;
submitBtn.textContent = taskId ? 'Speichern' : 'Erstellen';
}
}
async function handleDeleteTask(id, container) {
if (!confirm('Aufgabe und alle Teilaufgaben löschen?')) return;
try {
await api.delete(`/tasks/${id}`);
closeModal();
window.oikos.showToast('Aufgabe gelöscht.', 'default');
await loadTasks(container);
} catch (err) {
window.oikos.showToast(err.message, 'danger');
}
}
async function handleAddSubtask(parentId, container) {
const title = prompt('Teilaufgabe:');
if (!title?.trim()) return;
try {
await api.post('/tasks', { title: title.trim(), parent_task_id: parentId });
await loadTasks(container);
} catch (err) {
window.oikos.showToast(err.message, 'danger');
}
}
// --------------------------------------------------------
// Partielle DOM-Updates
// --------------------------------------------------------
function renderTaskList(container) {
const listEl = container.querySelector('#task-list');
if (!listEl) return;
listEl.innerHTML = renderTaskGroups(state.tasks, state.groupMode);
if (window.lucide) window.lucide.createIcons();
updateOverdueBadge();
}
function renderFilters(container) {
const bar = container.querySelector('#filter-bar');
if (!bar) return;
const chips = [];
if (state.filters.status) {
chips.push(`<span class="filter-chip filter-chip--active" data-filter="status">
${STATUS_LABELS[state.filters.status]}
<span class="filter-chip__remove" aria-hidden="true">×</span>
</span>`);
}
if (state.filters.priority) {
chips.push(`<span class="filter-chip filter-chip--active" data-filter="priority">
${PRIORITY_LABELS[state.filters.priority]}
<span class="filter-chip__remove" aria-hidden="true">×</span>
</span>`);
}
if (state.filters.assigned_to) {
const u = state.users.find((u) => u.id === Number(state.filters.assigned_to));
chips.push(`<span class="filter-chip filter-chip--active" data-filter="assigned_to">
${u?.display_name ?? 'Person'}
<span class="filter-chip__remove" aria-hidden="true">×</span>
</span>`);
}
// Inaktive Filter-Chips (zum Aktivieren)
if (!state.filters.status) {
STATUSES.forEach((s) => {
chips.push(`<span class="filter-chip" data-filter="status" data-value="${s.value}">${s.label}</span>`);
});
}
if (!state.filters.priority) {
PRIORITIES.forEach((p) => {
chips.push(`<span class="filter-chip" data-filter="priority" data-value="${p.value}">${p.label}</span>`);
});
}
if (!state.filters.assigned_to && state.users.length > 1) {
state.users.forEach((u) => {
chips.push(`<span class="filter-chip" data-filter="assigned_to" data-value="${u.id}">${u.display_name}</span>`);
});
}
bar.innerHTML = chips.join('');
wireFilterChips(container);
}
function updateOverdueBadge() {
const overdue = state.tasks.filter((t) => {
if (!t.due_date || t.status === 'done') return false;
return new Date(t.due_date) < new Date().setHours(0, 0, 0, 0);
}).length;
document.querySelectorAll('[data-route="/tasks"] .nav-badge').forEach((el) => el.remove());
if (overdue > 0) {
document.querySelectorAll('[data-route="/tasks"]').forEach((el) => {
el.insertAdjacentHTML('beforeend', `<span class="nav-badge">${overdue}</span>`);
});
}
}
// --------------------------------------------------------
// Event-Verdrahtung
// --------------------------------------------------------
function wireFilterChips(container) {
container.querySelectorAll('[data-filter]').forEach((chip) => {
chip.addEventListener('click', async () => {
const filter = chip.dataset.filter;
if (chip.classList.contains('filter-chip--active')) {
state.filters[filter] = '';
} else {
state.filters[filter] = chip.dataset.value;
}
renderFilters(container);
await loadTasks(container);
});
});
}
function wireGroupToggle(container) {
container.querySelectorAll('.group-toggle__btn').forEach((btn) => {
btn.addEventListener('click', async () => {
state.groupMode = btn.dataset.mode;
container.querySelectorAll('.group-toggle__btn').forEach((b) =>
b.classList.toggle('group-toggle__btn--active', b.dataset.mode === state.groupMode)
);
renderTaskList(container);
});
});
}
function wireNewTaskBtn(container) {
container.querySelector('#btn-new-task')?.addEventListener('click', () => {
openModal(renderModal({ users: state.users }));
wireModalEvents(container);
});
}
function wireModalEvents(container) {
document.getElementById('task-form')?.addEventListener('submit', (e) => handleFormSubmit(e, container));
document.querySelector('[data-action="close-modal"]')
?.addEventListener('click', closeModal);
document.querySelector('[data-action="delete-task"]')
?.addEventListener('click', (e) => handleDeleteTask(e.currentTarget.dataset.id, container));
}
function wireTaskList(container) {
const listEl = container.querySelector('#task-list');
if (!listEl) return;
listEl.addEventListener('click', async (e) => {
const target = e.target.closest('[data-action]');
if (!target) return;
const action = target.dataset.action;
const id = target.dataset.id;
if (action === 'toggle-status') {
const status = target.dataset.status;
target.classList.toggle('task-status-btn--done', status !== 'done');
target.closest('.task-card')?.classList.toggle('task-card--done', status !== 'done');
try {
await toggleTaskStatus(id, status);
await loadTasks(container);
} catch (err) {
window.oikos.showToast(err.message, 'danger');
await loadTasks(container);
}
}
if (action === 'toggle-subtasks') {
const subtaskList = document.getElementById(`subtasks-${id}`);
if (subtaskList) subtaskList.classList.toggle('subtask-list--visible');
}
if (action === 'toggle-subtask') {
try {
await toggleSubtaskStatus(id, target.dataset.status);
await loadTasks(container);
} catch (err) {
window.oikos.showToast(err.message, 'danger');
}
}
if (action === 'edit-task' || action === 'open-task') {
try {
const task = await loadTaskForEdit(id);
openModal(renderModal({ task, users: state.users }));
wireModalEvents(container);
} catch (err) {
window.oikos.showToast('Aufgabe konnte nicht geladen werden.', 'danger');
}
}
if (action === 'add-subtask') {
await handleAddSubtask(target.dataset.parent, container);
}
});
}
// --------------------------------------------------------
// Haupt-Render
// --------------------------------------------------------
export async function render(container, { user }) {
// Initiales Skeleton
container.innerHTML = `
<div class="tasks-page">
<div class="tasks-toolbar">
<h1 class="tasks-toolbar__title">Aufgaben</h1>
<div class="tasks-toolbar__actions">
<div class="group-toggle">
<button class="group-toggle__btn group-toggle__btn--active" data-mode="category">Kategorie</button>
<button class="group-toggle__btn" data-mode="due">Fälligkeit</button>
</div>
<button class="btn btn--primary" id="btn-new-task" style="gap:var(--space-1)">
<i data-lucide="plus" style="width:18px;height:18px"></i> Neu
</button>
</div>
</div>
<div class="tasks-filters" id="filter-bar"></div>
<div id="task-list">
${[1,2,3].map(() => `
<div class="widget-skeleton" style="margin-bottom:var(--space-2)">
<div class="skeleton skeleton-line skeleton-line--medium" style="height:18px;margin-bottom:var(--space-3)"></div>
<div class="skeleton skeleton-line skeleton-line--full" style="height:14px;margin-bottom:var(--space-2)"></div>
<div class="skeleton skeleton-line skeleton-line--short" style="height:12px"></div>
</div>`).join('')}
</div>
</div>
`;
if (window.lucide) window.lucide.createIcons();
// Daten laden
try {
const [tasksData, metaData] = await Promise.all([
api.get('/tasks'),
api.get('/tasks/meta/options'),
]);
state.tasks = tasksData.data ?? [];
state.users = metaData.users ?? [];
} catch (err) {
console.error('[Tasks] Ladefehler:', err.message);
window.oikos.showToast('Aufgaben konnten nicht geladen werden.', 'danger');
state.tasks = [];
state.users = [];
}
// UI verdrahten
wireGroupToggle(container);
wireNewTaskBtn(container);
wireTaskList(container);
renderFilters(container);
renderTaskList(container);
}