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.
This commit is contained in:
parent
3e30d958b3
commit
ab4d2bf9f5
@ -193,6 +193,10 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
default: ''
|
default: ''
|
||||||
},
|
},
|
||||||
|
entityNameArticle: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
columns: {
|
columns: {
|
||||||
type: Array,
|
type: Array,
|
||||||
required: true
|
required: true
|
||||||
@ -308,6 +312,11 @@ const exportItems = computed(() => [
|
|||||||
// Computed create button label
|
// Computed create button label
|
||||||
const createLabel = computed(() => {
|
const createLabel = computed(() => {
|
||||||
if (props.entityName) {
|
if (props.entityName) {
|
||||||
|
// If custom article is provided, use it
|
||||||
|
if (props.entityNameArticle) {
|
||||||
|
return `Neu${props.entityNameArticle === 'ein' ? 'es' : 'er'} ${props.entityName}`
|
||||||
|
}
|
||||||
|
// Fallback: Simple heuristic
|
||||||
return `Neue${props.entityName.endsWith('e') ? 'r' : 'n'} ${props.entityName}`
|
return `Neue${props.entityName.endsWith('e') ? 'r' : 'n'} ${props.entityName}`
|
||||||
}
|
}
|
||||||
return 'Neu'
|
return 'Neu'
|
||||||
|
|||||||
@ -13,13 +13,15 @@ const model = ref([
|
|||||||
{
|
{
|
||||||
label: 'CRM',
|
label: 'CRM',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Kontakte', icon: 'pi pi-fw pi-users', to: '/contacts' }
|
{ label: 'Kontakte', icon: 'pi pi-fw pi-users', to: '/contacts' },
|
||||||
|
{ label: 'Projekte', icon: 'pi pi-fw pi-briefcase', to: '/projects' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Administration',
|
label: 'Administration',
|
||||||
visible: () => authStore.isAdmin,
|
visible: () => authStore.isAdmin,
|
||||||
items: [
|
items: [
|
||||||
|
{ label: 'Projekt-Status', icon: 'pi pi-fw pi-tag', to: '/project-statuses' },
|
||||||
{ label: 'Benutzerverwaltung', icon: 'pi pi-fw pi-user-edit', to: '/users' },
|
{ label: 'Benutzerverwaltung', icon: 'pi pi-fw pi-user-edit', to: '/users' },
|
||||||
{ label: 'Rollenverwaltung', icon: 'pi pi-fw pi-shield', to: '/roles' },
|
{ label: 'Rollenverwaltung', icon: 'pi pi-fw pi-shield', to: '/roles' },
|
||||||
{ label: 'Einstellungen', icon: 'pi pi-fw pi-cog', to: '/settings' }
|
{ label: 'Einstellungen', icon: 'pi pi-fw pi-cog', to: '/settings' }
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router';
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
import Dashboard from './views/Dashboard.vue';
|
import Dashboard from './views/Dashboard.vue';
|
||||||
import ContactManagement from './views/ContactManagement.vue';
|
import ContactManagement from './views/ContactManagement.vue';
|
||||||
|
import ProjectManagement from './views/ProjectManagement.vue';
|
||||||
|
import ProjectStatusManagement from './views/ProjectStatusManagement.vue';
|
||||||
import UserManagement from './views/UserManagement.vue';
|
import UserManagement from './views/UserManagement.vue';
|
||||||
import RoleManagement from './views/RoleManagement.vue';
|
import RoleManagement from './views/RoleManagement.vue';
|
||||||
import SettingsManagement from './views/SettingsManagement.vue';
|
import SettingsManagement from './views/SettingsManagement.vue';
|
||||||
@ -8,6 +10,8 @@ import SettingsManagement from './views/SettingsManagement.vue';
|
|||||||
const routes = [
|
const routes = [
|
||||||
{ path: '/', name: 'dashboard', component: Dashboard },
|
{ path: '/', name: 'dashboard', component: Dashboard },
|
||||||
{ path: '/contacts', name: 'contacts', component: ContactManagement },
|
{ path: '/contacts', name: 'contacts', component: ContactManagement },
|
||||||
|
{ path: '/projects', name: 'projects', component: ProjectManagement },
|
||||||
|
{ path: '/project-statuses', name: 'project-statuses', component: ProjectStatusManagement, meta: { requiresAdmin: true } },
|
||||||
{ path: '/users', name: 'users', component: UserManagement, meta: { requiresAdmin: true } },
|
{ path: '/users', name: 'users', component: UserManagement, meta: { requiresAdmin: true } },
|
||||||
{ path: '/roles', name: 'roles', component: RoleManagement, meta: { requiresAdmin: true } },
|
{ path: '/roles', name: 'roles', component: RoleManagement, meta: { requiresAdmin: true } },
|
||||||
{ path: '/settings', name: 'settings', component: SettingsManagement, meta: { requiresAdmin: true } },
|
{ path: '/settings', name: 'settings', component: SettingsManagement, meta: { requiresAdmin: true } },
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
ref="tableRef"
|
ref="tableRef"
|
||||||
title="Kontakte"
|
title="Kontakte"
|
||||||
entity-name="Kontakt"
|
entity-name="Kontakt"
|
||||||
|
entity-name-article="einen"
|
||||||
:columns="contactColumns"
|
:columns="contactColumns"
|
||||||
data-source="/api/contacts"
|
data-source="/api/contacts"
|
||||||
storage-key="contactTableColumns"
|
storage-key="contactTableColumns"
|
||||||
|
|||||||
710
assets/js/views/ProjectManagement.vue
Normal file
710
assets/js/views/ProjectManagement.vue
Normal file
@ -0,0 +1,710 @@
|
|||||||
|
<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>
|
||||||
329
assets/js/views/ProjectStatusManagement.vue
Normal file
329
assets/js/views/ProjectStatusManagement.vue
Normal file
@ -0,0 +1,329 @@
|
|||||||
|
<template>
|
||||||
|
<div class="project-status-management">
|
||||||
|
<CrudDataTable
|
||||||
|
ref="tableRef"
|
||||||
|
title="Projekt-Status"
|
||||||
|
entity-name="Status"
|
||||||
|
entity-name-article="einen"
|
||||||
|
:columns="statusColumns"
|
||||||
|
data-source="/api/project_statuses"
|
||||||
|
storage-key="projectStatusTableColumns"
|
||||||
|
:show-view-button="false"
|
||||||
|
:show-edit-button="canEdit"
|
||||||
|
:show-delete-button="canDelete"
|
||||||
|
:show-export-button="false"
|
||||||
|
@create="openNewStatusDialog"
|
||||||
|
@edit="editStatus"
|
||||||
|
@delete="confirmDelete"
|
||||||
|
>
|
||||||
|
<!-- Custom Column Templates -->
|
||||||
|
<template #body-name="{ data }">
|
||||||
|
<div class="flex align-items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="inline-block w-3 h-3 border-round"
|
||||||
|
:style="{ backgroundColor: data.color }"
|
||||||
|
></span>
|
||||||
|
<span class="font-semibold">{{ data.name }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #body-color="{ data }">
|
||||||
|
<div class="flex align-items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="inline-block border-round"
|
||||||
|
:style="{
|
||||||
|
backgroundColor: data.color,
|
||||||
|
width: '16px',
|
||||||
|
height: '16px',
|
||||||
|
border: '1px solid var(--surface-300)'
|
||||||
|
}"
|
||||||
|
></span>
|
||||||
|
<code class="text-sm">{{ data.color }}</code>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #body-sortOrder="{ data }">
|
||||||
|
{{ data.sortOrder }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #body-isDefault="{ data }">
|
||||||
|
<Tag v-if="data.isDefault" value="Standard" severity="info" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #body-isActive="{ data }">
|
||||||
|
<Tag :value="data.isActive ? 'Aktiv' : 'Inaktiv'" :severity="data.isActive ? 'success' : 'secondary'" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #body-createdAt="{ data }">
|
||||||
|
{{ formatDate(data.createdAt) }}
|
||||||
|
</template>
|
||||||
|
</CrudDataTable>
|
||||||
|
|
||||||
|
<!-- Status Dialog -->
|
||||||
|
<Dialog
|
||||||
|
v-model:visible="statusDialog"
|
||||||
|
:header="editingStatus?.id ? 'Status bearbeiten' : 'Neuer Status'"
|
||||||
|
:modal="true"
|
||||||
|
:style="{ width: '600px' }"
|
||||||
|
:closable="!saving"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label for="name">Name *</label>
|
||||||
|
<InputText
|
||||||
|
id="name"
|
||||||
|
v-model="editingStatus.name"
|
||||||
|
:class="{ 'p-invalid': submitted && !editingStatus.name }"
|
||||||
|
:disabled="saving"
|
||||||
|
/>
|
||||||
|
<small v-if="submitted && !editingStatus.name" class="p-error">Name ist erforderlich</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label for="color">Farbe</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<ColorPicker
|
||||||
|
v-model="editingStatus.color"
|
||||||
|
format="hex"
|
||||||
|
:disabled="saving"
|
||||||
|
/>
|
||||||
|
<InputText
|
||||||
|
id="color"
|
||||||
|
v-model="editingStatus.color"
|
||||||
|
placeholder="#3B82F6"
|
||||||
|
:disabled="saving"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label for="sortOrder">Sortierung</label>
|
||||||
|
<InputNumber
|
||||||
|
id="sortOrder"
|
||||||
|
v-model="editingStatus.sortOrder"
|
||||||
|
:min="0"
|
||||||
|
:disabled="saving"
|
||||||
|
/>
|
||||||
|
<small class="text-500">Niedrigere Werte werden zuerst angezeigt</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex align-items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="isDefault"
|
||||||
|
v-model="editingStatus.isDefault"
|
||||||
|
:binary="true"
|
||||||
|
:disabled="saving"
|
||||||
|
/>
|
||||||
|
<label for="isDefault" class="mb-0">Als Standard-Status verwenden</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex align-items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="isActive"
|
||||||
|
v-model="editingStatus.isActive"
|
||||||
|
:binary="true"
|
||||||
|
:disabled="saving"
|
||||||
|
/>
|
||||||
|
<label for="isActive" class="mb-0">Status ist aktiv</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<Button label="Abbrechen" @click="statusDialog = false" text :disabled="saving" />
|
||||||
|
<Button label="Speichern" @click="saveStatus" :loading="saving" />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- Delete Confirmation Dialog -->
|
||||||
|
<Dialog
|
||||||
|
v-model:visible="deleteDialog"
|
||||||
|
header="Status 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 den Status <b>{{ statusToDelete?.name }}</b> wirklich löschen?</span>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<Button label="Abbrechen" @click="deleteDialog = false" text :disabled="deleting" />
|
||||||
|
<Button label="Löschen" @click="deleteStatus" severity="danger" :loading="deleting" />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, 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 InputNumber from 'primevue/inputnumber'
|
||||||
|
import Checkbox from 'primevue/checkbox'
|
||||||
|
import ColorPicker from 'primevue/colorpicker'
|
||||||
|
import Tag from 'primevue/tag'
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
const tableRef = ref(null)
|
||||||
|
const statusDialog = ref(false)
|
||||||
|
const deleteDialog = ref(false)
|
||||||
|
const editingStatus = ref({})
|
||||||
|
const statusToDelete = ref(null)
|
||||||
|
const submitted = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const deleting = ref(false)
|
||||||
|
|
||||||
|
// Permission checks
|
||||||
|
const canEdit = computed(() => true)
|
||||||
|
const canDelete = computed(() => true)
|
||||||
|
|
||||||
|
// Column definitions
|
||||||
|
const statusColumns = ref([
|
||||||
|
{ key: 'name', label: 'Name', field: 'name', sortable: true, visible: true },
|
||||||
|
{ key: 'color', label: 'Farbe', field: 'color', sortable: false, visible: true, style: { width: '150px' } },
|
||||||
|
{ key: 'sortOrder', label: 'Sortierung', field: 'sortOrder', sortable: true, visible: true, style: { width: '120px' } },
|
||||||
|
{ key: 'isDefault', label: 'Standard', field: 'isDefault', sortable: true, visible: true, style: { width: '120px' } },
|
||||||
|
{ key: 'isActive', label: 'Aktiv', field: 'isActive', sortable: true, visible: true, style: { width: '100px' } },
|
||||||
|
{ key: 'createdAt', label: 'Erstellt am', field: 'createdAt', sortable: true, visible: false }
|
||||||
|
])
|
||||||
|
|
||||||
|
function openNewStatusDialog() {
|
||||||
|
editingStatus.value = {
|
||||||
|
name: '',
|
||||||
|
color: '#3B82F6',
|
||||||
|
sortOrder: 0,
|
||||||
|
isDefault: false,
|
||||||
|
isActive: true
|
||||||
|
}
|
||||||
|
submitted.value = false
|
||||||
|
statusDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function editStatus(status) {
|
||||||
|
editingStatus.value = { ...status }
|
||||||
|
submitted.value = false
|
||||||
|
statusDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveStatus() {
|
||||||
|
submitted.value = true
|
||||||
|
|
||||||
|
if (!editingStatus.value.name) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Ensure color has # prefix
|
||||||
|
if (editingStatus.value.color && !editingStatus.value.color.startsWith('#')) {
|
||||||
|
editingStatus.value.color = '#' + editingStatus.value.color
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusData = {
|
||||||
|
name: editingStatus.value.name,
|
||||||
|
color: editingStatus.value.color || null,
|
||||||
|
sortOrder: editingStatus.value.sortOrder || 0,
|
||||||
|
isDefault: editingStatus.value.isDefault || false,
|
||||||
|
isActive: editingStatus.value.isActive !== false
|
||||||
|
}
|
||||||
|
|
||||||
|
const isNew = !editingStatus.value.id
|
||||||
|
const url = isNew ? '/api/project_statuses' : `/api/project_statuses/${editingStatus.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(statusData)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error['hydra:description'] || 'Fehler beim Speichern')
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Erfolg',
|
||||||
|
detail: `Status wurde ${isNew ? 'erstellt' : 'aktualisiert'}`,
|
||||||
|
life: 3000
|
||||||
|
})
|
||||||
|
|
||||||
|
statusDialog.value = false
|
||||||
|
tableRef.value?.loadData()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving status:', error)
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Fehler',
|
||||||
|
detail: error.message || 'Status konnte nicht gespeichert werden',
|
||||||
|
life: 3000
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(status) {
|
||||||
|
statusToDelete.value = status
|
||||||
|
deleteDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteStatus() {
|
||||||
|
deleting.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/project_statuses/${statusToDelete.value.id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Fehler beim Löschen')
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Erfolg',
|
||||||
|
detail: 'Status wurde gelöscht',
|
||||||
|
life: 3000
|
||||||
|
})
|
||||||
|
|
||||||
|
deleteDialog.value = false
|
||||||
|
statusToDelete.value = null
|
||||||
|
tableRef.value?.loadData()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting status:', error)
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Fehler',
|
||||||
|
detail: 'Status 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'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.project-status-management {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
33
migrations/Version20251111133233.php
Normal file
33
migrations/Version20251111133233.php
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20251111133233 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('CREATE TABLE projects (id INT AUTO_INCREMENT NOT NULL, customer_id INT DEFAULT NULL, name VARCHAR(255) NOT NULL, description LONGTEXT DEFAULT NULL, project_number VARCHAR(50) DEFAULT NULL, order_number VARCHAR(50) DEFAULT NULL, order_date DATE DEFAULT NULL COMMENT \'(DC2Type:date_immutable)\', start_date DATE DEFAULT NULL COMMENT \'(DC2Type:date_immutable)\', end_date DATE DEFAULT NULL COMMENT \'(DC2Type:date_immutable)\', budget NUMERIC(10, 2) DEFAULT NULL, hour_contingent NUMERIC(8, 2) DEFAULT NULL, is_private TINYINT(1) NOT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', INDEX IDX_5C93B3A49395C3F3 (customer_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
|
||||||
|
$this->addSql('ALTER TABLE projects ADD CONSTRAINT FK_5C93B3A49395C3F3 FOREIGN KEY (customer_id) REFERENCES contacts (id) ON DELETE SET NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE projects DROP FOREIGN KEY FK_5C93B3A49395C3F3');
|
||||||
|
$this->addSql('DROP TABLE projects');
|
||||||
|
}
|
||||||
|
}
|
||||||
37
migrations/Version20251111142205.php
Normal file
37
migrations/Version20251111142205.php
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20251111142205 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('CREATE TABLE project_statuses (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(100) NOT NULL, color VARCHAR(7) DEFAULT NULL, sort_order INT NOT NULL, is_default TINYINT(1) NOT NULL, is_active TINYINT(1) NOT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
|
||||||
|
$this->addSql('ALTER TABLE projects ADD status_id INT DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE projects ADD CONSTRAINT FK_5C93B3A46BF700BD FOREIGN KEY (status_id) REFERENCES project_statuses (id) ON DELETE SET NULL');
|
||||||
|
$this->addSql('CREATE INDEX IDX_5C93B3A46BF700BD ON projects (status_id)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE projects DROP FOREIGN KEY FK_5C93B3A46BF700BD');
|
||||||
|
$this->addSql('DROP TABLE project_statuses');
|
||||||
|
$this->addSql('DROP INDEX IDX_5C93B3A46BF700BD ON projects');
|
||||||
|
$this->addSql('ALTER TABLE projects DROP status_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -24,7 +24,7 @@ class PermissionController extends AbstractController
|
|||||||
$permissions = [];
|
$permissions = [];
|
||||||
|
|
||||||
// Liste aller Module die geprüft werden sollen
|
// Liste aller Module die geprüft werden sollen
|
||||||
$modules = ['contacts', 'users', 'roles', 'settings'];
|
$modules = ['contacts', 'projects', 'project_statuses', 'users', 'roles', 'settings'];
|
||||||
|
|
||||||
foreach ($modules as $module) {
|
foreach ($modules as $module) {
|
||||||
$permissions[$module] = [
|
$permissions[$module] = [
|
||||||
|
|||||||
@ -59,11 +59,11 @@ class Contact implements ModuleAwareInterface
|
|||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
#[Groups(['contact:read'])]
|
#[Groups(['contact:read', 'project:read'])]
|
||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 255)]
|
#[ORM\Column(length: 255)]
|
||||||
#[Groups(['contact:read', 'contact:write'])]
|
#[Groups(['contact:read', 'contact:write', 'project:read'])]
|
||||||
#[Assert\NotBlank(message: 'Der Firmenname darf nicht leer sein')]
|
#[Assert\NotBlank(message: 'Der Firmenname darf nicht leer sein')]
|
||||||
#[Assert\Length(max: 255)]
|
#[Assert\Length(max: 255)]
|
||||||
private ?string $companyName = null;
|
private ?string $companyName = null;
|
||||||
|
|||||||
318
src/Entity/Project.php
Normal file
318
src/Entity/Project.php
Normal file
@ -0,0 +1,318 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\ApiFilter;
|
||||||
|
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
|
||||||
|
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||||
|
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
|
use ApiPlatform\Metadata\Post;
|
||||||
|
use ApiPlatform\Metadata\Put;
|
||||||
|
use ApiPlatform\Metadata\Delete;
|
||||||
|
use App\Entity\Interface\ModuleAwareInterface;
|
||||||
|
use App\Repository\ProjectRepository;
|
||||||
|
use Doctrine\DBAL\Types\Types;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Serializer\Annotation\Groups;
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: ProjectRepository::class)]
|
||||||
|
#[ORM\Table(name: 'projects')]
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new GetCollection(
|
||||||
|
security: "is_granted('VIEW', 'projects')",
|
||||||
|
stateless: false
|
||||||
|
),
|
||||||
|
new Get(
|
||||||
|
security: "is_granted('VIEW', object)",
|
||||||
|
stateless: false
|
||||||
|
),
|
||||||
|
new Post(
|
||||||
|
security: "is_granted('CREATE', 'projects')",
|
||||||
|
stateless: false
|
||||||
|
),
|
||||||
|
new Put(
|
||||||
|
security: "is_granted('EDIT', object)",
|
||||||
|
stateless: false
|
||||||
|
),
|
||||||
|
new Delete(
|
||||||
|
security: "is_granted('DELETE', object)",
|
||||||
|
stateless: false
|
||||||
|
)
|
||||||
|
],
|
||||||
|
paginationClientItemsPerPage: true,
|
||||||
|
paginationItemsPerPage: 30,
|
||||||
|
paginationMaximumItemsPerPage: 5000,
|
||||||
|
normalizationContext: ['groups' => ['project:read']],
|
||||||
|
denormalizationContext: ['groups' => ['project:write']],
|
||||||
|
order: ['startDate' => 'DESC']
|
||||||
|
)]
|
||||||
|
#[ApiFilter(BooleanFilter::class, properties: ['isPrivate'])]
|
||||||
|
#[ApiFilter(SearchFilter::class, properties: ['name' => 'partial', 'projectNumber' => 'partial', 'orderNumber' => 'partial', 'customer.companyName' => 'partial'])]
|
||||||
|
#[ApiFilter(DateFilter::class, properties: ['startDate', 'endDate', 'orderDate'])]
|
||||||
|
class Project implements ModuleAwareInterface
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
#[Groups(['project:read'])]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255)]
|
||||||
|
#[Groups(['project:read', 'project:write'])]
|
||||||
|
#[Assert\NotBlank(message: 'Der Projektname darf nicht leer sein')]
|
||||||
|
#[Assert\Length(max: 255)]
|
||||||
|
private ?string $name = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||||
|
#[Groups(['project:read', 'project:write'])]
|
||||||
|
private ?string $description = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Contact::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||||
|
#[Groups(['project:read', 'project:write'])]
|
||||||
|
private ?Contact $customer = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: ProjectStatus::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||||
|
#[Groups(['project:read', 'project:write'])]
|
||||||
|
private ?ProjectStatus $status = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 50, nullable: true)]
|
||||||
|
#[Groups(['project:read', 'project:write'])]
|
||||||
|
#[Assert\Length(max: 50)]
|
||||||
|
private ?string $projectNumber = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 50, nullable: true)]
|
||||||
|
#[Groups(['project:read', 'project:write'])]
|
||||||
|
#[Assert\Length(max: 50)]
|
||||||
|
private ?string $orderNumber = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)]
|
||||||
|
#[Groups(['project:read', 'project:write'])]
|
||||||
|
private ?\DateTimeImmutable $orderDate = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)]
|
||||||
|
#[Groups(['project:read', 'project:write'])]
|
||||||
|
private ?\DateTimeImmutable $startDate = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)]
|
||||||
|
#[Groups(['project:read', 'project:write'])]
|
||||||
|
private ?\DateTimeImmutable $endDate = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 2, nullable: true)]
|
||||||
|
#[Groups(['project:read', 'project:write'])]
|
||||||
|
#[Assert\PositiveOrZero(message: 'Das Budget muss positiv sein')]
|
||||||
|
private ?string $budget = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::DECIMAL, precision: 8, scale: 2, nullable: true)]
|
||||||
|
#[Groups(['project:read', 'project:write'])]
|
||||||
|
#[Assert\PositiveOrZero(message: 'Das Stundenkontingent muss positiv sein')]
|
||||||
|
private ?string $hourContingent = null;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
#[Groups(['project:read', 'project:write'])]
|
||||||
|
private bool $isPrivate = false;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
#[Groups(['project:read'])]
|
||||||
|
private ?\DateTimeImmutable $createdAt = null;
|
||||||
|
|
||||||
|
#[ORM\Column(nullable: true)]
|
||||||
|
#[Groups(['project:read'])]
|
||||||
|
private ?\DateTimeImmutable $updatedAt = null;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->createdAt = new \DateTimeImmutable();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): ?string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setName(string $name): static
|
||||||
|
{
|
||||||
|
$this->name = $name;
|
||||||
|
$this->updatedAt = new \DateTimeImmutable();
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDescription(): ?string
|
||||||
|
{
|
||||||
|
return $this->description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDescription(?string $description): static
|
||||||
|
{
|
||||||
|
$this->description = $description;
|
||||||
|
$this->updatedAt = new \DateTimeImmutable();
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCustomer(): ?Contact
|
||||||
|
{
|
||||||
|
return $this->customer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCustomer(?Contact $customer): static
|
||||||
|
{
|
||||||
|
$this->customer = $customer;
|
||||||
|
$this->updatedAt = new \DateTimeImmutable();
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStatus(): ?ProjectStatus
|
||||||
|
{
|
||||||
|
return $this->status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setStatus(?ProjectStatus $status): static
|
||||||
|
{
|
||||||
|
$this->status = $status;
|
||||||
|
$this->updatedAt = new \DateTimeImmutable();
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getProjectNumber(): ?string
|
||||||
|
{
|
||||||
|
return $this->projectNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setProjectNumber(?string $projectNumber): static
|
||||||
|
{
|
||||||
|
$this->projectNumber = $projectNumber;
|
||||||
|
$this->updatedAt = new \DateTimeImmutable();
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOrderNumber(): ?string
|
||||||
|
{
|
||||||
|
return $this->orderNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setOrderNumber(?string $orderNumber): static
|
||||||
|
{
|
||||||
|
$this->orderNumber = $orderNumber;
|
||||||
|
$this->updatedAt = new \DateTimeImmutable();
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOrderDate(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->orderDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setOrderDate(?\DateTimeImmutable $orderDate): static
|
||||||
|
{
|
||||||
|
$this->orderDate = $orderDate;
|
||||||
|
$this->updatedAt = new \DateTimeImmutable();
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStartDate(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->startDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setStartDate(?\DateTimeImmutable $startDate): static
|
||||||
|
{
|
||||||
|
$this->startDate = $startDate;
|
||||||
|
$this->updatedAt = new \DateTimeImmutable();
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEndDate(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->endDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEndDate(?\DateTimeImmutable $endDate): static
|
||||||
|
{
|
||||||
|
$this->endDate = $endDate;
|
||||||
|
$this->updatedAt = new \DateTimeImmutable();
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getBudget(): ?string
|
||||||
|
{
|
||||||
|
return $this->budget;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setBudget(?string $budget): static
|
||||||
|
{
|
||||||
|
$this->budget = $budget;
|
||||||
|
$this->updatedAt = new \DateTimeImmutable();
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHourContingent(): ?string
|
||||||
|
{
|
||||||
|
return $this->hourContingent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setHourContingent(?string $hourContingent): static
|
||||||
|
{
|
||||||
|
$this->hourContingent = $hourContingent;
|
||||||
|
$this->updatedAt = new \DateTimeImmutable();
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIsPrivate(): bool
|
||||||
|
{
|
||||||
|
return $this->isPrivate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setIsPrivate(bool $isPrivate): static
|
||||||
|
{
|
||||||
|
$this->isPrivate = $isPrivate;
|
||||||
|
$this->updatedAt = new \DateTimeImmutable();
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCreatedAt(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCreatedAt(\DateTimeImmutable $createdAt): static
|
||||||
|
{
|
||||||
|
$this->createdAt = $createdAt;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUpdatedAt(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setUpdatedAt(?\DateTimeImmutable $updatedAt): static
|
||||||
|
{
|
||||||
|
$this->updatedAt = $updatedAt;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
return $this->name ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the module code this entity belongs to.
|
||||||
|
* Required by ModuleVoter for permission checks.
|
||||||
|
*/
|
||||||
|
public function getModuleName(): string
|
||||||
|
{
|
||||||
|
return 'projects';
|
||||||
|
}
|
||||||
|
}
|
||||||
199
src/Entity/ProjectStatus.php
Normal file
199
src/Entity/ProjectStatus.php
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\ApiFilter;
|
||||||
|
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
|
||||||
|
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
|
use ApiPlatform\Metadata\Post;
|
||||||
|
use ApiPlatform\Metadata\Put;
|
||||||
|
use ApiPlatform\Metadata\Delete;
|
||||||
|
use App\Entity\Interface\ModuleAwareInterface;
|
||||||
|
use App\Repository\ProjectStatusRepository;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Serializer\Annotation\Groups;
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: ProjectStatusRepository::class)]
|
||||||
|
#[ORM\Table(name: 'project_statuses')]
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new GetCollection(
|
||||||
|
security: "is_granted('VIEW', 'project_statuses')",
|
||||||
|
stateless: false
|
||||||
|
),
|
||||||
|
new Get(
|
||||||
|
security: "is_granted('VIEW', object)",
|
||||||
|
stateless: false
|
||||||
|
),
|
||||||
|
new Post(
|
||||||
|
security: "is_granted('CREATE', 'project_statuses')",
|
||||||
|
stateless: false
|
||||||
|
),
|
||||||
|
new Put(
|
||||||
|
security: "is_granted('EDIT', object)",
|
||||||
|
stateless: false
|
||||||
|
),
|
||||||
|
new Delete(
|
||||||
|
security: "is_granted('DELETE', object)",
|
||||||
|
stateless: false
|
||||||
|
)
|
||||||
|
],
|
||||||
|
paginationClientItemsPerPage: true,
|
||||||
|
paginationItemsPerPage: 30,
|
||||||
|
paginationMaximumItemsPerPage: 5000,
|
||||||
|
normalizationContext: ['groups' => ['project_status:read']],
|
||||||
|
denormalizationContext: ['groups' => ['project_status:write']],
|
||||||
|
order: ['sortOrder' => 'ASC']
|
||||||
|
)]
|
||||||
|
#[ApiFilter(BooleanFilter::class, properties: ['isActive', 'isDefault'])]
|
||||||
|
#[ApiFilter(SearchFilter::class, properties: ['name' => 'partial'])]
|
||||||
|
class ProjectStatus implements ModuleAwareInterface
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
#[Groups(['project_status:read', 'project:read'])]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 100)]
|
||||||
|
#[Groups(['project_status:read', 'project_status:write', 'project:read'])]
|
||||||
|
#[Assert\NotBlank(message: 'Der Statusname darf nicht leer sein')]
|
||||||
|
#[Assert\Length(max: 100)]
|
||||||
|
private ?string $name = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 7, nullable: true)]
|
||||||
|
#[Groups(['project_status:read', 'project_status:write', 'project:read'])]
|
||||||
|
#[Assert\Length(max: 7)]
|
||||||
|
#[Assert\Regex(pattern: '/^#[0-9A-Fa-f]{6}$/', message: 'Die Farbe muss im Format #RRGGBB sein')]
|
||||||
|
private ?string $color = null;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
#[Groups(['project_status:read', 'project_status:write'])]
|
||||||
|
private int $sortOrder = 0;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
#[Groups(['project_status:read', 'project_status:write'])]
|
||||||
|
private bool $isDefault = false;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
#[Groups(['project_status:read', 'project_status:write'])]
|
||||||
|
private bool $isActive = true;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
#[Groups(['project_status:read'])]
|
||||||
|
private ?\DateTimeImmutable $createdAt = null;
|
||||||
|
|
||||||
|
#[ORM\Column(nullable: true)]
|
||||||
|
#[Groups(['project_status:read'])]
|
||||||
|
private ?\DateTimeImmutable $updatedAt = null;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->createdAt = new \DateTimeImmutable();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): ?string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setName(string $name): static
|
||||||
|
{
|
||||||
|
$this->name = $name;
|
||||||
|
$this->updatedAt = new \DateTimeImmutable();
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getColor(): ?string
|
||||||
|
{
|
||||||
|
return $this->color;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setColor(?string $color): static
|
||||||
|
{
|
||||||
|
$this->color = $color;
|
||||||
|
$this->updatedAt = new \DateTimeImmutable();
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSortOrder(): int
|
||||||
|
{
|
||||||
|
return $this->sortOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setSortOrder(int $sortOrder): static
|
||||||
|
{
|
||||||
|
$this->sortOrder = $sortOrder;
|
||||||
|
$this->updatedAt = new \DateTimeImmutable();
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIsDefault(): bool
|
||||||
|
{
|
||||||
|
return $this->isDefault;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setIsDefault(bool $isDefault): static
|
||||||
|
{
|
||||||
|
$this->isDefault = $isDefault;
|
||||||
|
$this->updatedAt = new \DateTimeImmutable();
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIsActive(): bool
|
||||||
|
{
|
||||||
|
return $this->isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setIsActive(bool $isActive): static
|
||||||
|
{
|
||||||
|
$this->isActive = $isActive;
|
||||||
|
$this->updatedAt = new \DateTimeImmutable();
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCreatedAt(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCreatedAt(\DateTimeImmutable $createdAt): static
|
||||||
|
{
|
||||||
|
$this->createdAt = $createdAt;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUpdatedAt(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setUpdatedAt(?\DateTimeImmutable $updatedAt): static
|
||||||
|
{
|
||||||
|
$this->updatedAt = $updatedAt;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
return $this->name ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the module code this entity belongs to.
|
||||||
|
* Required by ModuleVoter for permission checks.
|
||||||
|
*/
|
||||||
|
public function getModuleName(): string
|
||||||
|
{
|
||||||
|
return 'project_statuses';
|
||||||
|
}
|
||||||
|
}
|
||||||
57
src/Repository/ProjectRepository.php
Normal file
57
src/Repository/ProjectRepository.php
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\Project;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<Project>
|
||||||
|
*/
|
||||||
|
class ProjectRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, Project::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find projects by customer
|
||||||
|
*/
|
||||||
|
public function findByCustomer(int $customerId): array
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('p')
|
||||||
|
->andWhere('p.customer = :customerId')
|
||||||
|
->setParameter('customerId', $customerId)
|
||||||
|
->orderBy('p.startDate', 'DESC')
|
||||||
|
->getQuery()
|
||||||
|
->getResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find active projects (end date in future or null)
|
||||||
|
*/
|
||||||
|
public function findActiveProjects(): array
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('p')
|
||||||
|
->andWhere('p.endDate IS NULL OR p.endDate >= :today')
|
||||||
|
->setParameter('today', new \DateTimeImmutable())
|
||||||
|
->orderBy('p.startDate', 'DESC')
|
||||||
|
->getQuery()
|
||||||
|
->getResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find projects by private flag
|
||||||
|
*/
|
||||||
|
public function findByPrivateFlag(bool $isPrivate): array
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('p')
|
||||||
|
->andWhere('p.isPrivate = :isPrivate')
|
||||||
|
->setParameter('isPrivate', $isPrivate)
|
||||||
|
->orderBy('p.startDate', 'DESC')
|
||||||
|
->getQuery()
|
||||||
|
->getResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
45
src/Repository/ProjectStatusRepository.php
Normal file
45
src/Repository/ProjectStatusRepository.php
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\ProjectStatus;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<ProjectStatus>
|
||||||
|
*/
|
||||||
|
class ProjectStatusRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, ProjectStatus::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find default status
|
||||||
|
*/
|
||||||
|
public function findDefault(): ?ProjectStatus
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('ps')
|
||||||
|
->andWhere('ps.isDefault = :true')
|
||||||
|
->andWhere('ps.isActive = :true')
|
||||||
|
->setParameter('true', true)
|
||||||
|
->setMaxResults(1)
|
||||||
|
->getQuery()
|
||||||
|
->getOneOrNullResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all active statuses ordered by sortOrder
|
||||||
|
*/
|
||||||
|
public function findAllActive(): array
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('ps')
|
||||||
|
->andWhere('ps.isActive = :true')
|
||||||
|
->setParameter('true', true)
|
||||||
|
->orderBy('ps.sortOrder', 'ASC')
|
||||||
|
->getQuery()
|
||||||
|
->getResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user