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:
olli 2025-11-11 16:25:54 +01:00
parent 3e30d958b3
commit ab4d2bf9f5
14 changed files with 1748 additions and 4 deletions

View File

@ -193,6 +193,10 @@ const props = defineProps({
type: String,
default: ''
},
entityNameArticle: {
type: String,
default: ''
},
columns: {
type: Array,
required: true
@ -308,6 +312,11 @@ const exportItems = computed(() => [
// Computed create button label
const createLabel = computed(() => {
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 'Neu'

View File

@ -13,13 +13,15 @@ const model = ref([
{
label: 'CRM',
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',
visible: () => authStore.isAdmin,
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: 'Rollenverwaltung', icon: 'pi pi-fw pi-shield', to: '/roles' },
{ label: 'Einstellungen', icon: 'pi pi-fw pi-cog', to: '/settings' }

View File

@ -1,6 +1,8 @@
import { createRouter, createWebHistory } from 'vue-router';
import Dashboard from './views/Dashboard.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 RoleManagement from './views/RoleManagement.vue';
import SettingsManagement from './views/SettingsManagement.vue';
@ -8,6 +10,8 @@ import SettingsManagement from './views/SettingsManagement.vue';
const routes = [
{ path: '/', name: 'dashboard', component: Dashboard },
{ 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: '/roles', name: 'roles', component: RoleManagement, meta: { requiresAdmin: true } },
{ path: '/settings', name: 'settings', component: SettingsManagement, meta: { requiresAdmin: true } },

View File

@ -4,6 +4,7 @@
ref="tableRef"
title="Kontakte"
entity-name="Kontakt"
entity-name-article="einen"
:columns="contactColumns"
data-source="/api/contacts"
storage-key="contactTableColumns"

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

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

View 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');
}
}

View 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');
}
}

View File

@ -24,7 +24,7 @@ class PermissionController extends AbstractController
$permissions = [];
// 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) {
$permissions[$module] = [

View File

@ -59,11 +59,11 @@ class Contact implements ModuleAwareInterface
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['contact:read'])]
#[Groups(['contact:read', 'project:read'])]
private ?int $id = null;
#[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\Length(max: 255)]
private ?string $companyName = null;

318
src/Entity/Project.php Normal file
View 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';
}
}

View 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';
}
}

View 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();
}
}

View 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();
}
}