TaskSchema module

This commit is contained in:
Marek Lenczewski
2026-04-12 15:42:48 +02:00
parent 4e81cea831
commit 5198769de4
57 changed files with 3066 additions and 324 deletions

View File

@@ -48,6 +48,12 @@ const props = defineProps({
<line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" />
</template>
<template v-else-if="name === 'calendar'">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</template>
<template v-else-if="name === 'refresh'">
<polyline points="23 4 23 10 17 10" />
<polyline points="1 20 1 14 7 14" />

View File

@@ -27,16 +27,40 @@ const router = createRouter({
},
},
{
path: '/tasks/create',
name: 'tasks-create',
component: () => import('../views/TaskCreate.vue'),
path: '/schemas',
name: 'schemas',
component: () => import('../views/SchemaAll.vue'),
meta: {
breadcrumb: [
{ label: 'Tasks', to: '/tasks' },
{ label: 'Schemas' },
],
},
},
{
path: '/schemas/create',
name: 'schemas-create',
component: () => import('../views/SchemaCreate.vue'),
meta: {
breadcrumb: [
{ label: 'Tasks', to: '/tasks' },
{ label: 'Schemas', to: '/schemas' },
{ label: 'Create' },
],
},
},
{
path: '/schemas/:id(\\d+)',
name: 'schemas-edit',
component: () => import('../views/SchemaEdit.vue'),
meta: {
breadcrumb: [
{ label: 'Tasks', to: '/tasks' },
{ label: 'Schemas', to: '/schemas' },
{ label: 'Edit' },
],
},
},
{
path: '/tasks/:id(\\d+)',
name: 'tasks-edit',

View File

@@ -12,10 +12,17 @@ async function request(path, opts = {}) {
return res.status === 204 ? null : res.json()
}
export const schemaApi = {
list: () => request('/task-schemas'),
get: (id) => request(`/task-schemas/${id}`),
create: (data) => request('/task-schemas', { method: 'POST', body: JSON.stringify(data) }),
update: (id, data) => request(`/task-schemas/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
remove: (id) => request(`/task-schemas/${id}`, { method: 'DELETE' }),
}
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' }),

View File

@@ -0,0 +1,44 @@
import { defineStore } from 'pinia'
import { schemaApi } from '../services/api'
export const useSchemasStore = defineStore('schemas', {
state: () => ({
schemas: [],
loading: false,
error: null,
}),
actions: {
async fetchAll() {
this.loading = true
this.error = null
try {
this.schemas = await schemaApi.list()
} catch (e) {
this.error = e.message
} finally {
this.loading = false
}
},
async get(id) {
return schemaApi.get(id)
},
async create(data) {
const result = await schemaApi.create(data)
return result
},
async update(id, data) {
const updated = await schemaApi.update(id, data)
this.schemas = this.schemas.map((s) => (s.id === id ? updated : s))
return updated
},
async remove(id) {
await schemaApi.remove(id)
this.schemas = this.schemas.filter((s) => s.id !== id)
},
},
})

View File

@@ -44,12 +44,6 @@ export const useTasksStore = defineStore('tasks', {
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)

View File

@@ -0,0 +1,145 @@
<script setup>
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useSchemasStore } from '../stores/schemas'
import Icon from '../components/Icon.vue'
const router = useRouter()
const store = useSchemasStore()
function repeatLabel(schema) {
if (!schema.repeat) return 'Einmalig'
if (schema.repeat.daily) return 'Täglich'
if (schema.repeat.weekly) return 'Wöchentlich'
if (schema.repeat.monthly) return 'Monatlich'
return ''
}
async function onDelete(id) {
await store.remove(id)
}
onMounted(() => {
store.fetchAll()
})
</script>
<template>
<div class="schema-all-view">
<div class="actions">
<button class="icon-btn" @click="router.push('/schemas/create')" title="Neues Schema">
<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.schemas.length === 0" class="hint">Lädt</p>
<p v-else-if="store.error" class="error">{{ store.error }}</p>
<p v-else-if="store.schemas.length === 0" class="hint">Keine Schemas.</p>
<ul v-else class="schema-list">
<li
v-for="schema in store.schemas"
:key="schema.id"
class="schema-item"
:class="{ inactive: schema.status === 'inactive' }"
>
<div class="schema-info">
<span class="schema-name">{{ schema.name }}</span>
<span class="schema-repeat">{{ repeatLabel(schema) }}</span>
</div>
<div class="schema-actions">
<button class="icon-btn" @click="router.push(`/schemas/${schema.id}`)" title="Bearbeiten">
<Icon name="pencil" />
</button>
<button class="icon-btn" @click="onDelete(schema.id)" title="Löschen">
<Icon name="trash" />
</button>
</div>
</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);
}
.schema-list {
list-style: none;
padding: 0;
margin: 0;
border: 1px solid var(--border);
border-radius: 0.375rem;
}
.schema-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
}
.schema-item:last-child {
border-bottom: none;
}
.schema-item:hover {
background: var(--breadcrumb-bg);
}
.schema-item.inactive {
opacity: 0.5;
font-style: italic;
}
.schema-info {
display: flex;
align-items: center;
gap: 0.75rem;
}
.schema-repeat {
font-size: 0.85rem;
color: var(--text-muted);
}
.schema-actions {
display: flex;
gap: 0.25rem;
}
.hint {
color: var(--text-muted);
}
.error {
color: #dc2626;
}
</style>

View File

@@ -0,0 +1,249 @@
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useSchemasStore } from '../stores/schemas'
const router = useRouter()
const store = useSchemasStore()
const form = ref({
name: '',
status: 'active',
taskStatus: 'active',
repeatType: 'none',
date: '',
start: '',
end: '',
weekly: Array(7).fill(false),
monthly: Array(31).fill(false),
})
const submitting = ref(false)
const error = ref(null)
const weekdays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
function buildPayload() {
const data = {
name: form.value.name,
status: form.value.status,
taskStatus: form.value.taskStatus,
}
if (form.value.repeatType === 'none') {
data.date = form.value.date || null
} else {
data.start = form.value.start || null
data.end = form.value.end || null
if (form.value.repeatType === 'daily') {
data.repeat = { daily: true }
} else if (form.value.repeatType === 'weekly') {
data.repeat = { weekly: [...form.value.weekly] }
} else if (form.value.repeatType === 'monthly') {
data.repeat = { monthly: [...form.value.monthly] }
}
}
return data
}
async function onSave() {
submitting.value = true
error.value = null
try {
await store.create(buildPayload())
router.push('/schemas')
} catch (e) {
error.value = e.message
} finally {
submitting.value = false
}
}
</script>
<template>
<form class="schema-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-row">
<div class="field">
<label for="status">Schema Status</label>
<select id="status" v-model="form.status">
<option value="active">active</option>
<option value="inactive">inactive</option>
</select>
</div>
<div class="field">
<label for="taskStatus">Task Status</label>
<select id="taskStatus" v-model="form.taskStatus">
<option value="active">active</option>
<option value="done">done</option>
<option value="inactive">inactive</option>
</select>
</div>
</div>
<div class="field">
<label for="repeatType">Wiederholung</label>
<select id="repeatType" v-model="form.repeatType">
<option value="none">Keine (Einmalig)</option>
<option value="daily">Täglich</option>
<option value="weekly">Wöchentlich</option>
<option value="monthly">Monatlich</option>
</select>
</div>
<div v-if="form.repeatType === 'none'" class="field">
<label for="date">Datum</label>
<input id="date" v-model="form.date" type="date" />
</div>
<div v-if="form.repeatType === 'weekly'" class="field">
<label>Wochentage</label>
<div class="day-grid">
<label v-for="(day, i) in weekdays" :key="i" class="day-label" :class="{ selected: form.weekly[i] }">
<input type="checkbox" v-model="form.weekly[i]" class="sr-only" />
{{ day }}
</label>
</div>
</div>
<div v-if="form.repeatType === 'monthly'" class="field">
<label>Monatstage</label>
<div class="day-grid">
<label v-for="d in 31" :key="d" class="day-label" :class="{ selected: form.monthly[d - 1] }">
<input type="checkbox" v-model="form.monthly[d - 1]" class="sr-only" />
{{ d }}
</label>
</div>
</div>
<div v-if="form.repeatType !== 'none'" class="field-row">
<div class="field">
<label for="start">Start</label>
<input id="start" v-model="form.start" type="date" />
</div>
<div class="field">
<label for="end">Ende</label>
<input id="end" v-model="form.end" type="date" />
</div>
</div>
<p v-if="error" class="error">{{ error }}</p>
<div class="form-actions">
<button type="submit" :disabled="submitting || !form.name">Erstellen</button>
<button type="button" @click="router.back()">Abbrechen</button>
</div>
</form>
</template>
<style scoped>
.schema-form {
max-width: 480px;
display: flex;
flex-direction: column;
gap: 1rem;
}
.field-row {
display: flex;
gap: 1rem;
}
.field-row .field {
flex: 1;
}
.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);
}
.day-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 0.25rem;
}
.day-label {
display: flex;
align-items: center;
justify-content: center;
padding: 0.375rem;
border: 1px solid var(--border);
border-radius: 0.25rem;
font-size: 0.85rem;
cursor: pointer;
user-select: none;
}
.day-label.selected {
background: var(--text);
color: var(--bg);
border-color: var(--text);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.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;
}
</style>

View File

@@ -0,0 +1,302 @@
<script setup>
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useSchemasStore } from '../stores/schemas'
const route = useRoute()
const router = useRouter()
const store = useSchemasStore()
const id = Number(route.params.id)
const original = ref(null)
const form = ref({
name: '',
status: 'active',
taskStatus: 'active',
repeatType: 'none',
date: '',
start: '',
end: '',
weekly: Array(7).fill(false),
monthly: Array(31).fill(false),
})
const submitting = ref(false)
const error = ref(null)
const weekdays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
function toFormDate(iso) {
if (!iso) return ''
return iso.slice(0, 10)
}
function detectRepeatType(schema) {
if (!schema.repeat) return 'none'
if (schema.repeat.daily) return 'daily'
if (schema.repeat.weekly) return 'weekly'
if (schema.repeat.monthly) return 'monthly'
return 'none'
}
function loadFromSchema(schema) {
const repeatType = detectRepeatType(schema)
return {
name: schema.name,
status: schema.status,
taskStatus: schema.taskStatus,
repeatType,
date: toFormDate(schema.date),
start: toFormDate(schema.start),
end: toFormDate(schema.end),
weekly: schema.repeat?.weekly ? [...schema.repeat.weekly] : Array(7).fill(false),
monthly: schema.repeat?.monthly ? [...schema.repeat.monthly] : Array(31).fill(false),
}
}
function buildPayload() {
const data = {
name: form.value.name,
status: form.value.status,
taskStatus: form.value.taskStatus,
}
if (form.value.repeatType === 'none') {
data.date = form.value.date || null
} else {
data.start = form.value.start || null
data.end = form.value.end || null
if (form.value.repeatType === 'daily') {
data.repeat = { daily: true }
} else if (form.value.repeatType === 'weekly') {
data.repeat = { weekly: [...form.value.weekly] }
} else if (form.value.repeatType === 'monthly') {
data.repeat = { monthly: [...form.value.monthly] }
}
}
return data
}
onMounted(async () => {
try {
const schema = await store.get(id)
original.value = schema
form.value = loadFromSchema(schema)
} catch (e) {
error.value = e.message
}
})
async function onUpdate() {
submitting.value = true
error.value = null
try {
await store.update(id, buildPayload())
router.push('/schemas')
} catch (e) {
error.value = e.message
} finally {
submitting.value = false
}
}
function onReset() {
if (original.value) {
form.value = loadFromSchema(original.value)
}
}
</script>
<template>
<form v-if="original" class="schema-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-row">
<div class="field">
<label for="status">Schema Status</label>
<select id="status" v-model="form.status">
<option value="active">active</option>
<option value="inactive">inactive</option>
</select>
</div>
<div class="field">
<label for="taskStatus">Task Status</label>
<select id="taskStatus" v-model="form.taskStatus">
<option value="active">active</option>
<option value="done">done</option>
<option value="inactive">inactive</option>
</select>
</div>
</div>
<div class="field">
<label for="repeatType">Wiederholung</label>
<select id="repeatType" v-model="form.repeatType">
<option value="none">Keine (Einmalig)</option>
<option value="daily">Täglich</option>
<option value="weekly">Wöchentlich</option>
<option value="monthly">Monatlich</option>
</select>
</div>
<div v-if="form.repeatType === 'none'" class="field">
<label for="date">Datum</label>
<input id="date" v-model="form.date" type="date" />
</div>
<div v-if="form.repeatType === 'weekly'" class="field">
<label>Wochentage</label>
<div class="day-grid">
<label v-for="(day, i) in weekdays" :key="i" class="day-label" :class="{ selected: form.weekly[i] }">
<input type="checkbox" v-model="form.weekly[i]" class="sr-only" />
{{ day }}
</label>
</div>
</div>
<div v-if="form.repeatType === 'monthly'" class="field">
<label>Monatstage</label>
<div class="day-grid">
<label v-for="d in 31" :key="d" class="day-label" :class="{ selected: form.monthly[d - 1] }">
<input type="checkbox" v-model="form.monthly[d - 1]" class="sr-only" />
{{ d }}
</label>
</div>
</div>
<div v-if="form.repeatType !== 'none'" class="field-row">
<div class="field">
<label for="start">Start</label>
<input id="start" v-model="form.start" type="date" />
</div>
<div class="field">
<label for="end">Ende</label>
<input id="end" v-model="form.end" type="date" />
</div>
</div>
<p v-if="error" class="error">{{ error }}</p>
<div class="form-actions">
<button type="submit" :disabled="submitting || !form.name">Aktualisieren</button>
<button type="button" @click="onReset">Zurücksetzen</button>
<button type="button" @click="router.back()">Abbrechen</button>
</div>
</form>
<p v-else-if="error" class="error">{{ error }}</p>
<p v-else class="hint">Lädt</p>
</template>
<style scoped>
.schema-form {
max-width: 480px;
display: flex;
flex-direction: column;
gap: 1rem;
}
.field-row {
display: flex;
gap: 1rem;
}
.field-row .field {
flex: 1;
}
.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);
}
.day-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 0.25rem;
}
.day-label {
display: flex;
align-items: center;
justify-content: center;
padding: 0.375rem;
border: 1px solid var(--border);
border-radius: 0.25rem;
font-size: 0.85rem;
cursor: pointer;
user-select: none;
}
.day-label.selected {
background: var(--text);
color: var(--bg);
border-color: var(--text);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.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>

View File

@@ -30,7 +30,10 @@ onMounted(() => {
<template>
<div class="task-view">
<div class="actions">
<button class="icon-btn" @click="router.push('/tasks/create')" title="Neuer Task">
<button class="icon-btn" @click="router.push('/schemas')" title="Schemas">
<Icon name="calendar" />
</button>
<button class="icon-btn" @click="router.push('/schemas/create')" title="Neues Schema">
<Icon name="plus" />
</button>
<button class="icon-btn" @click="router.push('/tasks/all')" title="Alle Tasks">

View File

@@ -24,7 +24,10 @@ onMounted(() => {
<template>
<div class="task-all-view">
<div class="actions">
<button class="icon-btn" @click="router.push('/tasks/create')" title="Neuer Task">
<button class="icon-btn" @click="router.push('/schemas')" title="Schemas">
<Icon name="calendar" />
</button>
<button class="icon-btn" @click="router.push('/schemas/create')" title="Neues Schema">
<Icon name="plus" />
</button>
<button class="icon-btn" @click="store.fetchAll()" title="Neu laden">

View File

@@ -1,132 +0,0 @@
<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>