Task module
This commit is contained in:
64
frontend/src/components/Icon.vue
Normal file
64
frontend/src/components/Icon.vue
Normal 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>
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
23
frontend/src/services/api.js
Normal file
23
frontend/src/services/api.js
Normal 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'),
|
||||
}
|
||||
77
frontend/src/stores/tasks.js
Normal file
77
frontend/src/stores/tasks.js
Normal 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)
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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
170
frontend/src/views/Task.vue
Normal 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>
|
||||
171
frontend/src/views/TaskAll.vue
Normal file
171
frontend/src/views/TaskAll.vue
Normal 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>
|
||||
132
frontend/src/views/TaskCreate.vue
Normal file
132
frontend/src/views/TaskCreate.vue
Normal 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>
|
||||
165
frontend/src/views/TaskEdit.vue
Normal file
165
frontend/src/views/TaskEdit.vue
Normal 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>
|
||||
Reference in New Issue
Block a user