1589 lines
44 KiB
Vue
1589 lines
44 KiB
Vue
<script setup>
|
|
import { setPageLayout } from "#app"
|
|
import { Bar } from "vue-chartjs"
|
|
|
|
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("list")
|
|
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 expandedTaskIds = ref([])
|
|
|
|
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 taskMap = computed(() => {
|
|
return Object.fromEntries(tasks.value.map((task) => [String(task.id), task]))
|
|
})
|
|
|
|
const taskSuccessorMap = computed(() => {
|
|
const map = {}
|
|
|
|
tasks.value.forEach((task) => {
|
|
map[String(task.id)] = []
|
|
})
|
|
|
|
tasks.value.forEach((task) => {
|
|
getDependencyIds(task).forEach((dependencyId) => {
|
|
const key = String(dependencyId)
|
|
if (!map[key]) map[key] = []
|
|
map[key].push(task.id)
|
|
})
|
|
})
|
|
|
|
return map
|
|
})
|
|
|
|
const dependencyOptions = computed(() => {
|
|
const blockedIds = taskForm.value.id ? getDescendantIds(taskForm.value.id) : new Set()
|
|
|
|
return tasks.value
|
|
.filter((task) => String(task.id) !== String(taskForm.value.id) && !blockedIds.has(String(task.id)))
|
|
.map((task) => ({ value: task.id, label: task.name || `#${task.id}` }))
|
|
.sort((a, b) => a.label.localeCompare(b.label, "de"))
|
|
})
|
|
|
|
const waterfallColumns = computed(() => buildWaterfallColumns(filteredTasks.value))
|
|
const waterfallHasCycle = computed(() => waterfallColumns.value.some((column) => column.kind === "cycle"))
|
|
const waterfallRows = computed(() => buildWaterfallRows(filteredTasks.value, waterfallColumns.value))
|
|
const listRootTasks = computed(() => {
|
|
const childIds = new Set()
|
|
|
|
filteredTasks.value.forEach((task) => {
|
|
getDependencyIds(task).forEach((dependencyId) => {
|
|
childIds.add(String(dependencyId))
|
|
})
|
|
})
|
|
|
|
return filteredTasks.value.filter((task) => !childIds.has(String(task.id)))
|
|
})
|
|
const waterfallMaxPosition = computed(() => {
|
|
return waterfallRows.value.reduce((max, row) => Math.max(max, row.end), 1)
|
|
})
|
|
const waterfallChartHeight = computed(() => Math.max(320, waterfallRows.value.length * 54 + 80))
|
|
const waterfallChartData = computed(() => ({
|
|
datasets: [
|
|
{
|
|
label: "Ablauf",
|
|
data: waterfallRows.value.map((row) => ({
|
|
x: [row.start, row.end],
|
|
y: row.label,
|
|
taskId: row.task.id,
|
|
taskName: row.task.name,
|
|
dependencies: getDependencySummary(row.task),
|
|
status: normalizeStatus(row.task.categorie),
|
|
stateLabel: getTaskDependencyStateLabel(row.task),
|
|
kind: row.kind
|
|
})),
|
|
borderRadius: 8,
|
|
borderSkipped: false,
|
|
borderWidth: 1.5,
|
|
borderColor(context) {
|
|
const raw = context.raw || {}
|
|
if (raw.kind === "cycle") return "#c2410c"
|
|
return getTaskDependencyState(taskMap.value[String(raw.taskId)]) === "done" ? "#15803d" : "#1d4ed8"
|
|
},
|
|
backgroundColor(context) {
|
|
const raw = context.raw || {}
|
|
if (raw.kind === "cycle") return "rgba(249, 115, 22, 0.85)"
|
|
const state = getTaskDependencyState(taskMap.value[String(raw.taskId)])
|
|
if (state === "done") return "rgba(34, 197, 94, 0.85)"
|
|
if (state === "blocked") return "rgba(245, 158, 11, 0.85)"
|
|
return "rgba(59, 130, 246, 0.82)"
|
|
},
|
|
barThickness: 22
|
|
}
|
|
]
|
|
}))
|
|
const waterfallChartOptions = computed(() => ({
|
|
indexAxis: "y",
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
animation: false,
|
|
parsing: true,
|
|
plugins: {
|
|
legend: {
|
|
display: false
|
|
},
|
|
tooltip: {
|
|
callbacks: {
|
|
title(items) {
|
|
return items[0]?.raw?.taskName || items[0]?.label || ""
|
|
},
|
|
label(item) {
|
|
const raw = item.raw || {}
|
|
return `Position: ${formatDependencyRange(raw.x)}`
|
|
},
|
|
afterLabel(item) {
|
|
const raw = item.raw || {}
|
|
return [
|
|
`Status: ${raw.status || "-"}`,
|
|
`Freigabe: ${raw.stateLabel || "-"}`,
|
|
`Unteraufgaben: ${raw.dependencies || "-"}`,
|
|
`Oberaufgaben: ${getDependentSummary(taskMap.value[String(raw.taskId)])}`
|
|
]
|
|
}
|
|
}
|
|
}
|
|
},
|
|
scales: {
|
|
x: {
|
|
min: 0,
|
|
max: waterfallMaxPosition.value,
|
|
grid: {
|
|
color: "rgba(148, 163, 184, 0.16)"
|
|
},
|
|
border: {
|
|
display: false
|
|
},
|
|
ticks: {
|
|
stepSize: 1,
|
|
display: false
|
|
},
|
|
title: {
|
|
display: true,
|
|
text: "Abhaengigkeitsverlauf"
|
|
}
|
|
},
|
|
y: {
|
|
grid: {
|
|
display: false
|
|
},
|
|
border: {
|
|
display: false
|
|
},
|
|
ticks: {
|
|
color: "#475569"
|
|
}
|
|
}
|
|
},
|
|
onClick: (_event, elements, chart) => {
|
|
const first = elements?.[0]
|
|
if (!first) return
|
|
const raw = chart.data.datasets[first.datasetIndex]?.data?.[first.index]
|
|
if (raw?.taskId) {
|
|
const task = taskMap.value[String(raw.taskId)]
|
|
if (task) openTaskViaRoute(task)
|
|
}
|
|
}
|
|
}))
|
|
|
|
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: "dependencies", label: "Unteraufgaben" },
|
|
{ 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",
|
|
dependencyIds: [],
|
|
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 normalizeDependencyIds(value, taskId = null) {
|
|
const ownId = taskId === null || taskId === undefined ? null : String(taskId)
|
|
|
|
return [...new Set((Array.isArray(value) ? value : [])
|
|
.map((entry) => Number(entry))
|
|
.filter((entry) => Number.isFinite(entry))
|
|
.filter((entry) => String(entry) !== ownId))]
|
|
}
|
|
|
|
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),
|
|
dependencyIds: getDependencyIds(task),
|
|
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 normalizeTask(task) {
|
|
return {
|
|
...task,
|
|
categorie: normalizeStatus(task.categorie),
|
|
dependencyIds: getDependencyIds(task)
|
|
}
|
|
}
|
|
|
|
function getDependencyIds(task) {
|
|
return normalizeDependencyIds(task?.dependencyIds ?? task?.dependency_ids ?? [], task?.id)
|
|
}
|
|
|
|
function getTaskLabel(taskId) {
|
|
const task = taskMap.value[String(taskId)]
|
|
return task?.name || `Aufgabe #${taskId}`
|
|
}
|
|
|
|
function getDescendantIds(taskId) {
|
|
const seen = new Set()
|
|
const stack = [...(taskSuccessorMap.value[String(taskId)] || [])]
|
|
|
|
while (stack.length) {
|
|
const current = stack.pop()
|
|
const key = String(current)
|
|
if (seen.has(key)) continue
|
|
seen.add(key)
|
|
stack.push(...(taskSuccessorMap.value[key] || []))
|
|
}
|
|
|
|
return seen
|
|
}
|
|
|
|
function wouldCreateDependencyCycle(taskId, dependencyIds) {
|
|
if (!taskId) return false
|
|
|
|
const targetKey = String(taskId)
|
|
const dependencyKeys = normalizeDependencyIds(dependencyIds, taskId).map((id) => String(id))
|
|
|
|
const hasPathToTask = (startKey) => {
|
|
const visited = new Set()
|
|
const stack = [startKey]
|
|
|
|
while (stack.length) {
|
|
const currentKey = stack.pop()
|
|
if (currentKey === targetKey) return true
|
|
if (visited.has(currentKey)) continue
|
|
visited.add(currentKey)
|
|
const currentTask = taskMap.value[currentKey]
|
|
if (!currentTask) continue
|
|
getDependencyIds(currentTask).forEach((nextId) => {
|
|
stack.push(String(nextId))
|
|
})
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
return dependencyKeys.some((dependencyKey) => hasPathToTask(dependencyKey))
|
|
}
|
|
|
|
function getTaskDependencyState(task) {
|
|
const dependencies = getDependencyIds(task)
|
|
if (normalizeStatus(task.categorie) === "Abgeschlossen") return "done"
|
|
if (!dependencies.length) return "ready"
|
|
|
|
const openDependencies = dependencies.filter((dependencyId) => {
|
|
const dependencyTask = taskMap.value[String(dependencyId)]
|
|
return dependencyTask && normalizeStatus(dependencyTask.categorie) !== "Abgeschlossen"
|
|
})
|
|
|
|
return openDependencies.length ? "blocked" : "ready"
|
|
}
|
|
|
|
function getTaskDependencyStateLabel(task) {
|
|
const state = getTaskDependencyState(task)
|
|
if (state === "done") return "Abgeschlossen"
|
|
if (state === "blocked") return "Blockiert"
|
|
return "Bereit"
|
|
}
|
|
|
|
function getDependencySummary(task) {
|
|
const dependencies = getDependencyIds(task)
|
|
if (!dependencies.length) return "-"
|
|
return dependencies.map((dependencyId) => getTaskLabel(dependencyId)).join(", ")
|
|
}
|
|
|
|
function getDependencyPreview(task, limit = 2) {
|
|
const dependencies = getDependencyIds(task)
|
|
if (!dependencies.length) return []
|
|
return dependencies.slice(0, limit).map((dependencyId) => getTaskLabel(dependencyId))
|
|
}
|
|
|
|
function getRemainingDependencyCount(task, shownCount = 2) {
|
|
const dependencies = getDependencyIds(task)
|
|
return Math.max(0, dependencies.length - shownCount)
|
|
}
|
|
|
|
function getDependencyTasks(task, limit = null) {
|
|
const dependencies = getDependencyIds(task)
|
|
.map((dependencyId) => taskMap.value[String(dependencyId)])
|
|
.filter(Boolean)
|
|
|
|
return limit === null ? dependencies : dependencies.slice(0, limit)
|
|
}
|
|
|
|
function getSubtaskProgress(task) {
|
|
const subtasks = getDependencyTasks(task)
|
|
const total = subtasks.length
|
|
const done = subtasks.filter((subtask) => normalizeStatus(subtask.categorie) === "Abgeschlossen").length
|
|
return { done, total }
|
|
}
|
|
|
|
function hasSubtasks(task) {
|
|
return getDependencyIds(task).length > 0
|
|
}
|
|
|
|
function hasOpenSubtasks(task) {
|
|
const { done, total } = getSubtaskProgress(task)
|
|
return total > 0 && done < total
|
|
}
|
|
|
|
function isExpanded(taskId) {
|
|
return expandedTaskIds.value.includes(String(taskId))
|
|
}
|
|
|
|
function toggleExpanded(taskId) {
|
|
const key = String(taskId)
|
|
if (isExpanded(key)) {
|
|
expandedTaskIds.value = expandedTaskIds.value.filter((id) => id !== key)
|
|
return
|
|
}
|
|
expandedTaskIds.value = [...expandedTaskIds.value, key]
|
|
}
|
|
|
|
function buildWaterfallColumns(taskList) {
|
|
const filteredIds = new Set(taskList.map((task) => String(task.id)))
|
|
const remaining = new Map(
|
|
taskList.map((task) => [
|
|
String(task.id),
|
|
{
|
|
...task,
|
|
internalDependencies: getDependencyIds(task).filter((dependencyId) => filteredIds.has(String(dependencyId)))
|
|
}
|
|
])
|
|
)
|
|
const columns = []
|
|
let level = 1
|
|
|
|
while (remaining.size) {
|
|
const ready = [...remaining.values()]
|
|
.filter((task) => task.internalDependencies.every((dependencyId) => !remaining.has(String(dependencyId))))
|
|
.sort((a, b) => a.name.localeCompare(b.name, "de"))
|
|
|
|
if (!ready.length) {
|
|
columns.push({
|
|
id: `cycle-${level}`,
|
|
kind: "cycle",
|
|
title: "Zyklische Abhängigkeit",
|
|
tasks: [...remaining.values()].sort((a, b) => a.name.localeCompare(b.name, "de"))
|
|
})
|
|
break
|
|
}
|
|
|
|
columns.push({
|
|
id: `level-${level}`,
|
|
kind: "level",
|
|
title: `Schritt ${level}`,
|
|
tasks: ready
|
|
})
|
|
|
|
ready.forEach((task) => {
|
|
remaining.delete(String(task.id))
|
|
})
|
|
|
|
level += 1
|
|
}
|
|
|
|
return columns
|
|
}
|
|
|
|
function buildWaterfallRows(taskList, columns) {
|
|
const filteredIds = new Set(taskList.map((task) => String(task.id)))
|
|
const columnTaskMeta = new Map()
|
|
|
|
columns.forEach((column, index) => {
|
|
column.tasks.forEach((task) => {
|
|
columnTaskMeta.set(String(task.id), {
|
|
columnId: column.id,
|
|
start: index,
|
|
end: index + 1,
|
|
kind: column.kind
|
|
})
|
|
})
|
|
})
|
|
|
|
const getInternalDependencyIds = (task) =>
|
|
getDependencyIds(task).filter((dependencyId) => filteredIds.has(String(dependencyId)))
|
|
|
|
const getTaskDepth = (taskId) => {
|
|
const meta = columnTaskMeta.get(String(taskId))
|
|
return meta?.end || 0
|
|
}
|
|
|
|
const rows = []
|
|
const childrenByParent = new Map()
|
|
const taskById = Object.fromEntries(taskList.map((task) => [String(task.id), task]))
|
|
const allChildIds = new Set()
|
|
|
|
taskList.forEach((task) => {
|
|
const meta = columnTaskMeta.get(String(task.id))
|
|
if (!meta || meta.kind === "cycle") return
|
|
|
|
const internalDependencies = getInternalDependencyIds(task)
|
|
if (!internalDependencies.length) return
|
|
|
|
childrenByParent.set(
|
|
String(task.id),
|
|
internalDependencies
|
|
.map((dependencyId) => taskById[String(dependencyId)])
|
|
.filter(Boolean)
|
|
)
|
|
|
|
internalDependencies.forEach((dependencyId) => {
|
|
allChildIds.add(String(dependencyId))
|
|
})
|
|
})
|
|
|
|
childrenByParent.forEach((children) => {
|
|
children.sort((a, b) => {
|
|
const depthDiff = getTaskDepth(a.id) - getTaskDepth(b.id)
|
|
if (depthDiff !== 0) return depthDiff
|
|
return (a.name || "").localeCompare(b.name || "", "de")
|
|
})
|
|
})
|
|
|
|
const appendTaskAndChildren = (task) => {
|
|
const meta = columnTaskMeta.get(String(task.id))
|
|
if (!meta) return
|
|
|
|
rows.push({
|
|
id: `${meta.columnId}-${task.id}`,
|
|
task,
|
|
label: task.name || `Aufgabe #${task.id}`,
|
|
start: meta.start,
|
|
end: meta.end,
|
|
kind: meta.kind
|
|
})
|
|
|
|
;(childrenByParent.get(String(task.id)) || []).forEach((child) => {
|
|
appendTaskAndChildren(child)
|
|
})
|
|
}
|
|
|
|
const rootTasks = taskList
|
|
.filter((task) => {
|
|
const meta = columnTaskMeta.get(String(task.id))
|
|
return meta && meta.kind !== "cycle" && !allChildIds.has(String(task.id))
|
|
})
|
|
.sort((a, b) => {
|
|
const depthDiff = getTaskDepth(a.id) - getTaskDepth(b.id)
|
|
if (depthDiff !== 0) return depthDiff
|
|
return (a.name || "").localeCompare(b.name || "", "de")
|
|
})
|
|
|
|
rootTasks.forEach((task) => {
|
|
appendTaskAndChildren(task)
|
|
})
|
|
|
|
taskList
|
|
.filter((task) => columnTaskMeta.get(String(task.id))?.kind === "cycle")
|
|
.sort((a, b) => (a.name || "").localeCompare(b.name || "", "de"))
|
|
.forEach((task) => {
|
|
if (rows.some((row) => String(row.task.id) === String(task.id))) return
|
|
const meta = columnTaskMeta.get(String(task.id))
|
|
rows.push({
|
|
id: `${meta.columnId}-${task.id}`,
|
|
task,
|
|
label: task.name || `Aufgabe #${task.id}`,
|
|
start: meta.start,
|
|
end: meta.end,
|
|
kind: meta.kind
|
|
})
|
|
})
|
|
|
|
Object.keys(taskById).forEach((taskId) => {
|
|
if (rows.some((row) => String(row.task.id) === taskId)) return
|
|
const task = taskById[taskId]
|
|
const meta = columnTaskMeta.get(taskId)
|
|
if (!task || !meta) return
|
|
rows.push({
|
|
id: `${meta.columnId}-${task.id}`,
|
|
task,
|
|
label: task.name || `Aufgabe #${task.id}`,
|
|
start: meta.start,
|
|
end: meta.end,
|
|
kind: meta.kind
|
|
})
|
|
})
|
|
|
|
return rows
|
|
}
|
|
|
|
function formatDependencyRange(range) {
|
|
if (!Array.isArray(range) || range.length < 2) return "-"
|
|
const start = Number(range[0]) + 1
|
|
const end = Number(range[1])
|
|
if (!Number.isFinite(start) || !Number.isFinite(end)) return "-"
|
|
return start === end ? `${end}` : `${start} bis ${end}`
|
|
}
|
|
|
|
function getDependentSummary(task) {
|
|
if (!task?.id) return "-"
|
|
const dependentIds = taskSuccessorMap.value[String(task.id)] || []
|
|
if (!dependentIds.length) return "-"
|
|
return dependentIds.map((taskId) => getTaskLabel(taskId)).join(", ")
|
|
}
|
|
|
|
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) => normalizeTask(task))
|
|
} 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"),
|
|
dependencyIds: normalizeDependencyIds(initialData.dependencyIds || initialData.dependency_ids || []),
|
|
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),
|
|
dependencyIds: normalizeDependencyIds(taskForm.value.dependencyIds, taskForm.value.id),
|
|
userId: taskForm.value.userId || null,
|
|
project: toNullableNumber(taskForm.value.project),
|
|
customer: toNullableNumber(taskForm.value.customer),
|
|
plant: toNullableNumber(taskForm.value.plant)
|
|
}
|
|
|
|
if (wouldCreateDependencyCycle(taskForm.value.id, payload.dependencyIds)) {
|
|
toast.add({
|
|
title: "Abhängigkeit nicht möglich",
|
|
description: "Diese Auswahl würde einen Zyklus in den Aufgaben-Abhängigkeiten erzeugen.",
|
|
color: "red"
|
|
})
|
|
return
|
|
}
|
|
|
|
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 setTaskCompleted(task, completed) {
|
|
if (!canCreate.value) return
|
|
if (!task?.id) return
|
|
if (completed && hasOpenSubtasks(task)) return
|
|
|
|
const nextStatus = completed ? "Abgeschlossen" : "Offen"
|
|
if (normalizeStatus(task.categorie) === nextStatus) return
|
|
|
|
const previousStatus = task.categorie
|
|
quickCompleteLoadingId.value = task.id
|
|
task.categorie = nextStatus
|
|
|
|
try {
|
|
await useEntities("tasks").update(task.id, { categorie: nextStatus }, true)
|
|
toast.add({
|
|
title: completed ? "Aufgabe abgeschlossen" : "Aufgabe wieder geöffnet",
|
|
color: completed ? "green" : "blue"
|
|
})
|
|
} catch (error) {
|
|
task.categorie = previousStatus
|
|
toast.add({ title: "Aufgabe konnte nicht aktualisiert 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 = normalizeTask(loadedTask)
|
|
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">
|
|
<template v-for="(status, index) in STATUS_COLUMNS" :key="status">
|
|
<section
|
|
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>
|
|
|
|
<div v-if="getDependencyIds(task).length" class="kanban-card-dependencies">
|
|
<span class="kanban-card-dependencies-label">Unteraufgaben</span>
|
|
<div class="kanban-card-subtasks">
|
|
<button
|
|
v-for="dependencyTask in getDependencyTasks(task, 3)"
|
|
:key="dependencyTask.id"
|
|
type="button"
|
|
class="kanban-subtask-card"
|
|
@click.stop="openTaskViaRoute(dependencyTask)"
|
|
>
|
|
<div class="kanban-subtask-card-head">
|
|
<span class="kanban-subtask-card-title">{{ dependencyTask.name }}</span>
|
|
<UBadge size="xs" variant="soft">{{ normalizeStatus(dependencyTask.categorie) }}</UBadge>
|
|
</div>
|
|
<p v-if="dependencyTask.description" class="kanban-subtask-card-description">
|
|
{{ dependencyTask.description }}
|
|
</p>
|
|
</button>
|
|
<div
|
|
v-if="getRemainingDependencyCount(task, 3) > 0"
|
|
class="kanban-subtask-more"
|
|
>
|
|
+{{ getRemainingDependencyCount(task, 3) }} weitere Unteraufgaben
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
|
|
<div v-if="!groupedTasks[status]?.length" class="kanban-empty">
|
|
Keine Aufgaben
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<div
|
|
v-if="index < STATUS_COLUMNS.length - 1"
|
|
class="kanban-divider"
|
|
aria-hidden="true"
|
|
/>
|
|
</template>
|
|
</div>
|
|
<div v-else-if="viewMode === 'list' && listRootTasks.length" class="task-list">
|
|
<article
|
|
v-for="task in listRootTasks"
|
|
:key="task.id"
|
|
class="task-list-item"
|
|
>
|
|
<div class="task-list-row">
|
|
<button
|
|
v-if="hasSubtasks(task)"
|
|
type="button"
|
|
class="task-list-toggle"
|
|
@click="toggleExpanded(task.id)"
|
|
>
|
|
<UIcon
|
|
:name="isExpanded(task.id) ? 'i-heroicons-chevron-down' : 'i-heroicons-chevron-right'"
|
|
class="h-4 w-4"
|
|
/>
|
|
</button>
|
|
<div v-else class="task-list-toggle-placeholder" />
|
|
|
|
<button
|
|
type="button"
|
|
class="task-list-main"
|
|
@click="openTaskViaRoute(task)"
|
|
>
|
|
<div class="task-list-main-top">
|
|
<div>
|
|
<div class="task-list-title">{{ task.name }}</div>
|
|
<p v-if="task.description" class="task-list-description">{{ task.description }}</p>
|
|
</div>
|
|
<div class="task-list-badges">
|
|
<UBadge variant="soft">{{ normalizeStatus(task.categorie) }}</UBadge>
|
|
<UBadge v-if="hasSubtasks(task)" variant="outline">
|
|
{{ getSubtaskProgress(task).done }} von {{ getSubtaskProgress(task).total }} abgehakt
|
|
</UBadge>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="task-list-meta">
|
|
<span>{{ getAssigneeLabel(task) }}</span>
|
|
<span v-if="getEntityLabel(projectOptions, task.project?.id || task.project)">
|
|
{{ getEntityLabel(projectOptions, task.project?.id || task.project) }}
|
|
</span>
|
|
<span v-if="getEntityLabel(customerOptions, task.customer?.id || task.customer)">
|
|
{{ getEntityLabel(customerOptions, task.customer?.id || task.customer) }}
|
|
</span>
|
|
<span v-if="getEntityLabel(plantOptions, task.plant?.id || task.plant)">
|
|
{{ getEntityLabel(plantOptions, task.plant?.id || task.plant) }}
|
|
</span>
|
|
</div>
|
|
</button>
|
|
|
|
<UCheckbox
|
|
v-if="canCreate"
|
|
:model-value="normalizeStatus(task.categorie) === 'Abgeschlossen'"
|
|
:disabled="quickCompleteLoadingId === task.id || hasOpenSubtasks(task)"
|
|
:title="hasOpenSubtasks(task) ? 'Erst alle Unteraufgaben erledigen' : undefined"
|
|
@update:model-value="(value) => setTaskCompleted(task, value)"
|
|
/>
|
|
</div>
|
|
|
|
<div v-if="hasSubtasks(task) && isExpanded(task.id)" class="task-list-children">
|
|
<div
|
|
v-for="subtask in getDependencyTasks(task)"
|
|
:key="subtask.id"
|
|
class="task-list-child"
|
|
>
|
|
<div class="task-list-child-row">
|
|
<UCheckbox
|
|
v-if="canCreate"
|
|
:model-value="normalizeStatus(subtask.categorie) === 'Abgeschlossen'"
|
|
:disabled="quickCompleteLoadingId === subtask.id"
|
|
@update:model-value="(value) => setTaskCompleted(subtask, value)"
|
|
/>
|
|
<button
|
|
type="button"
|
|
class="task-list-child-main"
|
|
@click="openTaskViaRoute(subtask)"
|
|
>
|
|
<div class="task-list-child-head">
|
|
<span class="task-list-child-title">{{ subtask.name }}</span>
|
|
<UBadge size="xs" variant="soft">{{ normalizeStatus(subtask.categorie) }}</UBadge>
|
|
</div>
|
|
<p v-if="subtask.description" class="task-list-child-description">{{ subtask.description }}</p>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
<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>
|
|
|
|
<div>
|
|
<label class="form-label">Unteraufgaben</label>
|
|
<USelectMenu
|
|
v-model="taskForm.dependencyIds"
|
|
:options="dependencyOptions"
|
|
value-attribute="value"
|
|
option-attribute="label"
|
|
:disabled="isFormReadonly || !canCreate"
|
|
multiple
|
|
searchable
|
|
clear-search-on-close
|
|
>
|
|
<template #label>
|
|
{{
|
|
taskForm.dependencyIds?.length
|
|
? `${taskForm.dependencyIds.length} Unteraufgabe${taskForm.dependencyIds.length === 1 ? "" : "n"}`
|
|
: "Keine Unteraufgaben"
|
|
}}
|
|
</template>
|
|
</USelectMenu>
|
|
</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: minmax(0, 1fr) 1px minmax(0, 1fr) 1px minmax(0, 1fr);
|
|
gap: 0.9rem;
|
|
}
|
|
}
|
|
|
|
.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);
|
|
}
|
|
|
|
.kanban-divider {
|
|
display: none;
|
|
}
|
|
|
|
@media (min-width: 1024px) {
|
|
.kanban-divider {
|
|
display: block;
|
|
align-self: stretch;
|
|
width: 1px;
|
|
border-radius: 999px;
|
|
background:
|
|
linear-gradient(
|
|
to bottom,
|
|
transparent,
|
|
color-mix(in oklab, var(--ui-border) 78%, var(--ui-bg-elevated)) 10%,
|
|
color-mix(in oklab, var(--ui-border) 78%, var(--ui-bg-elevated)) 90%,
|
|
transparent
|
|
);
|
|
opacity: 0.95;
|
|
}
|
|
}
|
|
|
|
.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-card-dependencies {
|
|
margin-top: 0.7rem;
|
|
padding-top: 0.65rem;
|
|
border-top: 1px dashed color-mix(in oklab, var(--ui-border) 82%, transparent);
|
|
}
|
|
|
|
.kanban-card-dependencies-label {
|
|
display: block;
|
|
margin-bottom: 0.35rem;
|
|
font-size: 0.72rem;
|
|
font-weight: 600;
|
|
letter-spacing: 0.01em;
|
|
text-transform: uppercase;
|
|
color: var(--ui-text-dimmed);
|
|
}
|
|
|
|
.kanban-card-dependency-list {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.35rem;
|
|
}
|
|
|
|
.kanban-card-subtasks {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.45rem;
|
|
}
|
|
|
|
.kanban-subtask-card {
|
|
width: 100%;
|
|
text-align: left;
|
|
border: 1px solid color-mix(in oklab, var(--ui-border) 88%, transparent);
|
|
background: color-mix(in oklab, var(--ui-bg-elevated) 78%, white 22%);
|
|
border-radius: 0.55rem;
|
|
padding: 0.55rem 0.6rem;
|
|
transition: border-color 0.2s ease, transform 0.2s ease, background-color 0.2s ease;
|
|
}
|
|
|
|
.kanban-subtask-card:hover {
|
|
border-color: color-mix(in oklab, var(--ui-primary) 40%, var(--ui-border));
|
|
background: color-mix(in oklab, var(--ui-bg-elevated) 88%, white 12%);
|
|
transform: translateY(-1px);
|
|
}
|
|
|
|
.kanban-subtask-card-head {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-start;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.kanban-subtask-card-title {
|
|
font-size: 0.83rem;
|
|
font-weight: 600;
|
|
line-height: 1.15rem;
|
|
color: var(--ui-text);
|
|
}
|
|
|
|
.kanban-subtask-card-description {
|
|
margin-top: 0.3rem;
|
|
font-size: 0.8rem;
|
|
line-height: 1.1rem;
|
|
color: var(--ui-text-dimmed);
|
|
}
|
|
|
|
.kanban-subtask-more {
|
|
font-size: 0.78rem;
|
|
color: var(--ui-text-dimmed);
|
|
padding: 0.1rem 0.15rem 0;
|
|
}
|
|
|
|
.kanban-empty {
|
|
border: 1px dashed var(--ui-border);
|
|
color: var(--ui-text-dimmed);
|
|
border-radius: 0.6rem;
|
|
padding: 0.7rem;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.waterfall-shell {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.waterfall-card-shell {
|
|
border: 1px solid var(--ui-border);
|
|
border-radius: 0.85rem;
|
|
background: var(--ui-bg);
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
box-shadow: 0 1px 2px color-mix(in oklab, var(--ui-text) 10%, transparent);
|
|
padding: 1rem;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.waterfall-chart-wrap {
|
|
position: relative;
|
|
min-height: 20rem;
|
|
}
|
|
|
|
.waterfall-hint {
|
|
font-size: 0.875rem;
|
|
color: var(--ui-text-dimmed);
|
|
}
|
|
|
|
.task-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.85rem;
|
|
}
|
|
|
|
.task-list-item {
|
|
border: 1px solid var(--ui-border);
|
|
border-radius: 0.85rem;
|
|
background: var(--ui-bg);
|
|
overflow: hidden;
|
|
box-shadow: 0 1px 2px color-mix(in oklab, var(--ui-text) 10%, transparent);
|
|
}
|
|
|
|
.task-list-row {
|
|
display: flex;
|
|
align-items: stretch;
|
|
gap: 0.75rem;
|
|
padding: 0.85rem 1rem;
|
|
}
|
|
|
|
.task-list-toggle,
|
|
.task-list-toggle-placeholder {
|
|
width: 2rem;
|
|
flex: 0 0 2rem;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.task-list-toggle {
|
|
border-radius: 0.55rem;
|
|
color: var(--ui-text-dimmed);
|
|
transition: background-color 0.2s ease, color 0.2s ease;
|
|
}
|
|
|
|
.task-list-toggle:hover {
|
|
background: var(--ui-bg-muted);
|
|
color: var(--ui-text);
|
|
}
|
|
|
|
.task-list-main {
|
|
flex: 1;
|
|
text-align: left;
|
|
}
|
|
|
|
.task-list-main-top {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-start;
|
|
gap: 0.8rem;
|
|
}
|
|
|
|
.task-list-title {
|
|
font-weight: 600;
|
|
line-height: 1.25rem;
|
|
color: var(--ui-text);
|
|
}
|
|
|
|
.task-list-description {
|
|
margin-top: 0.3rem;
|
|
font-size: 0.875rem;
|
|
line-height: 1.2rem;
|
|
color: var(--ui-text-dimmed);
|
|
}
|
|
|
|
.task-list-badges {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
justify-content: flex-end;
|
|
gap: 0.4rem;
|
|
}
|
|
|
|
.task-list-meta {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.75rem;
|
|
margin-top: 0.6rem;
|
|
font-size: 0.85rem;
|
|
color: var(--ui-text-dimmed);
|
|
}
|
|
|
|
.task-list-children {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.55rem;
|
|
padding: 0 1rem 1rem 3.75rem;
|
|
}
|
|
|
|
.task-list-child {
|
|
border: 1px solid color-mix(in oklab, var(--ui-border) 88%, transparent);
|
|
background: var(--ui-bg-elevated);
|
|
border-radius: 0.65rem;
|
|
padding: 0.7rem 0.8rem;
|
|
}
|
|
|
|
.task-list-child-row {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 0.7rem;
|
|
}
|
|
|
|
.task-list-child-main {
|
|
flex: 1;
|
|
text-align: left;
|
|
}
|
|
|
|
.task-list-child-head {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-start;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.task-list-child-title {
|
|
font-size: 0.9rem;
|
|
font-weight: 600;
|
|
line-height: 1.2rem;
|
|
color: var(--ui-text);
|
|
}
|
|
|
|
.task-list-child-description {
|
|
margin-top: 0.3rem;
|
|
font-size: 0.82rem;
|
|
line-height: 1.15rem;
|
|
color: var(--ui-text-dimmed);
|
|
}
|
|
|
|
.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>
|