myCRM/assets/js/views/ProjectTaskManagement.vue
olli 8a132d2fb9 feat: Implement ProjectTask module with full CRUD functionality
- 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.
2025-11-14 17:12:40 +01:00

628 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>