Files
FEDEO/frontend/pages/tasks/index.vue
2026-02-21 22:41:07 +01:00

737 lines
21 KiB
Vue

<script setup>
import { setPageLayout } from "#app"
const router = useRouter()
const route = useRoute()
const toast = useToast()
const auth = useAuthStore()
const profileStore = useProfileStore()
const { has } = usePermission()
const STATUS_COLUMNS = ["Offen", "In Bearbeitung", "Abgeschlossen"]
const loading = ref(false)
const saving = ref(false)
const deleting = ref(false)
const quickCompleteLoadingId = ref(null)
const tasks = ref([])
const search = ref("")
const viewMode = ref("kanban")
const draggingTaskId = ref(null)
const droppingOn = ref("")
const projects = ref([])
const customers = ref([])
const plants = ref([])
const canViewAll = computed(() => has("tasks-viewAll"))
const canCreate = computed(() => has("tasks-create"))
const currentUserId = computed(() => auth.user?.user_id || auth.user?.id || null)
const showOnlyMine = ref(!canViewAll.value)
const isModalOpen = ref(false)
const modalMode = ref("show")
const taskForm = ref(getEmptyTask())
const assigneeOptions = computed(() =>
(profileStore.profiles || [])
.map((profile) => {
const value = profile.user_id || profile.id
const label = profile.full_name || profile.fullName || profile.email
return value && label ? { value, label } : null
})
.filter(Boolean)
)
const projectOptions = computed(() =>
(projects.value || []).map((project) => ({ value: project.id, label: project.name }))
)
const customerOptions = computed(() =>
(customers.value || []).map((customer) => ({ value: customer.id, label: customer.name }))
)
const plantOptions = computed(() =>
(plants.value || []).map((plant) => ({ value: plant.id, label: plant.name }))
)
const filteredTasks = computed(() => {
const needle = search.value.trim().toLowerCase()
return tasks.value.filter((task) => {
const assigneeId = getTaskAssigneeId(task)
const mineMatch = !showOnlyMine.value || (currentUserId.value && assigneeId === currentUserId.value)
const searchMatch = !needle || [task.name, task.description, task.categorie].some((value) => String(value || "").toLowerCase().includes(needle))
return !task.archived && mineMatch && searchMatch
})
})
const groupedTasks = computed(() => {
return STATUS_COLUMNS.reduce((acc, status) => {
acc[status] = filteredTasks.value.filter((task) => normalizeStatus(task.categorie) === status)
return acc
}, {})
})
const modalTitle = computed(() => {
if (modalMode.value === "create") return "Neue Aufgabe"
if (modalMode.value === "edit") return "Aufgabe bearbeiten"
return "Aufgabe"
})
const isFormReadonly = computed(() => modalMode.value === "show")
const listColumns = [
{ key: "actions", label: "" },
{ key: "name", label: "Titel" },
{ key: "categorie", label: "Status" },
{ key: "assignee", label: "Zuweisung" },
{ key: "project", label: "Projekt" },
{ key: "customer", label: "Kunde" },
{ key: "plant", label: "Objekt" }
]
function getEmptyTask() {
return {
id: null,
name: "",
description: "",
categorie: "Offen",
userId: currentUserId.value || null,
project: null,
customer: null,
plant: null
}
}
function normalizeStatus(status) {
if (!status) return "Offen"
if (status === "Erledigt") return "Abgeschlossen"
return STATUS_COLUMNS.includes(status) ? status : "Offen"
}
function getTaskAssigneeId(task) {
return task.userId || task.user_id || task.profile || null
}
function toNullableNumber(value) {
if (value === null || value === undefined || value === "") return null
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
function mapTaskToForm(task) {
return {
id: task.id,
name: task.name || "",
description: task.description || "",
categorie: normalizeStatus(task.categorie),
userId: getTaskAssigneeId(task),
project: toNullableNumber(task.project?.id ?? task.project),
customer: toNullableNumber(task.customer?.id ?? task.customer),
plant: toNullableNumber(task.plant?.id ?? task.plant)
}
}
function getEntityLabel(options, id) {
if (!id) return null
const hit = options.find((item) => Number(item.value) === Number(id))
return hit?.label || null
}
function getAssigneeLabel(task) {
const assigneeId = getTaskAssigneeId(task)
return assigneeOptions.value.find((option) => option.value === assigneeId)?.label || "-"
}
async function loadTasks() {
loading.value = true
try {
const rows = await useEntities("tasks").select()
tasks.value = rows.map((task) => ({ ...task, categorie: normalizeStatus(task.categorie) }))
} finally {
loading.value = false
}
}
async function loadTaskOptions() {
const [projectRows, customerRows, plantRows] = await Promise.all([
useEntities("projects").select(),
useEntities("customers").select(),
useEntities("plants").select()
])
projects.value = projectRows || []
customers.value = customerRows || []
plants.value = plantRows || []
}
function openCreateTask(initialData = {}) {
if (!canCreate.value) return
modalMode.value = "create"
taskForm.value = {
...getEmptyTask(),
...initialData,
categorie: normalizeStatus(initialData.categorie || "Offen"),
userId: initialData.userId || initialData.user_id || currentUserId.value || null
}
isModalOpen.value = true
}
function openTask(task, mode = "show") {
modalMode.value = mode
taskForm.value = mapTaskToForm(task)
isModalOpen.value = true
}
function closeModal() {
isModalOpen.value = false
modalMode.value = "show"
taskForm.value = getEmptyTask()
const query = { ...route.query }
delete query.mode
delete query.id
router.replace({ path: "/tasks", query })
}
async function saveTask() {
if (!canCreate.value) return
const name = taskForm.value.name?.trim()
if (!name) {
toast.add({ title: "Name fehlt", description: "Bitte einen Aufgabennamen angeben.", color: "orange" })
return
}
const payload = {
name,
description: taskForm.value.description || null,
categorie: normalizeStatus(taskForm.value.categorie),
userId: taskForm.value.userId || null,
project: toNullableNumber(taskForm.value.project),
customer: toNullableNumber(taskForm.value.customer),
plant: toNullableNumber(taskForm.value.plant)
}
saving.value = true
try {
let targetId = taskForm.value.id
if (taskForm.value.id) {
await useEntities("tasks").update(taskForm.value.id, payload, true)
} else {
const created = await useEntities("tasks").create(payload, true)
targetId = created?.id || null
}
await loadTasks()
if (targetId) {
const query = { ...route.query, mode: "show", id: String(targetId) }
router.replace({ path: "/tasks", query })
const target = tasks.value.find((task) => String(task.id) === String(targetId))
if (target) openTask(target, "show")
return
}
closeModal()
} finally {
saving.value = false
}
}
async function archiveTask() {
if (!canCreate.value || !taskForm.value.id) return
deleting.value = true
try {
await useEntities("tasks").update(taskForm.value.id, { archived: true }, true)
await loadTasks()
closeModal()
} finally {
deleting.value = false
}
}
async function completeTaskQuick(task) {
if (!canCreate.value) return
if (!task?.id || normalizeStatus(task.categorie) === "Abgeschlossen") return
const previousStatus = task.categorie
quickCompleteLoadingId.value = task.id
task.categorie = "Abgeschlossen"
try {
await useEntities("tasks").update(task.id, { categorie: "Abgeschlossen" }, true)
toast.add({ title: "Aufgabe abgeschlossen", color: "green" })
} catch (error) {
task.categorie = previousStatus
toast.add({ title: "Aufgabe konnte nicht abgeschlossen werden", color: "red" })
} finally {
quickCompleteLoadingId.value = null
}
}
function onDragStart(task) {
draggingTaskId.value = task.id
}
async function onDrop(status) {
if (!canCreate.value || !draggingTaskId.value) return
const droppedTask = tasks.value.find((task) => String(task.id) === String(draggingTaskId.value))
draggingTaskId.value = null
droppingOn.value = ""
if (!droppedTask || normalizeStatus(droppedTask.categorie) === status) return
const oldStatus = droppedTask.categorie
droppedTask.categorie = status
try {
await useEntities("tasks").update(droppedTask.id, { categorie: status }, true)
} catch (error) {
droppedTask.categorie = oldStatus
toast.add({ title: "Status konnte nicht geändert werden", color: "red" })
}
}
async function handleRouteIntent() {
const mode = typeof route.query.mode === "string" ? route.query.mode : null
const id = typeof route.query.id === "string" ? route.query.id : null
if (!mode) {
if (isModalOpen.value) closeModal()
return
}
if (mode === "create") {
openCreateTask({
project: toNullableNumber(route.query.project),
customer: toNullableNumber(route.query.customer),
plant: toNullableNumber(route.query.plant),
userId: route.query.userId || route.query.user_id || currentUserId.value || null
})
return
}
if (!id) return
let task = tasks.value.find((item) => String(item.id) === id)
if (!task) {
const loadedTask = await useEntities("tasks").selectSingle(id, "*", true)
if (loadedTask) {
task = { ...loadedTask, categorie: normalizeStatus(loadedTask.categorie) }
const idx = tasks.value.findIndex((item) => String(item.id) === id)
if (idx === -1) tasks.value.push(task)
}
}
if (!task) return
openTask(task, mode === "edit" ? "edit" : "show")
}
function openCreateViaRoute() {
router.push({ path: "/tasks", query: { ...route.query, mode: "create" } })
}
function openTaskViaRoute(task) {
router.push({ path: "/tasks", query: { ...route.query, mode: "show", id: String(task.id) } })
}
watch(
() => route.query,
async () => {
await handleRouteIntent()
},
{ deep: true }
)
onMounted(async () => {
setPageLayout("default")
await Promise.all([loadTasks(), loadTaskOptions()])
await handleRouteIntent()
})
</script>
<template>
<UDashboardNavbar title="Aufgaben" :badge="filteredTasks.length">
<template #right>
<div class="flex items-center gap-2">
<UInput
v-model="search"
icon="i-heroicons-magnifying-glass"
placeholder="Aufgaben durchsuchen..."
class="w-72"
/>
<UButton
icon="i-heroicons-arrow-path"
variant="soft"
:loading="loading"
@click="loadTasks"
/>
<UButton
v-if="canCreate"
icon="i-heroicons-plus"
@click="openCreateViaRoute"
>
Aufgabe
</UButton>
</div>
</template>
</UDashboardNavbar>
<UDashboardToolbar>
<template #left>
<UCheckbox
v-if="canViewAll"
v-model="showOnlyMine"
label="Nur meine Aufgaben"
/>
<span v-else class="text-sm">Ansicht: Nur eigene Aufgaben</span>
</template>
<template #right>
<div class="view-toggle">
<UButton
size="xs"
variant="ghost"
icon="i-heroicons-view-columns"
:variant="viewMode === 'kanban' ? 'solid' : 'ghost'"
@click="viewMode = 'kanban'"
>
Kanban
</UButton>
<UButton
size="xs"
variant="ghost"
icon="i-heroicons-list-bullet"
:variant="viewMode === 'list' ? 'solid' : 'ghost'"
@click="viewMode = 'list'"
>
Liste
</UButton>
</div>
</template>
</UDashboardToolbar>
<UDashboardPanelContent>
<div v-if="viewMode === 'kanban'" class="kanban-grid">
<section
v-for="status in STATUS_COLUMNS"
:key="status"
class="kanban-column"
@dragover.prevent="droppingOn = status"
@dragleave="droppingOn = ''"
@drop.prevent="onDrop(status)"
>
<header class="kanban-column-header">
<h3>{{ status }}</h3>
<UBadge variant="subtle">{{ groupedTasks[status]?.length || 0 }}</UBadge>
</header>
<div :class="['kanban-dropzone', droppingOn === status ? 'kanban-dropzone-active' : '']">
<article
v-for="task in groupedTasks[status]"
:key="task.id"
class="kanban-card"
draggable="true"
@dragstart="onDragStart(task)"
@click="openTaskViaRoute(task)"
>
<div class="kanban-card-title">{{ task.name }}</div>
<p v-if="task.description" class="kanban-card-description">{{ task.description }}</p>
<div class="kanban-card-meta">
<UBadge v-if="getEntityLabel(projectOptions, task.project?.id || task.project)" variant="soft">
{{ getEntityLabel(projectOptions, task.project?.id || task.project) }}
</UBadge>
<UBadge v-if="getEntityLabel(customerOptions, task.customer?.id || task.customer)" variant="soft">
{{ getEntityLabel(customerOptions, task.customer?.id || task.customer) }}
</UBadge>
<UBadge v-if="getEntityLabel(plantOptions, task.plant?.id || task.plant)" variant="soft">
{{ getEntityLabel(plantOptions, task.plant?.id || task.plant) }}
</UBadge>
</div>
</article>
<div v-if="!groupedTasks[status]?.length" class="kanban-empty">
Keine Aufgaben
</div>
</div>
</section>
</div>
<UTable
v-else-if="filteredTasks.length"
:rows="filteredTasks"
:columns="listColumns"
@select="(task) => openTaskViaRoute(task)"
>
<template #actions-data="{ row }">
<UButton
v-if="normalizeStatus(row.categorie) !== 'Abgeschlossen' && canCreate"
size="xs"
variant="soft"
icon="i-heroicons-check"
:loading="quickCompleteLoadingId === row.id"
@click.stop="completeTaskQuick(row)"
>
Erledigt
</UButton>
</template>
<template #categorie-data="{ row }">
<UBadge variant="soft">{{ normalizeStatus(row.categorie) }}</UBadge>
</template>
<template #assignee-data="{ row }">
{{ getAssigneeLabel(row) }}
</template>
<template #project-data="{ row }">
{{ getEntityLabel(projectOptions, row.project?.id || row.project) || "-" }}
</template>
<template #customer-data="{ row }">
{{ getEntityLabel(customerOptions, row.customer?.id || row.customer) || "-" }}
</template>
<template #plant-data="{ row }">
{{ getEntityLabel(plantOptions, row.plant?.id || row.plant) || "-" }}
</template>
</UTable>
<UAlert
v-else
icon="i-heroicons-circle-stack-20-solid"
title="Keine Aufgaben anzuzeigen"
variant="subtle"
/>
</UDashboardPanelContent>
<UModal v-model="isModalOpen" :prevent-close="saving || deleting">
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h3 class="font-semibold">{{ modalTitle }}</h3>
<UBadge variant="subtle">{{ taskForm.id ? `#${taskForm.id}` : "Neu" }}</UBadge>
</div>
</template>
<div class="space-y-3">
<div>
<label class="form-label">Titel</label>
<UInput v-model="taskForm.name" :disabled="isFormReadonly || !canCreate" />
</div>
<div>
<label class="form-label">Beschreibung</label>
<UTextarea v-model="taskForm.description" :disabled="isFormReadonly || !canCreate" />
</div>
<USelectMenu
v-model="taskForm.categorie"
:options="STATUS_COLUMNS.map((status) => ({ label: status, value: status }))"
value-attribute="value"
option-attribute="label"
:disabled="isFormReadonly || !canCreate"
>
<template #label>
{{ taskForm.categorie || "Status auswählen" }}
</template>
</USelectMenu>
<USelectMenu
v-model="taskForm.userId"
:options="assigneeOptions"
value-attribute="value"
option-attribute="label"
:disabled="isFormReadonly || !canCreate"
searchable
>
<template #label>
{{ assigneeOptions.find((option) => option.value === taskForm.userId)?.label || "Zuweisung" }}
</template>
</USelectMenu>
<USelectMenu
v-model="taskForm.project"
:options="projectOptions"
value-attribute="value"
option-attribute="label"
:disabled="isFormReadonly || !canCreate"
searchable
clear-search-on-close
>
<template #label>
{{ getEntityLabel(projectOptions, taskForm.project) || "Projekt" }}
</template>
</USelectMenu>
<USelectMenu
v-model="taskForm.customer"
:options="customerOptions"
value-attribute="value"
option-attribute="label"
:disabled="isFormReadonly || !canCreate"
searchable
clear-search-on-close
>
<template #label>
{{ getEntityLabel(customerOptions, taskForm.customer) || "Kunde" }}
</template>
</USelectMenu>
<USelectMenu
v-model="taskForm.plant"
:options="plantOptions"
value-attribute="value"
option-attribute="label"
:disabled="isFormReadonly || !canCreate"
searchable
clear-search-on-close
>
<template #label>
{{ getEntityLabel(plantOptions, taskForm.plant) || "Objekt" }}
</template>
</USelectMenu>
</div>
<template #footer>
<div class="flex items-center justify-between gap-2">
<div class="flex gap-2">
<UButton
v-if="taskForm.id && canCreate"
variant="soft"
:loading="deleting"
@click="archiveTask"
>
Archivieren
</UButton>
</div>
<div class="flex gap-2">
<UButton variant="ghost" @click="closeModal">Schließen</UButton>
<UButton
v-if="modalMode === 'show' && canCreate"
variant="soft"
@click="modalMode = 'edit'"
>
Bearbeiten
</UButton>
<UButton
v-if="modalMode !== 'show' && canCreate"
:loading="saving"
@click="saveTask"
>
Speichern
</UButton>
</div>
</div>
</template>
</UCard>
</UModal>
</template>
<style scoped>
.kanban-grid {
display: grid;
grid-template-columns: 1fr;
gap: 1.25rem;
align-items: start;
}
@media (min-width: 1024px) {
.kanban-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.kanban-column {
border: 1px solid var(--ui-border);
border-radius: 0.75rem;
background: var(--ui-bg);
min-height: 500px;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
box-shadow: 0 1px 2px color-mix(in oklab, var(--ui-text) 10%, transparent);
}
@media (min-width: 1024px) {
.kanban-column:not(:first-child)::before {
content: "";
position: absolute;
left: -0.7rem;
top: 0.75rem;
bottom: 0.75rem;
width: 1px;
background: var(--ui-border);
opacity: 0.9;
pointer-events: none;
}
}
.kanban-column-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.85rem 1rem;
border-bottom: 1px solid var(--ui-border);
background: var(--ui-bg-muted);
}
.kanban-dropzone {
padding: 0.9rem;
flex: 1;
display: flex;
flex-direction: column;
gap: 0.85rem;
transition: background-color 0.2s ease;
}
.kanban-dropzone-active {
background: var(--ui-bg-muted);
}
.kanban-card {
border: 1px solid var(--ui-border);
background: var(--ui-bg-elevated);
border-radius: 0.6rem;
padding: 0.7rem;
cursor: pointer;
}
.kanban-card-title {
font-weight: 600;
line-height: 1.25rem;
}
.kanban-card-description {
margin-top: 0.4rem;
color: var(--ui-text-dimmed);
font-size: 0.875rem;
line-height: 1.15rem;
}
.kanban-card-meta {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.6rem;
}
.kanban-empty {
border: 1px dashed var(--ui-border);
color: var(--ui-text-dimmed);
border-radius: 0.6rem;
padding: 0.7rem;
font-size: 0.875rem;
}
.form-label {
display: block;
font-size: 0.875rem;
font-weight: 600;
margin-bottom: 0.35rem;
color: var(--ui-text);
}
.view-toggle {
display: flex;
align-items: center;
gap: 0.25rem;
border: 1px solid var(--ui-border);
border-radius: 0.5rem;
padding: 0.25rem;
}
</style>