Task module

This commit is contained in:
Marek Lenczewski
2026-04-12 10:06:17 +02:00
parent efe0cfe361
commit 27b34eb90f
39 changed files with 2454 additions and 41 deletions

View File

@@ -0,0 +1,64 @@
<script setup>
const props = defineProps({
name: { type: String, required: true },
size: { type: [String, Number], default: 20 },
})
</script>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
:width="size"
:height="size"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="icon"
>
<template v-if="name === 'list'">
<line x1="8" y1="6" x2="21" y2="6" />
<line x1="8" y1="12" x2="21" y2="12" />
<line x1="8" y1="18" x2="21" y2="18" />
<line x1="3" y1="6" x2="3.01" y2="6" />
<line x1="3" y1="12" x2="3.01" y2="12" />
<line x1="3" y1="18" x2="3.01" y2="18" />
</template>
<template v-else-if="name === 'plus'">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</template>
<template v-else-if="name === 'eye'">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</template>
<template v-else-if="name === 'eye-off'">
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
<line x1="1" y1="1" x2="23" y2="23" />
</template>
<template v-else-if="name === 'pencil'">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
</template>
<template v-else-if="name === 'trash'">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" />
</template>
<template v-else-if="name === 'refresh'">
<polyline points="23 4 23 10 17 10" />
<polyline points="1 20 1 14 7 14" />
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
</template>
</svg>
</template>
<style scoped>
.icon {
display: inline-block;
vertical-align: middle;
}
</style>

View File

@@ -9,6 +9,45 @@ const router = createRouter({
component: () => import('../views/Startpage.vue'),
meta: { breadcrumb: [] },
},
{
path: '/tasks',
name: 'tasks',
component: () => import('../views/Task.vue'),
meta: { breadcrumb: [{ label: 'Tasks', to: '/tasks' }] },
},
{
path: '/tasks/all',
name: 'tasks-all',
component: () => import('../views/TaskAll.vue'),
meta: {
breadcrumb: [
{ label: 'Tasks', to: '/tasks' },
{ label: 'All' },
],
},
},
{
path: '/tasks/create',
name: 'tasks-create',
component: () => import('../views/TaskCreate.vue'),
meta: {
breadcrumb: [
{ label: 'Tasks', to: '/tasks' },
{ label: 'Create' },
],
},
},
{
path: '/tasks/:id(\\d+)',
name: 'tasks-edit',
component: () => import('../views/TaskEdit.vue'),
meta: {
breadcrumb: [
{ label: 'Tasks', to: '/tasks' },
{ label: 'Edit' },
],
},
},
],
})

View File

@@ -0,0 +1,23 @@
const BASE = 'https://haushalt.ddev.site/api'
async function request(path, opts = {}) {
const res = await fetch(`${BASE}${path}`, {
headers: { 'Content-Type': 'application/json', ...(opts.headers ?? {}) },
...opts,
})
if (!res.ok) {
const body = await res.text().catch(() => '')
throw new Error(`API ${res.status} ${res.statusText}${body ? `: ${body}` : ''}`)
}
return res.status === 204 ? null : res.json()
}
export const taskApi = {
list: (filter) => request(`/tasks${filter ? `?filter=${filter}` : ''}`),
get: (id) => request(`/tasks/${id}`),
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' }),
toggle: (id) => request(`/tasks/${id}/toggle`, { method: 'PATCH' }),
statuses: () => request('/tasks/statuses'),
}

View File

@@ -0,0 +1,77 @@
import { defineStore } from 'pinia'
import { taskApi } from '../services/api'
export const useTasksStore = defineStore('tasks', {
state: () => ({
tasks: [],
currentTasks: [],
availableStatuses: [],
loading: false,
error: null,
}),
actions: {
async fetchAll() {
this.loading = true
this.error = null
try {
this.tasks = await taskApi.list()
} catch (e) {
this.error = e.message
} finally {
this.loading = false
}
},
async fetchCurrent() {
this.loading = true
this.error = null
try {
this.currentTasks = await taskApi.list('current')
} catch (e) {
this.error = e.message
} finally {
this.loading = false
}
},
async get(id) {
return taskApi.get(id)
},
async fetchStatuses() {
if (this.availableStatuses.length > 0) return
this.availableStatuses = await taskApi.statuses()
},
async create(data) {
const task = await taskApi.create(data)
this.tasks.push(task)
return task
},
async update(id, data) {
const updated = await taskApi.update(id, data)
this.replaceLocal(updated)
return updated
},
async remove(id) {
await taskApi.remove(id)
this.tasks = this.tasks.filter((t) => t.id !== id)
this.currentTasks = this.currentTasks.filter((t) => t.id !== id)
},
async toggle(id) {
const updated = await taskApi.toggle(id)
this.replaceLocal(updated)
return updated
},
replaceLocal(task) {
const replace = (list) => list.map((t) => (t.id === task.id ? task : t))
this.tasks = replace(this.tasks)
this.currentTasks = replace(this.currentTasks)
},
},
})

View File

@@ -1,3 +1,35 @@
<script setup>
import { RouterLink } from 'vue-router'
</script>
<template>
<div class="start-page"></div>
<div class="tile-grid">
<RouterLink to="/tasks" class="tile">Tasks</RouterLink>
</div>
</template>
<style scoped>
.tile-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 1rem;
}
.tile {
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 1;
border: 1px solid var(--border);
border-radius: 0.5rem;
text-decoration: none;
color: var(--text);
font-weight: 600;
font-size: 1.125rem;
background: var(--breadcrumb-bg);
}
.tile:hover {
border-color: var(--text);
}
</style>

170
frontend/src/views/Task.vue Normal file
View File

@@ -0,0 +1,170 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useTasksStore } from '../stores/tasks'
import Icon from '../components/Icon.vue'
const router = useRouter()
const store = useTasksStore()
const showDone = ref(false)
const visibleTasks = computed(() => {
if (showDone.value) return store.currentTasks
return store.currentTasks.filter((t) => t.status !== 'done')
})
function formatDate(iso) {
return new Date(iso).toLocaleDateString('de-DE')
}
const grouped = computed(() =>
Object.groupBy(visibleTasks.value, (t) => (t.date ? formatDate(t.date) : ''))
)
onMounted(() => {
store.fetchCurrent()
})
</script>
<template>
<div class="task-view">
<div class="actions">
<button class="icon-btn" @click="router.push('/tasks/create')" title="Neuer Task">
<Icon name="plus" />
</button>
<button class="icon-btn" @click="router.push('/tasks/all')" title="Alle Tasks">
<Icon name="list" />
</button>
<button class="icon-btn" @click="showDone = !showDone" :title="showDone ? 'Erledigte ausblenden' : 'Erledigte einblenden'">
<Icon :name="showDone ? 'eye' : 'eye-off'" />
</button>
<button class="icon-btn" @click="store.fetchCurrent()" title="Neu laden">
<Icon name="refresh" />
</button>
</div>
<p v-if="store.loading && visibleTasks.length === 0" class="hint">Lädt</p>
<p v-else-if="store.error" class="error">{{ store.error }}</p>
<p v-else-if="visibleTasks.length === 0" class="hint">Keine Tasks.</p>
<div v-else class="task-groups">
<section
v-for="(tasks, key) in grouped"
:key="key || 'no-date'"
class="task-group"
>
<h3 v-if="key" class="group-title">{{ key }}</h3>
<ul class="task-list">
<li
v-for="task in tasks"
:key="task.id"
class="task-item"
:class="{ done: task.status === 'done' }"
@click="store.toggle(task.id)"
>
<span class="task-name">{{ task.name }}</span>
</li>
</ul>
</section>
</div>
</div>
</template>
<style scoped>
.actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-bottom: 1rem;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
padding: 0;
background: transparent;
border: 1px solid var(--border);
border-radius: 0.375rem;
color: var(--text);
cursor: pointer;
}
.icon-btn:hover {
background: var(--breadcrumb-bg);
}
.task-groups {
display: flex;
flex-direction: column;
gap: 2rem;
}
.task-group {
position: relative;
border: 1px solid var(--border);
border-radius: 0.375rem;
padding-top: 0.25rem;
}
.group-title {
position: absolute;
top: -0.75rem;
left: 50%;
transform: translateX(-50%);
margin: 0;
padding: 0.1rem 0.5rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 0.375rem;
font-size: 0.85rem;
font-weight: 400;
line-height: 1.2;
color: var(--text);
}
.task-list {
list-style: none;
padding: 0;
margin: 0;
}
.task-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
cursor: pointer;
}
.task-item:last-child {
border-bottom: none;
}
.task-item:hover {
background: var(--breadcrumb-bg);
}
.task-item.done .task-name {
text-decoration: line-through;
color: var(--text-muted);
}
.task-date {
font-size: 0.85rem;
color: var(--text-muted);
}
.hint {
color: var(--text-muted);
}
.error {
color: #dc2626;
}
</style>

View File

@@ -0,0 +1,171 @@
<script setup>
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useTasksStore } from '../stores/tasks'
import Icon from '../components/Icon.vue'
const router = useRouter()
const store = useTasksStore()
function formatDate(iso) {
return new Date(iso).toLocaleDateString('de-DE')
}
async function onDelete(id) {
if (!confirm('Task wirklich löschen?')) return
await store.remove(id)
}
onMounted(() => {
store.fetchAll()
})
</script>
<template>
<div class="task-all-view">
<div class="actions">
<button class="icon-btn" @click="router.push('/tasks/create')" title="Neuer Task">
<Icon name="plus" />
</button>
<button class="icon-btn" @click="store.fetchAll()" title="Neu laden">
<Icon name="refresh" />
</button>
</div>
<p v-if="store.loading && store.tasks.length === 0" class="hint">Lädt</p>
<p v-else-if="store.error" class="error">{{ store.error }}</p>
<p v-else-if="store.tasks.length === 0" class="hint">Keine Tasks.</p>
<ul v-else class="task-list">
<li
v-for="task in store.tasks"
:key="task.id"
class="task-item"
:class="{
done: task.status === 'done',
inactive: task.status === 'inactive',
past: task.status === 'past',
}"
@click="store.toggle(task.id)"
>
<span class="task-name">{{ task.name }}</span>
<span v-if="task.date" class="task-date">{{ formatDate(task.date) }}</span>
<span class="row-actions">
<button
class="icon-btn"
@click.stop="router.push(`/tasks/${task.id}`)"
title="Bearbeiten"
>
<Icon name="pencil" :size="16" />
</button>
<button
class="icon-btn danger"
@click.stop="onDelete(task.id)"
title="Löschen"
>
<Icon name="trash" :size="16" />
</button>
</span>
</li>
</ul>
</div>
</template>
<style scoped>
.actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-bottom: 1rem;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
padding: 0;
background: transparent;
border: 1px solid var(--border);
border-radius: 0.375rem;
color: var(--text);
cursor: pointer;
}
.icon-btn:hover {
background: var(--breadcrumb-bg);
}
.icon-btn.danger:hover {
border-color: #dc2626;
color: #dc2626;
}
.task-list {
list-style: none;
padding: 0;
margin: 0;
border: 1px solid var(--border);
border-radius: 0.375rem;
}
.task-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
cursor: pointer;
}
.task-item:last-child {
border-bottom: none;
}
.task-item:hover {
background: var(--breadcrumb-bg);
}
.task-name {
flex: 1;
}
.task-item.done .task-name {
text-decoration: line-through;
color: var(--text-muted);
}
.task-item.inactive .task-name {
color: var(--text-muted);
font-style: italic;
}
.task-item.past {
opacity: 0.5;
}
.task-date {
font-size: 0.85rem;
color: var(--text-muted);
margin-right: 0.5rem;
}
.row-actions {
display: inline-flex;
gap: 0.25rem;
}
.row-actions .icon-btn {
width: 2rem;
height: 2rem;
}
.hint {
color: var(--text-muted);
}
.error {
color: #dc2626;
}
</style>

View File

@@ -0,0 +1,132 @@
<script setup>
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useTasksStore } from '../stores/tasks'
const router = useRouter()
const store = useTasksStore()
const form = ref({
name: '',
date: '',
status: 'active',
})
const submitting = ref(false)
const error = ref(null)
onMounted(() => {
store.fetchStatuses()
})
async function onSave() {
submitting.value = true
error.value = null
try {
await store.create({
name: form.value.name,
date: form.value.date || null,
status: form.value.status,
})
router.push('/tasks')
} catch (e) {
error.value = e.message
} finally {
submitting.value = false
}
}
function onAbort() {
router.back()
}
</script>
<template>
<form class="task-form" @submit.prevent="onSave">
<div class="field">
<label for="name">Name</label>
<input id="name" v-model="form.name" type="text" required />
</div>
<div class="field">
<label for="date">Datum</label>
<input id="date" v-model="form.date" type="date" />
</div>
<div class="field">
<label for="status">Status</label>
<select id="status" v-model="form.status">
<option v-for="key in store.availableStatuses" :key="key" :value="key">
{{ key }}
</option>
</select>
</div>
<p v-if="error" class="error">{{ error }}</p>
<div class="form-actions">
<button type="submit" :disabled="submitting">Speichern</button>
<button type="button" @click="onAbort">Abbrechen</button>
</div>
</form>
</template>
<style scoped>
.task-form {
max-width: 480px;
display: flex;
flex-direction: column;
gap: 1rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.field label {
font-size: 0.85rem;
color: var(--text-muted);
}
.field input,
.field select {
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
font-size: 1rem;
font-family: inherit;
color: var(--text);
background: var(--bg);
}
.form-actions {
display: flex;
gap: 0.5rem;
}
button {
padding: 0.5rem 1rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
background: var(--breadcrumb-bg);
color: var(--text);
font-family: inherit;
cursor: pointer;
}
button[type='submit'] {
background: var(--text);
color: var(--bg);
border-color: var(--text);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.error {
color: #dc2626;
}
</style>

View File

@@ -0,0 +1,165 @@
<script setup>
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useTasksStore } from '../stores/tasks'
const route = useRoute()
const router = useRouter()
const store = useTasksStore()
const id = Number(route.params.id)
const original = ref(null)
const form = ref({ name: '', date: '', status: 'active' })
const submitting = ref(false)
const error = ref(null)
function toFormDate(iso) {
if (!iso) return ''
return iso.slice(0, 10)
}
function loadFromTask(task) {
return {
name: task.name,
date: toFormDate(task.date),
status: task.status,
}
}
onMounted(async () => {
store.fetchStatuses()
try {
const task = await store.get(id)
original.value = task
form.value = loadFromTask(task)
} catch (e) {
error.value = e.message
}
})
async function onUpdate() {
submitting.value = true
error.value = null
try {
await store.update(id, {
name: form.value.name,
date: form.value.date || null,
status: form.value.status,
})
router.push('/tasks/all')
} catch (e) {
error.value = e.message
} finally {
submitting.value = false
}
}
function onReset() {
if (original.value) {
form.value = loadFromTask(original.value)
}
}
function onAbort() {
router.back()
}
</script>
<template>
<form v-if="original" class="task-form" @submit.prevent="onUpdate">
<div class="field">
<label for="name">Name</label>
<input id="name" v-model="form.name" type="text" required />
</div>
<div class="field">
<label for="date">Datum</label>
<input id="date" v-model="form.date" type="date" />
</div>
<div class="field">
<label for="status">Status</label>
<select id="status" v-model="form.status">
<option v-for="key in store.availableStatuses" :key="key" :value="key">
{{ key }}
</option>
</select>
</div>
<p v-if="error" class="error">{{ error }}</p>
<div class="form-actions">
<button type="submit" :disabled="submitting">Aktualisieren</button>
<button type="button" @click="onReset">Zurücksetzen</button>
<button type="button" @click="onAbort">Abbrechen</button>
</div>
</form>
<p v-else-if="error" class="error">{{ error }}</p>
<p v-else class="hint">Lädt</p>
</template>
<style scoped>
.task-form {
max-width: 480px;
display: flex;
flex-direction: column;
gap: 1rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.field label {
font-size: 0.85rem;
color: var(--text-muted);
}
.field input,
.field select {
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
font-size: 1rem;
font-family: inherit;
color: var(--text);
background: var(--bg);
}
.form-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
button {
padding: 0.5rem 1rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
background: var(--breadcrumb-bg);
color: var(--text);
font-family: inherit;
cursor: pointer;
}
button[type='submit'] {
background: var(--text);
color: var(--bg);
border-color: var(--text);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.error {
color: #dc2626;
}
.hint {
color: var(--text-muted);
}
</style>