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,
|
||||
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'
|
||||
|
||||
@ -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' }
|
||||
|
||||
@ -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 } },
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
ref="tableRef"
|
||||
title="Kontakte"
|
||||
entity-name="Kontakt"
|
||||
entity-name-article="einen"
|
||||
:columns="contactColumns"
|
||||
data-source="/api/contacts"
|
||||
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 = [];
|
||||
|
||||
// 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] = [
|
||||
|
||||
@ -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
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