- Added ProjectTask entity with fields for name, description, budget, hour contingent, hourly rate, and total price. - Created ProjectTaskRepository with methods for querying tasks by project and user access. - Implemented ProjectTaskVoter for fine-grained access control based on user roles and project membership. - Developed ProjectTaskSecurityListener to enforce permission checks during task creation. - Introduced custom ProjectTaskProjectFilter for filtering tasks based on project existence. - Integrated ProjectTask management in the frontend with Vue.js components, including CRUD operations and filtering capabilities. - Added API endpoints for ProjectTask with appropriate security measures. - Created migration for project_tasks table in the database. - Updated documentation to reflect new module features and usage.
628 lines
21 KiB
Vue
628 lines
21 KiB
Vue
<template>
|
||
<div class="project-task-management">
|
||
<CrudDataTable
|
||
ref="tableRef"
|
||
title="Tätigkeiten"
|
||
entity-name="Tätigkeit"
|
||
entity-name-article="eine"
|
||
:columns="taskColumns"
|
||
data-source="/api/project_tasks"
|
||
storage-key="projectTaskTableColumns"
|
||
:show-view-button="canView"
|
||
:show-create-button="canCreate"
|
||
:show-edit-button="canEdit"
|
||
:show-delete-button="canDelete"
|
||
:show-export-button="canExport"
|
||
@view="viewTask"
|
||
@create="openNewTaskDialog"
|
||
@edit="editTask"
|
||
@delete="confirmDelete"
|
||
@data-loaded="onDataLoaded"
|
||
>
|
||
<!-- Custom Filter Buttons -->
|
||
<template #filter-buttons="{ loadData }">
|
||
<div class="flex gap-2 mb-4">
|
||
<Button
|
||
label="Alle"
|
||
:outlined="projectFilter !== 'all'"
|
||
@click="filterByProject('all', loadData)"
|
||
size="small"
|
||
/>
|
||
<Button
|
||
label="Mit Projekt"
|
||
:outlined="projectFilter !== 'with-project'"
|
||
@click="filterByProject('with-project', loadData)"
|
||
size="small"
|
||
/>
|
||
<Button
|
||
label="Ohne Projekt"
|
||
:outlined="projectFilter !== 'without-project'"
|
||
@click="filterByProject('without-project', loadData)"
|
||
size="small"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Custom Column Templates -->
|
||
<template #body-name="{ data }">
|
||
<div class="font-semibold">{{ data.name }}</div>
|
||
</template>
|
||
|
||
<template #body-project="{ data }">
|
||
<div v-if="data.project">{{ data.project.name }}</div>
|
||
<span v-else class="text-500">Projektunabhängig</span>
|
||
</template>
|
||
|
||
<template #body-budget="{ data }">
|
||
<div v-if="data.budget" class="text-right">{{ formatCurrency(data.budget) }}</div>
|
||
</template>
|
||
|
||
<template #body-hourContingent="{ data }">
|
||
<div v-if="data.hourContingent" class="text-right">{{ data.hourContingent }} h</div>
|
||
</template>
|
||
|
||
<template #body-hourlyRate="{ data }">
|
||
<div v-if="data.hourlyRate" class="text-right">{{ formatCurrency(data.hourlyRate) }}/h</div>
|
||
</template>
|
||
|
||
<template #body-totalPrice="{ data }">
|
||
<div v-if="data.totalPrice" class="text-right font-semibold">{{ formatCurrency(data.totalPrice) }}</div>
|
||
</template>
|
||
|
||
<template #body-pricingModel="{ data }">
|
||
<Tag v-if="data.hourlyRate && data.totalPrice" value="Beide" severity="info" />
|
||
<Tag v-else-if="data.hourlyRate" value="Stundensatz" severity="success" />
|
||
<Tag v-else-if="data.totalPrice" value="Festpreis" severity="warning" />
|
||
<span v-else class="text-500">Nicht definiert</span>
|
||
</template>
|
||
|
||
<template #body-createdAt="{ data }">
|
||
{{ formatDate(data.createdAt) }}
|
||
</template>
|
||
|
||
<template #body-updatedAt="{ data }">
|
||
{{ formatDate(data.updatedAt) }}
|
||
</template>
|
||
</CrudDataTable>
|
||
|
||
<!-- Task Dialog -->
|
||
<Dialog
|
||
v-model:visible="taskDialog"
|
||
:header="editingTask?.id ? 'Tätigkeit bearbeiten' : 'Neue Tätigkeit'"
|
||
:modal="true"
|
||
:style="{ width: '800px' }"
|
||
:closable="!saving"
|
||
>
|
||
<div class="flex flex-col gap-4">
|
||
<!-- Basic Information -->
|
||
<div class="font-semibold text-lg">Grunddaten</div>
|
||
<div class="grid grid-cols-2 gap-4">
|
||
<div class="flex flex-col gap-2 col-span-2">
|
||
<label for="name">Name der Tätigkeit *</label>
|
||
<InputText
|
||
id="name"
|
||
v-model="editingTask.name"
|
||
:class="{ 'p-invalid': submitted && !editingTask.name }"
|
||
:disabled="saving"
|
||
/>
|
||
<small v-if="submitted && !editingTask.name" class="p-error">Name ist erforderlich</small>
|
||
</div>
|
||
|
||
<div class="flex flex-col gap-2 col-span-2">
|
||
<label for="description">Beschreibung</label>
|
||
<Textarea
|
||
id="description"
|
||
v-model="editingTask.description"
|
||
rows="3"
|
||
:disabled="saving"
|
||
/>
|
||
</div>
|
||
|
||
<div class="flex flex-col gap-2 col-span-2">
|
||
<label for="project">Projekt</label>
|
||
<Select
|
||
id="project"
|
||
v-model="editingTask.project"
|
||
:options="projects"
|
||
option-label="name"
|
||
placeholder="Projekt auswählen (optional)"
|
||
filter
|
||
:disabled="saving"
|
||
show-clear
|
||
/>
|
||
<small class="text-500">Projektunabhängige Tätigkeiten können nur von Admins erstellt werden</small>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Budget & Contingent -->
|
||
<div class="font-semibold text-lg mt-4">Budget & Kontingent</div>
|
||
<div class="grid grid-cols-2 gap-4">
|
||
<div class="flex flex-col gap-2">
|
||
<label for="budget">Budget (€)</label>
|
||
<InputNumber
|
||
id="budget"
|
||
v-model="editingTask.budget"
|
||
mode="currency"
|
||
currency="EUR"
|
||
locale="de-DE"
|
||
:min="0"
|
||
:disabled="saving"
|
||
/>
|
||
<small class="text-500">Gesamtbudget für diese Tätigkeit</small>
|
||
</div>
|
||
|
||
<div class="flex flex-col gap-2">
|
||
<label for="hourContingent">Stundenkontingent</label>
|
||
<InputNumber
|
||
id="hourContingent"
|
||
v-model="editingTask.hourContingent"
|
||
suffix=" h"
|
||
:min-fraction-digits="0"
|
||
:max-fraction-digits="2"
|
||
:min="0"
|
||
:disabled="saving"
|
||
/>
|
||
<small class="text-500">Verfügbare Stunden für diese Tätigkeit</small>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Pricing -->
|
||
<div class="font-semibold text-lg mt-4">Preismodell</div>
|
||
<div class="grid grid-cols-2 gap-4">
|
||
<div class="flex flex-col gap-2">
|
||
<label for="hourlyRate">Stundensatz (€)</label>
|
||
<InputNumber
|
||
id="hourlyRate"
|
||
v-model="editingTask.hourlyRate"
|
||
mode="currency"
|
||
currency="EUR"
|
||
locale="de-DE"
|
||
:min="0"
|
||
:disabled="saving"
|
||
@input="updateTotalPriceFromHourly"
|
||
/>
|
||
<small class="text-500">Preis pro Stunde</small>
|
||
</div>
|
||
|
||
<div class="flex flex-col gap-2">
|
||
<label for="totalPrice">Gesamtpreis (€)</label>
|
||
<InputNumber
|
||
id="totalPrice"
|
||
v-model="editingTask.totalPrice"
|
||
mode="currency"
|
||
currency="EUR"
|
||
locale="de-DE"
|
||
:min="0"
|
||
:disabled="saving"
|
||
/>
|
||
<small class="text-500">Festpreis oder berechneter Gesamtpreis</small>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Pricing Info -->
|
||
<div v-if="pricingInfo" class="p-3 border-round border-1 surface-border bg-primary-50 dark:bg-primary-900/20">
|
||
<div class="flex items-center gap-2 text-sm">
|
||
<i class="pi pi-info-circle text-primary-600"></i>
|
||
<span>{{ pricingInfo }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<template #footer>
|
||
<Button label="Abbrechen" @click="taskDialog = false" text :disabled="saving" />
|
||
<Button label="Speichern" @click="saveTask" :loading="saving" />
|
||
</template>
|
||
</Dialog>
|
||
|
||
<!-- View Task Dialog -->
|
||
<Dialog
|
||
v-model:visible="viewDialog"
|
||
header="Tätigkeit anzeigen"
|
||
:modal="true"
|
||
:style="{ width: '800px' }"
|
||
>
|
||
<div class="flex flex-col gap-4">
|
||
<!-- Basic Information -->
|
||
<div class="p-4 border-round border-1 surface-border">
|
||
<div class="font-semibold text-lg mb-3">Grunddaten</div>
|
||
<div class="grid grid-cols-2 gap-4">
|
||
<div class="flex flex-col gap-2 col-span-2">
|
||
<label class="font-medium text-sm text-500">Name</label>
|
||
<div class="text-900 font-semibold">{{ viewingTask.name || '-' }}</div>
|
||
</div>
|
||
|
||
<div class="flex flex-col gap-2 col-span-2">
|
||
<label class="font-medium text-sm text-500">Beschreibung</label>
|
||
<div class="text-900 whitespace-pre-wrap">{{ viewingTask.description || '-' }}</div>
|
||
</div>
|
||
|
||
<div class="flex flex-col gap-2 col-span-2">
|
||
<label class="font-medium text-sm text-500">Projekt</label>
|
||
<div v-if="viewingTask.project" class="text-900">{{ viewingTask.project.name }}</div>
|
||
<Tag v-else value="Projektunabhängig" severity="info" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Budget & Contingent -->
|
||
<div class="p-4 border-round border-1 surface-border">
|
||
<div class="font-semibold text-lg mb-3">Budget & Kontingent</div>
|
||
<div class="grid grid-cols-2 gap-4">
|
||
<div class="flex flex-col gap-2">
|
||
<label class="font-medium text-sm text-500">Budget</label>
|
||
<div class="text-900">{{ viewingTask.budget ? formatCurrency(viewingTask.budget) : '-' }}</div>
|
||
</div>
|
||
|
||
<div class="flex flex-col gap-2">
|
||
<label class="font-medium text-sm text-500">Stundenkontingent</label>
|
||
<div class="text-900">{{ viewingTask.hourContingent ? `${viewingTask.hourContingent} h` : '-' }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Pricing -->
|
||
<div class="p-4 border-round border-1 surface-border">
|
||
<div class="font-semibold text-lg mb-3">Preismodell</div>
|
||
<div class="grid grid-cols-2 gap-4">
|
||
<div class="flex flex-col gap-2">
|
||
<label class="font-medium text-sm text-500">Stundensatz</label>
|
||
<div class="text-900">{{ viewingTask.hourlyRate ? `${formatCurrency(viewingTask.hourlyRate)}/h` : '-' }}</div>
|
||
</div>
|
||
|
||
<div class="flex flex-col gap-2">
|
||
<label class="font-medium text-sm text-500">Gesamtpreis</label>
|
||
<div class="text-900 font-semibold text-lg">{{ viewingTask.totalPrice ? formatCurrency(viewingTask.totalPrice) : '-' }}</div>
|
||
</div>
|
||
|
||
<div class="flex flex-col gap-2 col-span-2">
|
||
<label class="font-medium text-sm text-500">Abrechnungsart</label>
|
||
<div>
|
||
<Tag v-if="viewingTask.hourlyRate && viewingTask.totalPrice" value="Stundensatz + Gesamtpreis" severity="info" />
|
||
<Tag v-else-if="viewingTask.hourlyRate" value="Stundensatz" severity="success" />
|
||
<Tag v-else-if="viewingTask.totalPrice" value="Festpreis" severity="warning" />
|
||
<span v-else class="text-500">Nicht definiert</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Timestamps -->
|
||
<div class="p-4 border-round border-1 surface-border">
|
||
<div class="font-semibold text-lg mb-3">Metadaten</div>
|
||
<div class="grid grid-cols-2 gap-4">
|
||
<div class="flex flex-col gap-2">
|
||
<label class="font-medium text-sm text-500">Erstellt am</label>
|
||
<div class="text-900">{{ formatDate(viewingTask.createdAt) || '-' }}</div>
|
||
</div>
|
||
|
||
<div class="flex flex-col gap-2">
|
||
<label class="font-medium text-sm text-500">Zuletzt geändert</label>
|
||
<div class="text-900">{{ formatDate(viewingTask.updatedAt) || '-' }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<template #footer>
|
||
<Button label="Schließen" @click="viewDialog = false" />
|
||
<Button v-if="canEdit" label="Bearbeiten" @click="editFromView" icon="pi pi-pencil" />
|
||
</template>
|
||
</Dialog>
|
||
|
||
<!-- Delete Confirmation Dialog -->
|
||
<Dialog
|
||
v-model:visible="deleteDialog"
|
||
header="Tätigkeit löschen"
|
||
:modal="true"
|
||
:style="{ width: '450px' }"
|
||
>
|
||
<div class="flex align-items-center gap-3">
|
||
<i class="pi pi-exclamation-triangle text-4xl text-red-500" />
|
||
<span>Möchten Sie die Tätigkeit <b>{{ taskToDelete?.name }}</b> wirklich löschen?</span>
|
||
</div>
|
||
<template #footer>
|
||
<Button label="Abbrechen" @click="deleteDialog = false" text :disabled="deleting" />
|
||
<Button label="Löschen" @click="deleteTask" severity="danger" :loading="deleting" />
|
||
</template>
|
||
</Dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, onMounted, computed } from 'vue'
|
||
import { useToast } from 'primevue/usetoast'
|
||
import { usePermissionStore } from '../stores/permissions'
|
||
import CrudDataTable from '../components/CrudDataTable.vue'
|
||
import Dialog from 'primevue/dialog'
|
||
import Button from 'primevue/button'
|
||
import InputText from 'primevue/inputtext'
|
||
import Textarea from 'primevue/textarea'
|
||
import Select from 'primevue/select'
|
||
import InputNumber from 'primevue/inputnumber'
|
||
import Tag from 'primevue/tag'
|
||
|
||
const toast = useToast()
|
||
const permissionStore = usePermissionStore()
|
||
const tableRef = ref(null)
|
||
const taskDialog = ref(false)
|
||
const viewDialog = ref(false)
|
||
const deleteDialog = ref(false)
|
||
const editingTask = ref({})
|
||
const viewingTask = ref({})
|
||
const taskToDelete = ref(null)
|
||
const submitted = ref(false)
|
||
const saving = ref(false)
|
||
const deleting = ref(false)
|
||
const projectFilter = ref('all')
|
||
const projects = ref([])
|
||
|
||
// Permission checks
|
||
const canView = computed(() => permissionStore.canView('project_tasks'))
|
||
const canCreate = computed(() => permissionStore.canCreate('project_tasks'))
|
||
const canEdit = computed(() => permissionStore.canEdit('project_tasks'))
|
||
const canDelete = computed(() => permissionStore.canDelete('project_tasks'))
|
||
const canExport = computed(() => permissionStore.canExport('project_tasks'))
|
||
|
||
// Pricing info computed
|
||
const pricingInfo = computed(() => {
|
||
if (!editingTask.value.hourlyRate || !editingTask.value.hourContingent) {
|
||
return null
|
||
}
|
||
|
||
const calculatedTotal = editingTask.value.hourlyRate * editingTask.value.hourContingent
|
||
|
||
if (editingTask.value.totalPrice && Math.abs(calculatedTotal - editingTask.value.totalPrice) > 0.01) {
|
||
return `Hinweis: Bei ${editingTask.value.hourContingent}h × ${formatCurrency(editingTask.value.hourlyRate)}/h würde der Gesamtpreis ${formatCurrency(calculatedTotal)} betragen.`
|
||
}
|
||
|
||
return `${editingTask.value.hourContingent}h × ${formatCurrency(editingTask.value.hourlyRate)}/h = ${formatCurrency(calculatedTotal)}`
|
||
})
|
||
|
||
// Column definitions
|
||
const taskColumns = ref([
|
||
{ key: 'name', label: 'Name', field: 'name', sortable: true, visible: true },
|
||
{
|
||
key: 'project',
|
||
label: 'Projekt',
|
||
field: 'project.name',
|
||
sortable: false,
|
||
visible: true,
|
||
dataType: 'text',
|
||
showFilterMatchModes: true
|
||
},
|
||
{ key: 'budget', label: 'Budget', field: 'budget', sortable: true, visible: true },
|
||
{ key: 'hourContingent', label: 'Stunden', field: 'hourContingent', sortable: true, visible: true },
|
||
{ key: 'hourlyRate', label: 'Stundensatz', field: 'hourlyRate', sortable: true, visible: true },
|
||
{ key: 'totalPrice', label: 'Gesamtpreis', field: 'totalPrice', sortable: true, visible: true },
|
||
{ key: 'pricingModel', label: 'Abrechnungsart', field: 'pricingModel', sortable: false, visible: true },
|
||
{ key: 'createdAt', label: 'Erstellt am', field: 'createdAt', sortable: true, visible: false },
|
||
{ key: 'updatedAt', label: 'Geändert am', field: 'updatedAt', sortable: true, visible: false }
|
||
])
|
||
|
||
onMounted(async () => {
|
||
await loadProjects()
|
||
})
|
||
|
||
async function loadProjects() {
|
||
try {
|
||
const response = await fetch('/api/projects?pagination=false')
|
||
if (!response.ok) throw new Error('Fehler beim Laden der Projekte')
|
||
|
||
const data = await response.json()
|
||
const projectsList = data['hydra:member'] || data.member || data
|
||
|
||
projects.value = Array.isArray(projectsList) ? projectsList : []
|
||
} catch (error) {
|
||
console.error('Error loading projects:', error)
|
||
projects.value = []
|
||
toast.add({
|
||
severity: 'error',
|
||
summary: 'Fehler',
|
||
detail: 'Projekte konnten nicht geladen werden',
|
||
life: 3000
|
||
})
|
||
}
|
||
}
|
||
|
||
function filterByProject(type, loadData) {
|
||
projectFilter.value = type
|
||
|
||
let filters = {}
|
||
if (type === 'with-project') {
|
||
filters['hasProject'] = 'true'
|
||
} else if (type === 'without-project') {
|
||
filters['hasProject'] = 'false'
|
||
}
|
||
// For 'all', no filter is applied
|
||
|
||
loadData(filters)
|
||
}
|
||
|
||
function onDataLoaded(data) {
|
||
console.log('Tasks loaded:', data.length)
|
||
}
|
||
|
||
async function viewTask(task) {
|
||
viewingTask.value = { ...task }
|
||
viewDialog.value = true
|
||
}
|
||
|
||
function editFromView() {
|
||
viewDialog.value = false
|
||
editTask(viewingTask.value)
|
||
}
|
||
|
||
function openNewTaskDialog() {
|
||
editingTask.value = {
|
||
name: '',
|
||
description: '',
|
||
project: null,
|
||
budget: null,
|
||
hourContingent: null,
|
||
hourlyRate: null,
|
||
totalPrice: null
|
||
}
|
||
submitted.value = false
|
||
taskDialog.value = true
|
||
}
|
||
|
||
function editTask(task) {
|
||
// Find project object from projects array
|
||
let projectObject = null
|
||
if (task.project) {
|
||
if (typeof task.project === 'object' && task.project.id) {
|
||
projectObject = projects.value.find(p => p.id === task.project.id) || task.project
|
||
} else if (typeof task.project === 'string') {
|
||
// Extract ID from IRI like "/api/projects/1"
|
||
const projectId = parseInt(task.project.split('/').pop())
|
||
projectObject = projects.value.find(p => p.id === projectId)
|
||
}
|
||
}
|
||
|
||
editingTask.value = {
|
||
...task,
|
||
project: projectObject,
|
||
budget: task.budget ? parseFloat(task.budget) : null,
|
||
hourContingent: task.hourContingent ? parseFloat(task.hourContingent) : null,
|
||
hourlyRate: task.hourlyRate ? parseFloat(task.hourlyRate) : null,
|
||
totalPrice: task.totalPrice ? parseFloat(task.totalPrice) : null
|
||
}
|
||
submitted.value = false
|
||
taskDialog.value = true
|
||
}
|
||
|
||
function updateTotalPriceFromHourly() {
|
||
if (editingTask.value.hourlyRate && editingTask.value.hourContingent) {
|
||
const calculated = editingTask.value.hourlyRate * editingTask.value.hourContingent
|
||
// Only auto-update if totalPrice is not set or is zero
|
||
if (!editingTask.value.totalPrice || editingTask.value.totalPrice === 0) {
|
||
editingTask.value.totalPrice = calculated
|
||
}
|
||
}
|
||
}
|
||
|
||
async function saveTask() {
|
||
submitted.value = true
|
||
|
||
if (!editingTask.value.name) {
|
||
return
|
||
}
|
||
|
||
saving.value = true
|
||
|
||
try {
|
||
const taskData = {
|
||
name: editingTask.value.name,
|
||
description: editingTask.value.description || null,
|
||
project: editingTask.value.project ? `/api/projects/${editingTask.value.project.id}` : null,
|
||
budget: editingTask.value.budget ? editingTask.value.budget.toString() : null,
|
||
hourContingent: editingTask.value.hourContingent ? editingTask.value.hourContingent.toString() : null,
|
||
hourlyRate: editingTask.value.hourlyRate ? editingTask.value.hourlyRate.toString() : null,
|
||
totalPrice: editingTask.value.totalPrice ? editingTask.value.totalPrice.toString() : null
|
||
}
|
||
|
||
const isNew = !editingTask.value.id
|
||
const url = isNew ? '/api/project_tasks' : `/api/project_tasks/${editingTask.value.id}`
|
||
const method = isNew ? 'POST' : 'PUT'
|
||
|
||
const response = await fetch(url, {
|
||
method,
|
||
headers: {
|
||
'Content-Type': 'application/ld+json',
|
||
'Accept': 'application/ld+json'
|
||
},
|
||
body: JSON.stringify(taskData)
|
||
})
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json()
|
||
throw new Error(error['hydra:description'] || error.message || 'Fehler beim Speichern')
|
||
}
|
||
|
||
toast.add({
|
||
severity: 'success',
|
||
summary: 'Erfolg',
|
||
detail: `Tätigkeit wurde ${isNew ? 'erstellt' : 'aktualisiert'}`,
|
||
life: 3000
|
||
})
|
||
|
||
taskDialog.value = false
|
||
tableRef.value?.loadData()
|
||
} catch (error) {
|
||
console.error('Error saving task:', error)
|
||
toast.add({
|
||
severity: 'error',
|
||
summary: 'Fehler',
|
||
detail: error.message || 'Tätigkeit konnte nicht gespeichert werden',
|
||
life: 3000
|
||
})
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
function confirmDelete(task) {
|
||
taskToDelete.value = task
|
||
deleteDialog.value = true
|
||
}
|
||
|
||
async function deleteTask() {
|
||
deleting.value = true
|
||
|
||
try {
|
||
const response = await fetch(`/api/project_tasks/${taskToDelete.value.id}`, {
|
||
method: 'DELETE'
|
||
})
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Fehler beim Löschen')
|
||
}
|
||
|
||
toast.add({
|
||
severity: 'success',
|
||
summary: 'Erfolg',
|
||
detail: 'Tätigkeit wurde gelöscht',
|
||
life: 3000
|
||
})
|
||
|
||
deleteDialog.value = false
|
||
taskToDelete.value = null
|
||
tableRef.value?.loadData()
|
||
} catch (error) {
|
||
console.error('Error deleting task:', error)
|
||
toast.add({
|
||
severity: 'error',
|
||
summary: 'Fehler',
|
||
detail: 'Tätigkeit konnte nicht gelöscht werden',
|
||
life: 3000
|
||
})
|
||
} finally {
|
||
deleting.value = false
|
||
}
|
||
}
|
||
|
||
function formatDate(dateString) {
|
||
if (!dateString) return ''
|
||
const date = new Date(dateString)
|
||
return date.toLocaleDateString('de-DE', {
|
||
day: '2-digit',
|
||
month: '2-digit',
|
||
year: 'numeric'
|
||
})
|
||
}
|
||
|
||
function formatCurrency(value) {
|
||
if (!value && value !== 0) return ''
|
||
return new Intl.NumberFormat('de-DE', {
|
||
style: 'currency',
|
||
currency: 'EUR'
|
||
}).format(value)
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.project-task-management {
|
||
height: 100%;
|
||
}
|
||
</style>
|