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>
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
<link rel="stylesheet" href="/styles/layout.css" />
|
||||
<link rel="stylesheet" href="/styles/login.css" />
|
||||
<link rel="stylesheet" href="/styles/dashboard.css" />
|
||||
<link rel="stylesheet" href="/styles/tasks.css" />
|
||||
|
||||
<!-- Lucide Icons (CDN) -->
|
||||
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|
||||
|
||||
+663
-14
@@ -1,25 +1,674 @@
|
||||
/**
|
||||
* Modul: Tasks
|
||||
* Zweck: Seite für das Tasks-Modul
|
||||
* Modul: Aufgaben (Tasks)
|
||||
* Zweck: Listenansicht mit Filtern, Gruppierung, CRUD-Modal, Subtask-Verwaltung
|
||||
* Abhängigkeiten: /api.js
|
||||
*/
|
||||
|
||||
import { api } from '/api.js';
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} container
|
||||
* @param {{ user: object }} context
|
||||
*/
|
||||
export async function render(container, { user }) {
|
||||
container.innerHTML = `
|
||||
<div class="page">
|
||||
<div class="page__header">
|
||||
<h1 class="page__title">Tasks</h1>
|
||||
// --------------------------------------------------------
|
||||
// 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>
|
||||
<div class="empty-state">
|
||||
<div class="empty-state__title">Kommt bald.</div>
|
||||
<div class="empty-state__description">Dieses Modul wird in Phase 2 implementiert.</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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,523 @@
|
||||
/**
|
||||
* Modul: Aufgaben (Tasks)
|
||||
* Zweck: Styles für Listenansicht, Task-Cards, Subtasks, Filter-Panel, Modal
|
||||
* Abhängigkeiten: tokens.css, layout.css
|
||||
*/
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Seiten-Layout
|
||||
* -------------------------------------------------------- */
|
||||
.tasks-page {
|
||||
padding: var(--space-4);
|
||||
padding-bottom: calc(var(--nav-height-mobile) + var(--safe-area-inset-bottom) + var(--space-16));
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.tasks-page {
|
||||
padding: var(--space-8);
|
||||
padding-bottom: var(--space-16);
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Header + Toolbar
|
||||
* -------------------------------------------------------- */
|
||||
.tasks-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tasks-toolbar__title {
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tasks-toolbar__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
/* Gruppierungs-Toggle */
|
||||
.group-toggle {
|
||||
display: flex;
|
||||
background-color: var(--color-surface-2);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 3px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.group-toggle__btn {
|
||||
padding: var(--space-1) var(--space-3);
|
||||
border-radius: calc(var(--radius-sm) - 2px);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-secondary);
|
||||
transition: background-color var(--transition-fast), color var(--transition-fast);
|
||||
min-height: unset;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.group-toggle__btn--active {
|
||||
background-color: var(--color-surface);
|
||||
color: var(--color-text-primary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Filter-Leiste
|
||||
* -------------------------------------------------------- */
|
||||
.tasks-filters {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-4);
|
||||
overflow-x: auto;
|
||||
padding-bottom: var(--space-1);
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.tasks-filters::-webkit-scrollbar { display: none; }
|
||||
|
||||
.filter-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
border: 1.5px solid var(--color-border);
|
||||
background-color: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
transition: all var(--transition-fast);
|
||||
min-height: 34px;
|
||||
}
|
||||
|
||||
.filter-chip--active {
|
||||
background-color: var(--color-accent-light);
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.filter-chip__remove {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-full);
|
||||
background-color: var(--color-accent);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Gruppen-Überschriften
|
||||
* -------------------------------------------------------- */
|
||||
.task-group {
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.task-group__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-2);
|
||||
padding: 0 var(--space-1);
|
||||
}
|
||||
|
||||
.task-group__title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.task-group__count {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-disabled);
|
||||
background-color: var(--color-surface-2);
|
||||
padding: 2px var(--space-2);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Task-Card
|
||||
* -------------------------------------------------------- */
|
||||
.task-card {
|
||||
background-color: var(--color-surface);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-sm);
|
||||
margin-bottom: var(--space-2);
|
||||
overflow: hidden;
|
||||
transition: box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.task-card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.task-card--done {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.task-card__main {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
/* Status-Checkbox */
|
||||
.task-status-btn {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: var(--radius-full);
|
||||
border: 2px solid var(--color-border);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: border-color var(--transition-fast), background-color var(--transition-fast);
|
||||
min-height: unset;
|
||||
}
|
||||
|
||||
.task-status-btn--done {
|
||||
border-color: var(--color-success);
|
||||
background-color: var(--color-success);
|
||||
}
|
||||
|
||||
.task-status-btn--in_progress {
|
||||
border-color: var(--color-warning);
|
||||
}
|
||||
|
||||
.task-status-btn__check {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: #fff;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.task-status-btn--done .task-status-btn__check { display: block; }
|
||||
|
||||
.task-card__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-card__title {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: var(--space-1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.task-card--done .task-card__title {
|
||||
text-decoration: line-through;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.task-card__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Prioritäts-Badge */
|
||||
.priority-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
padding: 2px var(--space-2);
|
||||
border-radius: var(--radius-xs);
|
||||
}
|
||||
|
||||
.priority-badge--low { color: var(--color-priority-low); background-color: #8E8E9322; }
|
||||
.priority-badge--medium { color: var(--color-priority-medium); background-color: #FF950022; }
|
||||
.priority-badge--high { color: var(--color-priority-high); background-color: #FF6B3522; }
|
||||
.priority-badge--urgent { color: var(--color-priority-urgent); background-color: #FF3B3022; }
|
||||
|
||||
.priority-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: var(--radius-full);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.priority-dot--low { background-color: var(--color-priority-low); }
|
||||
.priority-dot--medium { background-color: var(--color-priority-medium); }
|
||||
.priority-dot--high { background-color: var(--color-priority-high); }
|
||||
.priority-dot--urgent { background-color: var(--color-priority-urgent); }
|
||||
|
||||
/* Fälligkeitsdatum */
|
||||
.due-date {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.due-date--overdue { color: var(--color-danger); font-weight: var(--font-weight-semibold); }
|
||||
.due-date--today { color: var(--color-warning); font-weight: var(--font-weight-semibold); }
|
||||
|
||||
/* Avatar */
|
||||
.task-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-full);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Subtask-Fortschrittsbalken */
|
||||
.subtask-progress {
|
||||
padding: 0 var(--space-4) var(--space-3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.subtask-progress__bar-wrap {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background-color: var(--color-border);
|
||||
border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.subtask-progress__bar-fill {
|
||||
height: 100%;
|
||||
background-color: var(--color-success);
|
||||
border-radius: var(--radius-full);
|
||||
transition: width var(--transition-slow);
|
||||
}
|
||||
|
||||
.subtask-progress__text {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Subtask-Liste (eingeklappt/ausgeklappt) */
|
||||
.subtask-list {
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.subtask-list--visible { display: block; }
|
||||
|
||||
.subtask-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.subtask-item:last-child { border-bottom: none; }
|
||||
|
||||
.subtask-item__checkbox {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: var(--radius-xs);
|
||||
border: 1.5px solid var(--color-border);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all var(--transition-fast);
|
||||
min-height: unset;
|
||||
}
|
||||
|
||||
.subtask-item__checkbox--done {
|
||||
background-color: var(--color-success);
|
||||
border-color: var(--color-success);
|
||||
}
|
||||
|
||||
.subtask-item__title {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-primary);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.subtask-item--done .subtask-item__title {
|
||||
text-decoration: line-through;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.subtask-item__add {
|
||||
width: 100%;
|
||||
padding: var(--space-2);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
background: none;
|
||||
border: 1.5px dashed var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
margin-top: var(--space-2);
|
||||
min-height: unset;
|
||||
transition: border-color var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.subtask-item__add:hover {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Task-Modal (Erstellen / Bearbeiten)
|
||||
* -------------------------------------------------------- */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
z-index: var(--z-modal);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
animation: backdrop-in 0.2s ease;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.modal-backdrop {
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes backdrop-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.modal {
|
||||
background-color: var(--color-surface);
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
width: 100%;
|
||||
max-height: 92dvh;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-6);
|
||||
padding-bottom: calc(var(--space-6) + var(--safe-area-inset-bottom));
|
||||
animation: modal-in 0.25s ease;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.modal {
|
||||
border-radius: var(--radius-lg);
|
||||
max-width: 540px;
|
||||
max-height: 85dvh;
|
||||
padding-bottom: var(--space-6);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes modal-in {
|
||||
from { transform: translateY(24px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.modal__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.modal__title {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.modal__close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-full);
|
||||
background-color: var(--color-surface-2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
min-height: unset;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.modal__actions {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-6);
|
||||
}
|
||||
|
||||
.modal__actions .btn { flex: 1; }
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Overdue-Badge (Navigation)
|
||||
* -------------------------------------------------------- */
|
||||
.nav-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: var(--radius-full);
|
||||
background-color: var(--color-danger);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: var(--font-weight-bold);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Leer-Zustand
|
||||
* -------------------------------------------------------- */
|
||||
.tasks-empty {
|
||||
padding: var(--space-12) var(--space-4);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tasks-empty__icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
color: var(--color-text-disabled);
|
||||
margin: 0 auto var(--space-4);
|
||||
}
|
||||
|
||||
.tasks-empty__title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.tasks-empty__desc {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
Reference in New Issue
Block a user