Tasks und Vertragstyp fix #17
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 30s
Build and Push Docker Images / build-frontend (push) Successful in 1m8s

This commit is contained in:
2026-02-15 22:02:16 +01:00
parent 087ba1126e
commit 3f8ce5daf7
23 changed files with 1037 additions and 24 deletions

View File

@@ -0,0 +1 @@
ALTER TABLE "customers" ADD COLUMN "customTaxType" text;

View File

@@ -0,0 +1,16 @@
CREATE TABLE "contracttypes" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "contracttypes_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"tenant" bigint NOT NULL,
"name" text NOT NULL,
"description" text,
"paymentType" text,
"recurring" boolean DEFAULT false NOT NULL,
"archived" boolean DEFAULT false NOT NULL,
"updated_at" timestamp with time zone,
"updated_by" uuid
);
--> statement-breakpoint
ALTER TABLE "contracttypes" ADD CONSTRAINT "contracttypes_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "contracttypes" ADD CONSTRAINT "contracttypes_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -0,0 +1,3 @@
ALTER TABLE "contracts" ADD COLUMN "contracttype" bigint;
--> statement-breakpoint
ALTER TABLE "contracts" ADD CONSTRAINT "contracts_contracttype_contracttypes_id_fk" FOREIGN KEY ("contracttype") REFERENCES "public"."contracttypes"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -0,0 +1,3 @@
ALTER TABLE "contracttypes" ADD COLUMN "billingInterval" text;
--> statement-breakpoint
ALTER TABLE "contracts" ADD COLUMN "billingInterval" text;

View File

@@ -57,6 +57,27 @@
"when": 1772000100000,
"tag": "0007_bright_default_tax_type",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1773000000000,
"tag": "0008_quick_contracttypes",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1773000100000,
"tag": "0009_heavy_contract_contracttype",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1773000200000,
"tag": "0010_sudden_billing_interval",
"breakpoints": true
}
]
}

View File

@@ -11,6 +11,7 @@ import {
import { tenants } from "./tenants"
import { customers } from "./customers"
import { contacts } from "./contacts"
import { contracttypes } from "./contracttypes"
import { authUsers } from "./auth_users"
export const contracts = pgTable(
@@ -48,6 +49,9 @@ export const contracts = pgTable(
contact: bigint("contact", { mode: "number" }).references(
() => contacts.id
),
contracttype: bigint("contracttype", { mode: "number" }).references(
() => contracttypes.id
),
bankingIban: text("bankingIban"),
bankingBIC: text("bankingBIC"),
@@ -57,6 +61,7 @@ export const contracts = pgTable(
sepaDate: timestamp("sepaDate", { withTimezone: true }),
paymentType: text("paymentType"),
billingInterval: text("billingInterval"),
invoiceDispatch: text("invoiceDispatch"),
ownFields: jsonb("ownFields").notNull().default({}),

View File

@@ -0,0 +1,40 @@
import {
pgTable,
bigint,
timestamp,
text,
boolean,
uuid,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
export const contracttypes = pgTable("contracttypes", {
id: bigint("id", { mode: "number" })
.primaryKey()
.generatedByDefaultAsIdentity(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
tenant: bigint("tenant", { mode: "number" })
.notNull()
.references(() => tenants.id),
name: text("name").notNull(),
description: text("description"),
paymentType: text("paymentType"),
recurring: boolean("recurring").notNull().default(false),
billingInterval: text("billingInterval"),
archived: boolean("archived").notNull().default(false),
updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id),
})
export type ContractType = typeof contracttypes.$inferSelect
export type NewContractType = typeof contracttypes.$inferInsert

View File

@@ -13,6 +13,7 @@ export * from "./checks"
export * from "./citys"
export * from "./contacts"
export * from "./contracts"
export * from "./contracttypes"
export * from "./costcentres"
export * from "./countrys"
export * from "./createddocuments"

View File

@@ -296,6 +296,8 @@ export const diffTranslations: Record<
},
projecttype: { label: "Projekttyp" },
contracttype: { label: "Vertragstyp" },
billingInterval: { label: "Abrechnungsintervall" },
fixed: {
label: "Festgeschrieben",

View File

@@ -5,6 +5,7 @@ import {
bankstatements,
contacts,
contracts,
contracttypes,
costcentres,
createddocuments,
customers,
@@ -55,9 +56,13 @@ export const resourceConfig = {
},
contracts: {
table: contracts,
searchColumns: ["name", "notes", "contractNumber", "paymentType", "sepaRef", "bankingName"],
searchColumns: ["name", "notes", "contractNumber", "paymentType", "billingInterval", "sepaRef", "bankingName"],
numberRangeHolder: "contractNumber",
mtoLoad: ["customer"],
mtoLoad: ["customer", "contracttype"],
},
contracttypes: {
table: contracttypes,
searchColumns: ["name", "description", "paymentType", "billingInterval"],
},
plants: {
table: plants,

View File

@@ -31,6 +31,7 @@ const dataStore = useDataStore()
const tempStore = useTempStore()
const router = useRouter()
const createRoute = computed(() => type.value === "tasks" ? `/tasks/create?${props.queryStringData}` : `/standardEntity/${type.value}/create?${props.queryStringData}`)
let dataType = null
@@ -80,7 +81,7 @@ setup()
</template>
<Toolbar>
<UButton
@click="router.push(`/standardEntity/${type}/create?${props.queryStringData}`)"
@click="router.push(createRoute)"
>
+ {{dataType.labelSingle}}
</UButton>

View File

@@ -1,4 +1,6 @@
<script setup>
const getShowRoute = (entityType, id) => entityType === "tasks" ? `/tasks/show/${id}` : `/standardEntity/${entityType}/show/${id}`
defineShortcuts({
/*'/': () => {
//console.log(searchinput)
@@ -8,7 +10,7 @@
'Enter': {
usingInput: true,
handler: () => {
router.push(`/standardEntity/${props.type}/show/${props.rows.value[selectedItem.value].id}`)
router.push(getShowRoute(props.type, props.rows[selectedItem.value].id))
}
},
'arrowdown': () => {
@@ -75,7 +77,7 @@
:columns="props.columns"
class="w-full"
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
@select="(i) => router.push(`/standardEntity/${type}/show/${i.id}`) "
@select="(i) => router.push(getShowRoute(type, i.id))"
:empty-state="{ icon: 'i-heroicons-circle-stack-20-solid', label: `Keine ${dataType.label} anzuzeigen` }"
>
<!-- <template

View File

@@ -46,6 +46,7 @@
const dataStore = useDataStore()
const router = useRouter()
const getShowRoute = (entityType, id) => entityType === "tasks" ? `/tasks/show/${id}` : `/standardEntity/${entityType}/show/${id}`
const dataType = dataStore.dataTypes[props.type]
@@ -59,7 +60,7 @@
<a
v-for="item in props.rows"
class="my-1"
@click="router.push(`/standardEntity/${type}/show/${item.id}`)"
@click="router.push(getShowRoute(type, item.id))"
>
<p class="truncate text-left text-primary text-xl">{{dataType.templateColumns.find(i => i.title).key ? item[dataType.templateColumns.find(i => i.title).key] : null}}</p>
<p class="text-sm">

View File

@@ -20,7 +20,7 @@ const links = computed(() => {
} else if (pin.type === "standardEntity") {
return {
label: pin.label,
to: `/standardEntity/${pin.datatype}/show/${pin.id}`,
to: pin.datatype === "tasks" ? `/tasks/show/${pin.id}` : `/standardEntity/${pin.datatype}/show/${pin.id}`,
icon: pin.icon,
pinned: true
}
@@ -47,7 +47,7 @@ const links = computed(() => {
children: [
...has("tasks") ? [{
label: "Aufgaben",
to: "/standardEntity/tasks",
to: "/tasks",
icon: "i-heroicons-rectangle-stack"
}] : [],
...true ? [{
@@ -278,6 +278,10 @@ const links = computed(() => {
label: "Projekttypen",
to: "/projecttypes",
icon: "i-heroicons-clipboard-document-list",
}, {
label: "Vertragstypen",
to: "/standardEntity/contracttypes",
icon: "i-heroicons-document-duplicate",
}, {
label: "Export",
to: "/export",

View File

@@ -0,0 +1,13 @@
<script setup>
const props = defineProps({
row: {
type: Object,
required: true,
default: {}
}
})
</script>
<template>
<span>{{ props.row.contracttype ? props.row.contracttype.name : '' }}</span>
</template>

View File

@@ -5,8 +5,11 @@ const router = useRouter()
const auth = useAuthStore()
const setupPage = async () => {
//TODO: BACKEND CHANGE Migrate to auth_users for profile
openTasks.value = (await useEntities("tasks").select()).filter(i => !i.archived && i.user_id === auth.user.id)
openTasks.value = (await useEntities("tasks").select()).filter((task) => {
const assignee = task.userId || task.user_id || task.profile
const currentUser = auth.user?.user_id || auth.user?.id
return !task.archived && assignee === currentUser
})
}
setupPage()
@@ -18,7 +21,7 @@ setupPage()
v-if="openTasks.length > 0"
:rows="openTasks"
:columns="[{key:'name',label:'Name'},{key:'categorie',label:'Kategorie'}]"
@select="(i) => router.push(`/standardEntity/tasks/show/${i.id}`)"
@select="(i) => router.push(`/tasks/show/${i.id}`)"
/>
<div v-else>
<p class="text-center font-bold">Keine offenen Aufgaben</p>

View File

@@ -8,7 +8,7 @@ const _useDashboard = () => {
defineShortcuts({
'g-h': () => router.push('/'),
'g-a': () => router.push('/standardEntity/tasks'),
'g-a': () => router.push('/tasks'),
'g-d': () => router.push('/files'),
'g-k': () => router.push('/standardEntity/customers'),
'g-l': () => router.push('/standardEntity/vendors'),

View File

@@ -31,6 +31,18 @@ export const useRole = () => {
label: "Verträge erstellen",
parent: "contracts"
},
contracttypes: {
label: "Vertragstypen",
showToAllUsers: false
},
"contracttypes-viewAll": {
label: "Alle Vertragstypen einsehen",
parent: "contracttypes"
},
"contracttypes-create": {
label: "Vertragstypen erstellen",
parent: "contracttypes"
},
plants: {
label: "Objekte",
showToAllUsers: false

View File

@@ -22,6 +22,13 @@ const setupPage = async (sort_column = null, sort_direction = null) => {
loaded.value = false
setPageLayout(platform)
if (type === "tasks") {
const query = { ...route.query, mode: route.params.mode }
if (route.params.id) query.id = route.params.id
await navigateTo({ path: "/tasks", query })
return
}
if (route.params.mode) mode.value = route.params.mode

View File

@@ -143,6 +143,11 @@ const setupPage = async () => {
loading.value = true
setPageLayout(platformIsNative ? "mobile" : "default")
if (type === "tasks") {
await navigateTo("/tasks")
return
}
const filters = {
archived:false

View File

@@ -0,0 +1,22 @@
<script setup>
const route = useRoute()
const mode = typeof route.params.mode === "string" ? route.params.mode : ""
const id = typeof route.params.id === "string" ? route.params.id : ""
const query = { ...route.query }
if (["create", "show", "edit"].includes(mode)) {
query.mode = mode
}
if (id) {
query.id = id
}
await navigateTo({ path: "/tasks", query }, { replace: true })
</script>
<template>
<UProgress animation="carousel" class="p-5 mt-10" />
</template>

View File

@@ -0,0 +1,746 @@
<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 || "-"
}
function getStatusBadgeColor(status) {
const normalized = normalizeStatus(status)
if (normalized === "Offen") return "gray"
if (normalized === "In Bearbeitung") return "amber"
return "green"
}
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"
color="gray"
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 text-gray-500">Ansicht: Nur eigene Aufgaben</span>
</template>
<template #right>
<div class="flex items-center gap-1 rounded-lg border border-gray-200 p-1 dark:border-gray-700">
<UButton
size="xs"
variant="ghost"
icon="i-heroicons-view-columns"
:color="viewMode === 'kanban' ? 'primary' : 'gray'"
@click="viewMode = 'kanban'"
>
Kanban
</UButton>
<UButton
size="xs"
variant="ghost"
icon="i-heroicons-list-bullet"
:color="viewMode === 'list' ? 'primary' : 'gray'"
@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 color="gray" 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)" color="primary" variant="soft">
{{ getEntityLabel(projectOptions, task.project?.id || task.project) }}
</UBadge>
<UBadge v-if="getEntityLabel(customerOptions, task.customer?.id || task.customer)" color="gray" variant="soft">
{{ getEntityLabel(customerOptions, task.customer?.id || task.customer) }}
</UBadge>
<UBadge v-if="getEntityLabel(plantOptions, task.plant?.id || task.plant)" color="gray" 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
:rows="filteredTasks"
:columns="listColumns"
:empty-state="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Aufgaben anzuzeigen' }"
@select="(task) => openTaskViaRoute(task)"
>
<template #actions-data="{ row }">
<UButton
v-if="normalizeStatus(row.categorie) !== 'Abgeschlossen' && canCreate"
size="xs"
color="green"
variant="soft"
icon="i-heroicons-check"
:loading="quickCompleteLoadingId === row.id"
@click.stop="completeTaskQuick(row)"
>
Erledigt
</UButton>
</template>
<template #categorie-data="{ row }">
<UBadge :color="getStatusBadgeColor(row.categorie)" 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>
</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 color="gray" 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"
color="red"
variant="soft"
:loading="deleting"
@click="archiveTask"
>
Archivieren
</UButton>
</div>
<div class="flex gap-2">
<UButton color="gray" variant="ghost" @click="closeModal">Schließen</UButton>
<UButton
v-if="modalMode === 'show' && canCreate"
color="gray"
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: 1rem;
}
@media (min-width: 1024px) {
.kanban-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.kanban-column {
border: 1px solid rgb(229 231 235);
border-radius: 0.75rem;
background: rgb(249 250 251);
min-height: 500px;
display: flex;
flex-direction: column;
}
.kanban-column-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 0.9rem;
border-bottom: 1px solid rgb(229 231 235);
}
.kanban-dropzone {
padding: 0.75rem;
flex: 1;
display: flex;
flex-direction: column;
gap: 0.75rem;
transition: background-color 0.2s ease;
}
.kanban-dropzone-active {
background: rgb(239 246 255);
}
.kanban-card {
border: 1px solid rgb(229 231 235);
background: white;
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: rgb(107 114 128);
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 rgb(209 213 219);
color: rgb(156 163 175);
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;
}
:global(.dark) .kanban-column {
border-color: rgb(55 65 81);
background: rgb(17 24 39);
}
:global(.dark) .kanban-column-header {
border-bottom-color: rgb(55 65 81);
}
:global(.dark) .kanban-dropzone-active {
background: rgb(30 41 59);
}
:global(.dark) .kanban-card {
border-color: rgb(75 85 99);
background: rgb(31 41 55);
}
:global(.dark) .kanban-card-description {
color: rgb(209 213 219);
}
:global(.dark) .kanban-empty {
border-color: rgb(75 85 99);
color: rgb(156 163 175);
}
:global(.dark) .form-label {
color: rgb(229 231 235);
}
</style>

View File

@@ -4,6 +4,7 @@ import dayjs from "dayjs"
import projecttype from "~/components/columnRenderings/projecttype.vue"
import contracttype from "~/components/columnRenderings/contracttype.vue"
import customer from "~/components/columnRenderings/customer.vue"
import contact from "~/components/columnRenderings/contact.vue"
import plant from "~/components/columnRenderings/plant.vue"
@@ -53,7 +54,7 @@ export const useDataStore = defineStore('data', () => {
isArchivable: true,
label: "Aufgaben",
labelSingle: "Aufgabe",
isStandardEntity: true,
isStandardEntity: false,
redirect: true,
historyItemHolder: "task",
selectWithInformation: "*, plant(*), project(*), customer(*)",
@@ -582,7 +583,7 @@ export const useDataStore = defineStore('data', () => {
"Allgemeines",
"Abrechnung"
],
selectWithInformation: "*, customer(*), files(*)",
selectWithInformation: "*, customer(*), contracttype(*), files(*)",
templateColumns: [
{
key: 'contractNumber',
@@ -600,6 +601,23 @@ export const useDataStore = defineStore('data', () => {
inputType: "text",
inputColumn: "Allgemeines",
sortable: true
},{
key: "contracttype",
label: "Vertragstyp",
component: contracttype,
inputType: "select",
selectDataType: "contracttypes",
selectOptionAttribute: "name",
selectSearchAttributes: ["name"],
inputChangeFunction: function (item, loadedOptions = {}) {
const selectedContractType = (loadedOptions.contracttypes || []).find(i => i.id === item.contracttype)
if (!selectedContractType) return
item.paymentType = selectedContractType.paymentType || null
item.recurring = Boolean(selectedContractType.recurring)
item.billingInterval = selectedContractType.billingInterval || null
},
inputColumn: "Allgemeines"
},{
key: "active",
label: "Aktiv",
@@ -670,6 +688,19 @@ export const useDataStore = defineStore('data', () => {
{label:'Überweisung'}
],
inputColumn: "Abrechnung"
},{
key: "billingInterval",
label: "Abrechnungsintervall",
inputType: "select",
selectValueAttribute: "label",
selectManualOptions: [
{ label: "Monatlich" },
{ label: "Quartalsweise" },
{ label: "Halbjährlich" },
{ label: "Jährlich" }
],
inputColumn: "Abrechnung",
sortable: true
},{
key: 'startDate',
label: "Vertragsstart",
@@ -730,6 +761,75 @@ export const useDataStore = defineStore('data', () => {
],
showTabs: [{label: 'Informationen'},{label: 'Dateien'},{label: 'Wiki'}]
},
contracttypes: {
isArchivable: true,
label: "Vertragstypen",
labelSingle: "Vertragstyp",
isStandardEntity: true,
redirect: true,
sortColumn: "name",
selectWithInformation: "*",
historyItemHolder: "contracttype",
filters: [{
name: "Archivierte ausblenden",
default: true,
"filterFunction": function (row) {
if(!row.archived) {
return true
} else {
return false
}
}
}],
templateColumns: [
{
key: "name",
label: "Name",
required: true,
title: true,
inputType: "text",
sortable: true
},
{
key: "description",
label: "Beschreibung",
inputType: "textarea",
sortable: true
},
{
key: "paymentType",
label: "Zahlart",
inputType: "select",
selectValueAttribute: "label",
selectManualOptions: [
{ label: "Einzug" },
{ label: "Überweisung" }
],
sortable: true
},
{
key: "billingInterval",
label: "Abrechnungsintervall",
inputType: "select",
selectValueAttribute: "label",
selectManualOptions: [
{ label: "Monatlich" },
{ label: "Quartalsweise" },
{ label: "Halbjährlich" },
{ label: "Jährlich" }
],
sortable: true
},
{
key: "recurring",
label: "Wiederkehrend",
inputType: "bool",
component: recurring,
sortable: true
}
],
showTabs: [{ label: "Informationen" }]
},
absencerequests: {
isArchivable: true,
label: "Abwesenheiten",