myCRM/assets/js/views/ProjectManagement.vue
olli ab4d2bf9f5 feat: Implement Project Status Management
- Added ProjectStatusManagement.vue for managing project statuses with CRUD operations.
- Created migration scripts for projects and project_statuses tables.
- Defined Project and ProjectStatus entities with necessary fields and relationships.
- Implemented repositories for Project and ProjectStatus with custom query methods.
- Enhanced API resource configurations for Project and ProjectStatus entities.
2025-11-11 16:25:54 +01:00

711 lines
23 KiB
Vue

<template>
<div class="project-management">
<CrudDataTable
ref="tableRef"
title="Projekte"
entity-name="Projekt"
entity-name-article="ein"
:columns="projectColumns"
data-source="/api/projects"
storage-key="projectTableColumns"
:show-view-button="canView"
:show-edit-button="canEdit"
:show-delete-button="canDelete"
:show-export-button="canExport"
@view="viewProject"
@create="openNewProjectDialog"
@edit="editProject"
@delete="confirmDelete"
@data-loaded="onDataLoaded"
>
<!-- Custom Filter Buttons -->
<template #filter-buttons="{ loadData }">
<div class="flex gap-2 mb-4">
<Button
label="Alle"
:outlined="typeFilter !== 'all'"
@click="filterByType('all', loadData)"
size="small"
/>
<Button
label="Beruflich"
:outlined="typeFilter !== 'business'"
@click="filterByType('business', loadData)"
size="small"
/>
<Button
label="Privat"
:outlined="typeFilter !== 'private'"
@click="filterByType('private', loadData)"
size="small"
/>
</div>
</template>
<!-- Custom Column Templates -->
<template #body-name="{ data }">
<div class="font-semibold">{{ data.name }}</div>
</template>
<template #body-projectNumber="{ data }">
{{ data.projectNumber }}
</template>
<template #body-customer="{ data }">
<div v-if="data.customer">{{ data.customer.companyName }}</div>
<span v-else class="text-500">Kein Kunde zugewiesen</span>
</template>
<template #body-status="{ data }">
<Tag v-if="data.status" :value="data.status.name" :style="{ backgroundColor: data.status.color }" />
<span v-else class="text-500">Kein Status</span>
</template>
<template #body-orderNumber="{ data }">
{{ data.orderNumber }}
</template>
<template #body-orderDate="{ data }">
{{ formatDate(data.orderDate) }}
</template>
<template #body-startDate="{ data }">
{{ formatDate(data.startDate) }}
</template>
<template #body-endDate="{ data }">
{{ formatDate(data.endDate) }}
</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-type="{ data }">
<Tag :value="data.isPrivate ? 'Privat' : 'Beruflich'" :severity="data.isPrivate ? 'info' : 'success'" />
</template>
<template #body-createdAt="{ data }">
{{ formatDate(data.createdAt) }}
</template>
<template #body-updatedAt="{ data }">
{{ formatDate(data.updatedAt) }}
</template>
</CrudDataTable>
<!-- Project Dialog -->
<Dialog
v-model:visible="projectDialog"
:header="editingProject?.id ? 'Projekt bearbeiten' : 'Neues Projekt'"
:modal="true"
:style="{ width: '900px' }"
: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">Projektname *</label>
<InputText
id="name"
v-model="editingProject.name"
:class="{ 'p-invalid': submitted && !editingProject.name }"
:disabled="saving"
/>
<small v-if="submitted && !editingProject.name" class="p-error">Projektname ist erforderlich</small>
</div>
<div class="flex flex-col gap-2 col-span-2">
<label for="description">Beschreibung</label>
<Textarea
id="description"
v-model="editingProject.description"
rows="3"
:disabled="saving"
/>
</div>
<div class="flex flex-col gap-2">
<label for="customer">Kunde</label>
<Select
id="customer"
v-model="editingProject.customer"
:options="customers"
option-label="companyName"
placeholder="Kunde auswählen"
filter
:disabled="saving"
show-clear
/>
</div>
<div class="flex flex-col gap-2">
<label for="status">Status</label>
<Select
id="status"
v-model="editingProject.status"
:options="statuses"
option-label="name"
placeholder="Status auswählen"
:disabled="saving"
show-clear
>
<template #option="slotProps">
<div class="flex align-items-center gap-2">
<span
class="inline-block w-3 h-3 border-round"
:style="{ backgroundColor: slotProps.option.color }"
></span>
<span>{{ slotProps.option.name }}</span>
</div>
</template>
</Select>
</div>
<div class="flex flex-col gap-2">
<label for="isPrivate">Typ</label>
<div class="flex align-items-center gap-3 mt-2">
<div class="flex align-items-center">
<RadioButton
id="business"
v-model="editingProject.isPrivate"
:value="false"
:disabled="saving"
/>
<label for="business" class="ml-2">Beruflich</label>
</div>
<div class="flex align-items-center">
<RadioButton
id="private"
v-model="editingProject.isPrivate"
:value="true"
:disabled="saving"
/>
<label for="private" class="ml-2">Privat</label>
</div>
</div>
</div>
</div>
<!-- Project Numbers & Dates -->
<div class="font-semibold text-lg mt-4">Nummern & Daten</div>
<div class="grid grid-cols-3 gap-4">
<div class="flex flex-col gap-2">
<label for="projectNumber">Projektnummer</label>
<InputText
id="projectNumber"
v-model="editingProject.projectNumber"
:disabled="saving"
/>
</div>
<div class="flex flex-col gap-2">
<label for="orderNumber">Bestellnummer</label>
<InputText
id="orderNumber"
v-model="editingProject.orderNumber"
:disabled="saving"
/>
</div>
<div class="flex flex-col gap-2">
<label for="orderDate">Bestelldatum</label>
<DatePicker
id="orderDate"
v-model="editingProject.orderDate"
date-format="dd.mm.yy"
:disabled="saving"
show-icon
/>
</div>
<div class="flex flex-col gap-2">
<label for="startDate">Startdatum</label>
<DatePicker
id="startDate"
v-model="editingProject.startDate"
date-format="dd.mm.yy"
:disabled="saving"
show-icon
/>
</div>
<div class="flex flex-col gap-2">
<label for="endDate">Enddatum</label>
<DatePicker
id="endDate"
v-model="editingProject.endDate"
date-format="dd.mm.yy"
:disabled="saving"
show-icon
/>
</div>
</div>
<!-- Budget & Hours -->
<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="editingProject.budget"
mode="currency"
currency="EUR"
locale="de-DE"
:min="0"
:disabled="saving"
/>
</div>
<div class="flex flex-col gap-2">
<label for="hourContingent">Stundenkontingent</label>
<InputNumber
id="hourContingent"
v-model="editingProject.hourContingent"
suffix=" h"
:min-fraction-digits="0"
:max-fraction-digits="2"
:min="0"
:disabled="saving"
/>
</div>
</div>
</div>
<template #footer>
<Button label="Abbrechen" @click="projectDialog = false" text :disabled="saving" />
<Button label="Speichern" @click="saveProject" :loading="saving" />
</template>
</Dialog>
<!-- Delete Confirmation Dialog -->
<Dialog
v-model:visible="deleteDialog"
header="Projekt 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 das Projekt <b>{{ projectToDelete?.name }}</b> wirklich löschen?</span>
</div>
<template #footer>
<Button label="Abbrechen" @click="deleteDialog = false" text :disabled="deleting" />
<Button label="Löschen" @click="deleteProject" severity="danger" :loading="deleting" />
</template>
</Dialog>
<!-- View Project Dialog -->
<Dialog
v-model:visible="viewDialog"
header="Projekt anzeigen"
:modal="true"
:style="{ width: '900px' }"
>
<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">
<label class="font-medium text-sm text-500">Projektname</label>
<div class="text-900">{{ viewingProject.name || '-' }}</div>
</div>
<div class="flex flex-col gap-2">
<label class="font-medium text-sm text-500">Projektnummer</label>
<div class="text-900">{{ viewingProject.projectNumber || '-' }}</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">{{ viewingProject.description || '-' }}</div>
</div>
<div class="flex flex-col gap-2">
<label class="font-medium text-sm text-500">Kunde</label>
<div class="text-900">{{ viewingProject.customer?.companyName || '-' }}</div>
</div>
<div class="flex flex-col gap-2">
<label class="font-medium text-sm text-500">Status</label>
<Tag v-if="viewingProject.status" :value="viewingProject.status.name" :style="{ backgroundColor: viewingProject.status.color }" />
<div v-else class="text-900">-</div>
</div>
<div class="flex flex-col gap-2">
<label class="font-medium text-sm text-500">Typ</label>
<Tag :value="viewingProject.isPrivate ? 'Privat' : 'Beruflich'" :severity="viewingProject.isPrivate ? 'info' : 'success'" />
</div>
</div>
<!-- Project Numbers & Dates -->
<div class="font-semibold text-lg mt-4">Nummern & Daten</div>
<div class="grid grid-cols-3 gap-4">
<div class="flex flex-col gap-2">
<label class="font-medium text-sm text-500">Bestellnummer</label>
<div class="text-900">{{ viewingProject.orderNumber || '-' }}</div>
</div>
<div class="flex flex-col gap-2">
<label class="font-medium text-sm text-500">Bestelldatum</label>
<div class="text-900">{{ formatDate(viewingProject.orderDate) || '-' }}</div>
</div>
<div class="flex flex-col gap-2">
<label class="font-medium text-sm text-500">Startdatum</label>
<div class="text-900">{{ formatDate(viewingProject.startDate) || '-' }}</div>
</div>
<div class="flex flex-col gap-2">
<label class="font-medium text-sm text-500">Enddatum</label>
<div class="text-900">{{ formatDate(viewingProject.endDate) || '-' }}</div>
</div>
</div>
<!-- Budget & Hours -->
<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 class="font-medium text-sm text-500">Budget</label>
<div class="text-900">{{ viewingProject.budget ? formatCurrency(viewingProject.budget) : '-' }}</div>
</div>
<div class="flex flex-col gap-2">
<label class="font-medium text-sm text-500">Stundenkontingent</label>
<div class="text-900">{{ viewingProject.hourContingent ? `${viewingProject.hourContingent} h` : '-' }}</div>
</div>
</div>
<!-- Timestamps -->
<div class="font-semibold text-lg mt-4">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(viewingProject.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(viewingProject.updatedAt) || '-' }}</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>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useToast } from 'primevue/usetoast'
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 DatePicker from 'primevue/datepicker'
import InputNumber from 'primevue/inputnumber'
import RadioButton from 'primevue/radiobutton'
import Tag from 'primevue/tag'
const toast = useToast()
const tableRef = ref(null)
const projectDialog = ref(false)
const viewDialog = ref(false)
const deleteDialog = ref(false)
const editingProject = ref({})
const viewingProject = ref({})
const projectToDelete = ref(null)
const submitted = ref(false)
const saving = ref(false)
const deleting = ref(false)
const typeFilter = ref('all')
const customers = ref([])
const statuses = ref([])
// Permission checks (will be replaced with actual permission checks)
const canView = computed(() => true)
const canEdit = computed(() => true)
const canDelete = computed(() => true)
const canExport = computed(() => true)
// Column definitions
const projectColumns = ref([
{ key: 'name', label: 'Projektname', field: 'name', sortable: true, visible: true },
{ key: 'projectNumber', label: 'Projektnummer', field: 'projectNumber', sortable: true, visible: true },
{ key: 'customer', label: 'Kunde', field: 'customer', sortable: false, visible: true },
{ key: 'status', label: 'Status', field: 'status', sortable: false, visible: true },
{ key: 'orderNumber', label: 'Bestellnummer', field: 'orderNumber', sortable: true, visible: false },
{ key: 'orderDate', label: 'Bestelldatum', field: 'orderDate', sortable: true, visible: false },
{ key: 'startDate', label: 'Start', field: 'startDate', sortable: true, visible: true },
{ key: 'endDate', label: 'Ende', field: 'endDate', sortable: true, visible: true },
{ key: 'budget', label: 'Budget', field: 'budget', sortable: true, visible: true },
{ key: 'hourContingent', label: 'Stunden', field: 'hourContingent', sortable: true, visible: false },
{ key: 'type', label: 'Typ', field: 'type', 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 Promise.all([loadCustomers(), loadStatuses()])
})
async function loadCustomers() {
try {
const response = await fetch('/api/contacts?pagination=false')
if (!response.ok) throw new Error('Fehler beim Laden der Kunden')
const data = await response.json()
const customersList = data['hydra:member'] || data.member || data
// Ensure it's an array
customers.value = Array.isArray(customersList) ? customersList : []
} catch (error) {
console.error('Error loading customers:', error)
customers.value = []
toast.add({
severity: 'error',
summary: 'Fehler',
detail: 'Kunden konnten nicht geladen werden',
life: 3000
})
}
}
async function loadStatuses() {
try {
const response = await fetch('/api/project_statuses?pagination=false&isActive=true')
if (!response.ok) throw new Error('Fehler beim Laden der Status')
const data = await response.json()
const statusesList = data['hydra:member'] || data.member || data
// Ensure it's an array
statuses.value = Array.isArray(statusesList) ? statusesList : []
} catch (error) {
console.error('Error loading statuses:', error)
statuses.value = []
toast.add({
severity: 'error',
summary: 'Fehler',
detail: 'Status konnten nicht geladen werden',
life: 3000
})
}
}
function filterByType(type, loadData) {
typeFilter.value = type
let filters = {}
if (type === 'business') {
filters.isPrivate = false
} else if (type === 'private') {
filters.isPrivate = true
}
loadData(filters)
}
function onDataLoaded(data) {
console.log('Projects loaded:', data.length)
}
function viewProject(project) {
viewingProject.value = { ...project }
viewDialog.value = true
}
function editFromView() {
viewDialog.value = false
editProject(viewingProject.value)
}
function openNewProjectDialog() {
// Find default status
const defaultStatus = statuses.value.find(s => s.isDefault) || statuses.value[0] || null
editingProject.value = {
name: '',
description: '',
customer: null,
status: defaultStatus,
projectNumber: '',
orderNumber: '',
orderDate: null,
startDate: null,
endDate: null,
budget: null,
hourContingent: null,
isPrivate: false
}
submitted.value = false
projectDialog.value = true
}
function editProject(project) {
// Convert date strings to Date objects for Calendar component
editingProject.value = {
...project,
orderDate: project.orderDate ? new Date(project.orderDate) : null,
startDate: project.startDate ? new Date(project.startDate) : null,
endDate: project.endDate ? new Date(project.endDate) : null,
budget: project.budget ? parseFloat(project.budget) : null,
hourContingent: project.hourContingent ? parseFloat(project.hourContingent) : null
}
submitted.value = false
projectDialog.value = true
}
async function saveProject() {
submitted.value = true
if (!editingProject.value.name) {
return
}
saving.value = true
try {
// Prepare data for API
const projectData = {
name: editingProject.value.name,
description: editingProject.value.description || null,
customer: editingProject.value.customer ? `/api/contacts/${editingProject.value.customer.id}` : null,
status: editingProject.value.status ? `/api/project_statuses/${editingProject.value.status.id}` : null,
projectNumber: editingProject.value.projectNumber || null,
orderNumber: editingProject.value.orderNumber || null,
orderDate: editingProject.value.orderDate ? formatDateForAPI(editingProject.value.orderDate) : null,
startDate: editingProject.value.startDate ? formatDateForAPI(editingProject.value.startDate) : null,
endDate: editingProject.value.endDate ? formatDateForAPI(editingProject.value.endDate) : null,
budget: editingProject.value.budget ? editingProject.value.budget.toString() : null,
hourContingent: editingProject.value.hourContingent ? editingProject.value.hourContingent.toString() : null,
isPrivate: editingProject.value.isPrivate
}
const isNew = !editingProject.value.id
const url = isNew ? '/api/projects' : `/api/projects/${editingProject.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(projectData)
})
if (!response.ok) {
const error = await response.json()
throw new Error(error['hydra:description'] || 'Fehler beim Speichern')
}
toast.add({
severity: 'success',
summary: 'Erfolg',
detail: `Projekt wurde ${isNew ? 'erstellt' : 'aktualisiert'}`,
life: 3000
})
projectDialog.value = false
tableRef.value?.loadData()
} catch (error) {
console.error('Error saving project:', error)
toast.add({
severity: 'error',
summary: 'Fehler',
detail: error.message || 'Projekt konnte nicht gespeichert werden',
life: 3000
})
} finally {
saving.value = false
}
}
function confirmDelete(project) {
projectToDelete.value = project
deleteDialog.value = true
}
async function deleteProject() {
deleting.value = true
try {
const response = await fetch(`/api/projects/${projectToDelete.value.id}`, {
method: 'DELETE'
})
if (!response.ok) {
throw new Error('Fehler beim Löschen')
}
toast.add({
severity: 'success',
summary: 'Erfolg',
detail: 'Projekt wurde gelöscht',
life: 3000
})
deleteDialog.value = false
projectToDelete.value = null
tableRef.value?.loadData()
} catch (error) {
console.error('Error deleting project:', error)
toast.add({
severity: 'error',
summary: 'Fehler',
detail: 'Projekt 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 formatDateForAPI(date) {
if (!date) return null
const d = new Date(date)
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
function formatCurrency(value) {
if (!value) return ''
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR'
}).format(value)
}
</script>
<style scoped>
.project-management {
height: 100%;
}
</style>