feat: Implement PDF upload functionality in PDFUploadForm.vue

- Added a file input for PDF uploads with validation for file type and size.
- Implemented file size formatting and user feedback for selected files.
- Created upload and cancel methods with placeholder for future API integration.

feat: Create PaymentForm.vue for handling payments

- Developed a form for entering payment details including date, amount, method, and notes.
- Integrated currency formatting and dropdown for payment methods.
- Implemented save and cancel actions with API call for saving payment data.

docs: Add documentation for dynamic plugin menus and permissions

- Provided guidelines for defining menu items and permissions in plugins.
- Explained the process for synchronizing permissions and integrating menus in the frontend.
- Included examples and best practices for plugin development.

feat: Add database migrations for invoices, invoice items, and payments

- Created migration scripts to define the database schema for invoices, invoice items, and payments.
- Established foreign key relationships between invoices and related entities.

feat: Implement command for synchronizing plugin permissions

- Developed a console command to synchronize plugin permissions with the database.
- Added options for dry-run and force synchronization for unlicensed modules.

feat: Create API controller for plugin menu items

- Implemented API endpoints to retrieve plugin menu items in both flat and grouped formats.
- Ensured access control with role-based permissions for API access.

feat: Develop service for managing plugin menu items

- Created a service to collect and manage menu items from installed plugins.
- Implemented methods for retrieving flat and grouped menu items for frontend use.

feat: Add service for synchronizing plugin permissions

- Developed a service to handle the synchronization of plugin permissions with the database.
- Included logic for creating and updating permission modules based on plugin definitions.
This commit is contained in:
olli 2025-12-05 11:13:41 +01:00
parent 7d6ef9f0eb
commit a787019a3b
17 changed files with 1904 additions and 15 deletions

View File

@ -1,11 +1,12 @@
<script setup>
import { ref } from 'vue';
import { ref, onMounted } from 'vue';
import { useAuthStore } from '../stores/auth';
import AppMenuItem from './AppMenuItem.vue';
const authStore = useAuthStore();
const model = ref([
// Core-Menü (immer vorhanden)
const coreMenu = [
{
label: 'Home',
items: [{ label: 'Dashboard', icon: 'pi pi-fw pi-home', to: '/' }]
@ -17,18 +18,66 @@ const model = ref([
{ label: 'Projekte', icon: 'pi pi-fw pi-briefcase', to: '/projects' },
{ label: 'Tätigkeiten', icon: 'pi pi-fw pi-list-check', to: '/project-tasks' }
]
},
{
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' }
]
}
]);
];
// Administration-Menü (immer vorhanden)
const adminMenu = {
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' }
]
};
// Dynamisches Menü (wird geladen)
const model = ref([...coreMenu]);
// Plugin-Menüs laden
const loadPluginMenus = async () => {
try {
const response = await fetch('/api/plugin-menu/grouped');
const data = await response.json();
if (data.success && data.data) {
// Plugin-Menüs als eigene Gruppen hinzufügen
Object.entries(data.data).forEach(([groupLabel, items]) => {
// Konvertiere zu PrimeVue Menü-Format
const menuGroup = {
label: groupLabel,
items: items.map(item => ({
label: item.label,
icon: item.icon ? `pi pi-fw ${item.icon}` : 'pi pi-fw pi-circle',
to: item.to,
...(item.items && { items: item.items }),
// Wenn Permission definiert, prüfen
...(item.permission && {
visible: () => authStore.hasPermission(item.permission)
})
}))
};
model.value.push(menuGroup);
});
}
// Administration-Menü am Ende hinzufügen
model.value.push(adminMenu);
} catch (error) {
console.error('Fehler beim Laden der Plugin-Menüs:', error);
// Fallback: Nur Core + Admin Menü
model.value = [...coreMenu, adminMenu];
}
};
// Beim Mount laden
onMounted(() => {
loadPluginMenus();
});
</script>
<template>

View File

@ -7,6 +7,7 @@ import ProjectStatusManagement from './views/ProjectStatusManagement.vue';
import UserManagement from './views/UserManagement.vue';
import RoleManagement from './views/RoleManagement.vue';
import SettingsManagement from './views/SettingsManagement.vue';
import InvoiceManagement from './views/InvoiceManagement.vue';
const routes = [
{ path: '/', name: 'dashboard', component: Dashboard },
@ -17,6 +18,8 @@ const routes = [
{ 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 } },
// Billing Module Routes
{ path: '/billing/invoices', name: 'invoices', component: InvoiceManagement, meta: { requiresPermission: { module: 'billing', action: 'view' } } },
];
const router = createRouter({

View File

@ -0,0 +1,205 @@
<template>
<div class="invoice-form">
<Message severity="info" v-if="!invoice">
Rechnungsformular (Phase 1 MVP - wird erweitert)
</Message>
<div class="grid p-fluid">
<!-- Rechnungsnummer -->
<div class="col-12 md:col-6">
<label for="invoiceNumber">Rechnungsnummer *</label>
<InputText id="invoiceNumber" v-model="form.invoiceNumber" required />
</div>
<!-- Status -->
<div class="col-12 md:col-6">
<label for="status">Status</label>
<Dropdown
id="status"
v-model="form.status"
:options="statusOptions"
option-label="label"
option-value="value"
/>
</div>
<!-- Kunde -->
<div class="col-12 md:col-6">
<label for="contact">Kunde *</label>
<Dropdown
id="contact"
v-model="form.contactId"
:options="contacts"
option-label="name"
option-value="id"
filter
placeholder="Kunde auswählen"
/>
</div>
<!-- Rechnungsdatum -->
<div class="col-12 md:col-6">
<label for="invoiceDate">Rechnungsdatum *</label>
<Calendar id="invoiceDate" v-model="form.invoiceDate" date-format="dd.mm.yy" />
</div>
<!-- Fälligkeitsdatum -->
<div class="col-12 md:col-6">
<label for="dueDate">Fälligkeitsdatum *</label>
<Calendar id="dueDate" v-model="form.dueDate" date-format="dd.mm.yy" />
</div>
<!-- Notizen -->
<div class="col-12">
<label for="notes">Notizen</label>
<Textarea id="notes" v-model="form.notes" rows="3" />
</div>
<!-- Positionen -->
<div class="col-12">
<h3>Rechnungspositionen</h3>
<DataTable :value="form.items" responsiveLayout="scroll">
<Column field="description" header="Beschreibung">
<template #body="slotProps">
<Textarea v-model="slotProps.data.description" rows="2" class="w-full" />
</template>
</Column>
<Column field="quantity" header="Menge">
<template #body="slotProps">
<InputNumber v-model="slotProps.data.quantity" :min-fraction-digits="2" class="w-full" />
</template>
</Column>
<Column field="unitPrice" header="Einzelpreis">
<template #body="slotProps">
<InputNumber v-model="slotProps.data.unitPrice" mode="currency" currency="EUR" locale="de-DE" class="w-full" />
</template>
</Column>
<Column field="taxRate" header="MwSt %">
<template #body="slotProps">
<Dropdown v-model="slotProps.data.taxRate" :options="taxRates" class="w-full" />
</template>
</Column>
<Column header="Aktionen">
<template #body="slotProps">
<Button icon="pi pi-trash" text severity="danger" @click="removeItem(slotProps.index)" />
</template>
</Column>
</DataTable>
<Button label="Position hinzufügen" icon="pi pi-plus" text @click="addItem" class="mt-2" />
</div>
</div>
<!-- Actions -->
<div class="flex gap-2 mt-4">
<Button label="Speichern" icon="pi pi-check" @click="save" />
<Button label="Abbrechen" icon="pi pi-times" severity="secondary" text @click="cancel" />
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import InputText from 'primevue/inputtext'
import Dropdown from 'primevue/dropdown'
import Calendar from 'primevue/calendar'
import Textarea from 'primevue/textarea'
import Button from 'primevue/button'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import InputNumber from 'primevue/inputnumber'
import Message from 'primevue/message'
const props = defineProps({
invoice: Object
})
const emit = defineEmits(['save', 'cancel'])
const form = ref({
invoiceNumber: '',
status: 'draft',
contactId: null,
invoiceDate: new Date(),
dueDate: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), // +14 Tage
notes: '',
items: []
})
const contacts = ref([])
const statusOptions = [
{ label: 'Entwurf', value: 'draft' },
{ label: 'Offen', value: 'open' },
{ label: 'Bezahlt', value: 'paid' },
{ label: 'Teilweise bezahlt', value: 'partial' },
{ label: 'Überfällig', value: 'overdue' },
{ label: 'Storniert', value: 'cancelled' }
]
const taxRates = ['0.00', '7.00', '19.00']
onMounted(async () => {
// Load contacts
const response = await fetch('/api/contacts')
const data = await response.json()
contacts.value = data['hydra:member'] || []
// Load existing invoice data
if (props.invoice) {
form.value = { ...props.invoice }
if (props.invoice.invoiceDate) {
form.value.invoiceDate = new Date(props.invoice.invoiceDate)
}
if (props.invoice.dueDate) {
form.value.dueDate = new Date(props.invoice.dueDate)
}
}
})
const addItem = () => {
form.value.items.push({
description: '',
quantity: 1.00,
unitPrice: 0.00,
taxRate: '19.00'
})
}
const removeItem = (index) => {
form.value.items.splice(index, 1)
}
const save = async () => {
const method = props.invoice ? 'PUT' : 'POST'
const url = props.invoice ? `/api/invoices/${props.invoice.id}` : '/api/invoices'
const payload = {
invoiceNumber: form.value.invoiceNumber,
contact: `/api/contacts/${form.value.contactId}`,
status: form.value.status,
invoiceDate: form.value.invoiceDate.toISOString().split('T')[0],
dueDate: form.value.dueDate.toISOString().split('T')[0],
notes: form.value.notes,
items: form.value.items
}
await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
emit('save', form.value)
}
const cancel = () => {
emit('cancel')
}
</script>
<style scoped>
.invoice-form {
padding: 1rem;
}
</style>

View File

@ -0,0 +1,281 @@
<template>
<div class="card">
<h2>Rechnungsverwaltung</h2>
<CrudDataTable
title="Rechnungen"
:api-endpoint="`/api/invoices`"
:columns="columns"
:show-create-button="canCreate"
create-button-label="Neue Rechnung"
@create="openCreateDialog"
@edit="openEditDialog"
@delete="deleteInvoice"
>
<template #body="{ item }">
<Column field="invoiceNumber" header="Rechnungsnummer" sortable>
<template #body="slotProps">
<router-link :to="`/billing/invoices/${slotProps.data.id}`" class="text-primary">
{{ slotProps.data.invoiceNumber }}
</router-link>
</template>
</Column>
<Column field="contact.name" header="Kunde" sortable>
<template #body="slotProps">
{{ slotProps.data.contact?.name || '-' }}
</template>
</Column>
<Column field="invoiceDate" header="Rechnungsdatum" sortable>
<template #body="slotProps">
{{ formatDate(slotProps.data.invoiceDate) }}
</template>
</Column>
<Column field="dueDate" header="Fälligkeitsdatum" sortable>
<template #body="slotProps">
{{ formatDate(slotProps.data.dueDate) }}
</template>
</Column>
<Column field="total" header="Gesamtbetrag" sortable>
<template #body="slotProps">
{{ formatCurrency(slotProps.data.total) }}
</template>
</Column>
<Column field="openAmount" header="Offen" sortable>
<template #body="slotProps">
{{ formatCurrency(slotProps.data.openAmount) }}
</template>
</Column>
<Column field="status" header="Status" sortable>
<template #body="slotProps">
<Tag :value="getStatusLabel(slotProps.data.status)" :severity="getStatusSeverity(slotProps.data.status)" />
</template>
</Column>
<Column :exportable="false" style="min-width:12rem">
<template #body="slotProps">
<div class="flex gap-2">
<Button
v-if="slotProps.data.pdfPath"
icon="pi pi-file-pdf"
outlined
severity="secondary"
@click="viewPDF(slotProps.data)"
v-tooltip.top="'PDF anzeigen'"
/>
<Button
v-else
icon="pi pi-upload"
outlined
severity="info"
@click="uploadPDF(slotProps.data)"
v-tooltip.top="'PDF hochladen'"
/>
<Button
icon="pi pi-money-bill"
outlined
severity="success"
@click="addPayment(slotProps.data)"
v-tooltip.top="'Zahlung hinzufügen'"
/>
<Button
icon="pi pi-pencil"
outlined
severity="info"
@click="openEditDialog(slotProps.data)"
v-tooltip.top="'Bearbeiten'"
/>
<Button
icon="pi pi-trash"
outlined
severity="danger"
@click="deleteInvoice(slotProps.data)"
v-tooltip.top="'Löschen'"
/>
</div>
</template>
</Column>
</template>
</CrudDataTable>
<!-- Create/Edit Dialog -->
<Dialog v-model:visible="dialogVisible" :header="dialogTitle" :modal="true" :style="{width: '70vw'}" :maximizable="true">
<InvoiceForm
v-if="dialogVisible"
:invoice="currentInvoice"
@save="saveInvoice"
@cancel="closeDialog"
/>
</Dialog>
<!-- Payment Dialog -->
<Dialog v-model:visible="paymentDialogVisible" header="Zahlung hinzufügen" :modal="true" :style="{width: '40vw'}">
<PaymentForm
v-if="paymentDialogVisible"
:invoice="currentInvoice"
@save="savePayment"
@cancel="paymentDialogVisible = false"
/>
</Dialog>
<!-- PDF Upload Dialog -->
<Dialog v-model:visible="pdfUploadDialogVisible" header="PDF hochladen" :modal="true" :style="{width: '40vw'}">
<PDFUploadForm
v-if="pdfUploadDialogVisible"
:invoice="currentInvoice"
@save="savePDFUpload"
@cancel="pdfUploadDialogVisible = false"
/>
</Dialog>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import CrudDataTable from '@/components/CrudDataTable.vue'
import Column from 'primevue/column'
import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import Tag from 'primevue/tag'
import InvoiceForm from './InvoiceForm.vue'
import PaymentForm from './PaymentForm.vue'
import PDFUploadForm from './PDFUploadForm.vue'
const router = useRouter()
const authStore = useAuthStore()
const dialogVisible = ref(false)
const dialogTitle = ref('')
const currentInvoice = ref(null)
const paymentDialogVisible = ref(false)
const pdfUploadDialogVisible = ref(false)
const canCreate = computed(() => authStore.hasPermission('billing', 'create'))
const columns = [
{ field: 'invoiceNumber', header: 'Rechnungsnummer' },
{ field: 'contact.name', header: 'Kunde' },
{ field: 'invoiceDate', header: 'Rechnungsdatum' },
{ field: 'dueDate', header: 'Fälligkeitsdatum' },
{ field: 'total', header: 'Gesamtbetrag' },
{ field: 'openAmount', header: 'Offen' },
{ field: 'status', header: 'Status' }
]
const openCreateDialog = () => {
currentInvoice.value = null
dialogTitle.value = 'Neue Rechnung'
dialogVisible.value = true
}
const openEditDialog = (invoice) => {
currentInvoice.value = invoice
dialogTitle.value = 'Rechnung bearbeiten'
dialogVisible.value = true
}
const closeDialog = () => {
dialogVisible.value = false
currentInvoice.value = null
}
const saveInvoice = async (invoice) => {
// Save logic handled by InvoiceForm
closeDialog()
// Refresh table
window.location.reload()
}
const deleteInvoice = async (invoice) => {
if (confirm(`Rechnung ${invoice.invoiceNumber} wirklich löschen?`)) {
await fetch(`/api/invoices/${invoice.id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
}
})
window.location.reload()
}
}
const addPayment = (invoice) => {
currentInvoice.value = invoice
paymentDialogVisible.value = true
}
const savePayment = async () => {
paymentDialogVisible.value = false
window.location.reload()
}
const uploadPDF = (invoice) => {
currentInvoice.value = invoice
pdfUploadDialogVisible.value = true
}
const savePDFUpload = async () => {
pdfUploadDialogVisible.value = false
window.location.reload()
}
const viewPDF = (invoice) => {
window.open(invoice.pdfPath, '_blank')
}
const formatDate = (dateString) => {
if (!dateString) return '-'
const date = new Date(dateString)
return date.toLocaleDateString('de-DE')
}
const formatCurrency = (amount) => {
if (!amount) return '0,00 €'
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR'
}).format(parseFloat(amount))
}
const getStatusLabel = (status) => {
const labels = {
draft: 'Entwurf',
open: 'Offen',
paid: 'Bezahlt',
partial: 'Teilweise bezahlt',
overdue: 'Überfällig',
cancelled: 'Storniert'
}
return labels[status] || status
}
const getStatusSeverity = (status) => {
const severities = {
draft: 'secondary',
open: 'info',
paid: 'success',
partial: 'warning',
overdue: 'danger',
cancelled: 'secondary'
}
return severities[status] || 'info'
}
</script>
<style scoped>
.text-primary {
color: var(--primary-color);
text-decoration: none;
font-weight: 600;
}
.text-primary:hover {
text-decoration: underline;
}
</style>

View File

@ -0,0 +1,102 @@
<template>
<div class="pdf-upload-form">
<Message severity="info">
PDF-Upload Funktionalität (Phase 1 MVP - wird in späteren Phasen implementiert)
</Message>
<div class="grid p-fluid mt-3">
<div class="col-12">
<p>
<strong>Rechnung:</strong> {{ invoice?.invoiceNumber }}
</p>
</div>
<!-- File Upload -->
<div class="col-12">
<label for="pdfFile">PDF-Datei hochladen</label>
<input
id="pdfFile"
type="file"
accept="application/pdf"
@change="handleFileChange"
class="p-inputtext p-component"
/>
<small class="text-muted">Nur PDF-Dateien erlaubt (max. 10 MB)</small>
</div>
<div class="col-12" v-if="selectedFile">
<Message severity="success">
Datei ausgewählt: {{ selectedFile.name }} ({{ formatFileSize(selectedFile.size) }})
</Message>
</div>
</div>
<!-- Actions -->
<div class="flex gap-2 mt-4">
<Button label="Hochladen" icon="pi pi-upload" @click="upload" :disabled="!selectedFile" />
<Button label="Abbrechen" icon="pi pi-times" severity="secondary" text @click="cancel" />
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import Button from 'primevue/button'
import Message from 'primevue/message'
const props = defineProps({
invoice: Object
})
const emit = defineEmits(['save', 'cancel'])
const selectedFile = ref(null)
const handleFileChange = (event) => {
const file = event.target.files[0]
if (file && file.type === 'application/pdf') {
selectedFile.value = file
} else {
alert('Bitte wählen Sie eine PDF-Datei aus.')
event.target.value = ''
}
}
const formatFileSize = (bytes) => {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB'
return (bytes / (1024 * 1024)).toFixed(2) + ' MB'
}
const upload = async () => {
if (!selectedFile.value) return
const formData = new FormData()
formData.append('pdf', selectedFile.value)
formData.append('invoiceId', props.invoice.id)
// TODO: Implement upload endpoint
// await fetch('/api/invoices/upload-pdf', {
// method: 'POST',
// body: formData
// })
alert('PDF-Upload wird in Phase 2 implementiert. Für Phase 1 MVP können Sie den Pfad manuell in der Datenbank setzen.')
emit('save')
}
const cancel = () => {
emit('cancel')
}
</script>
<style scoped>
.pdf-upload-form {
padding: 1rem;
}
.text-muted {
color: var(--text-color-secondary);
}
</style>

View File

@ -0,0 +1,121 @@
<template>
<div class="payment-form">
<div class="grid p-fluid">
<div class="col-12">
<p>
<strong>Rechnung:</strong> {{ invoice?.invoiceNumber }}<br />
<strong>Offener Betrag:</strong> {{ formatCurrency(invoice?.openAmount) }}
</p>
</div>
<!-- Zahlungsdatum -->
<div class="col-12">
<label for="paymentDate">Zahlungsdatum *</label>
<Calendar id="paymentDate" v-model="form.paymentDate" date-format="dd.mm.yy" />
</div>
<!-- Betrag -->
<div class="col-12">
<label for="amount">Betrag *</label>
<InputNumber
id="amount"
v-model="form.amount"
mode="currency"
currency="EUR"
locale="de-DE"
/>
</div>
<!-- Zahlungsart -->
<div class="col-12">
<label for="paymentMethod">Zahlungsart</label>
<Dropdown
id="paymentMethod"
v-model="form.paymentMethod"
:options="paymentMethods"
option-label="label"
option-value="value"
/>
</div>
<!-- Notizen -->
<div class="col-12">
<label for="notes">Notizen</label>
<Textarea id="notes" v-model="form.notes" rows="3" />
</div>
</div>
<!-- Actions -->
<div class="flex gap-2 mt-4">
<Button label="Speichern" icon="pi pi-check" @click="save" />
<Button label="Abbrechen" icon="pi pi-times" severity="secondary" text @click="cancel" />
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import InputNumber from 'primevue/inputnumber'
import Calendar from 'primevue/calendar'
import Dropdown from 'primevue/dropdown'
import Textarea from 'primevue/textarea'
import Button from 'primevue/button'
const props = defineProps({
invoice: Object
})
const emit = defineEmits(['save', 'cancel'])
const form = ref({
paymentDate: new Date(),
amount: parseFloat(props.invoice?.openAmount || 0),
paymentMethod: 'bank_transfer',
notes: ''
})
const paymentMethods = [
{ label: 'Überweisung', value: 'bank_transfer' },
{ label: 'Bar', value: 'cash' },
{ label: 'Karte', value: 'card' },
{ label: 'PayPal', value: 'paypal' },
{ label: 'SEPA-Lastschrift', value: 'sepa' },
{ label: 'Sonstiges', value: 'other' }
]
const formatCurrency = (amount) => {
if (!amount) return '0,00 €'
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR'
}).format(parseFloat(amount))
}
const save = async () => {
const payload = {
invoice: `/api/invoices/${props.invoice.id}`,
paymentDate: form.value.paymentDate.toISOString().split('T')[0],
amount: form.value.amount.toString(),
paymentMethod: form.value.paymentMethod,
notes: form.value.notes
}
await fetch('/api/payments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
emit('save')
}
const cancel = () => {
emit('cancel')
}
</script>
<style scoped>
.payment-form {
padding: 1rem;
}
</style>

View File

@ -15,6 +15,7 @@
"doctrine/orm": "^3.5",
"knpuniversity/oauth2-client-bundle": "*",
"league/oauth2-client": "*",
"mycrm/billing-module": "@dev",
"mycrm/test-module": "*",
"nelmio/cors-bundle": "^2.6",
"phpdocumentor/reflection-docblock": "^5.6",
@ -118,6 +119,10 @@
"mycrm-test-module": {
"type": "vcs",
"url": "https://git.osdata-home.de/mycrm/mycrm-test-module"
},
"mycrm-billing-module": {
"type": "path",
"url": "../mycrm-billing-module"
}
}
}

39
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "caa943b57e0e058e754382b31531775b",
"content-hash": "04f5bf6ec79dae707473e4ea7e9892b4",
"packages": [
{
"name": "api-platform/core",
@ -2038,6 +2038,39 @@
],
"time": "2025-03-24T10:02:05+00:00"
},
{
"name": "mycrm/billing-module",
"version": "dev-main",
"dist": {
"type": "path",
"url": "../mycrm-billing-module",
"reference": "3fc81714629410d172800eec9b1c58b3d40568ff"
},
"require": {
"api-platform/core": "^4.0",
"doctrine/orm": "^3.0",
"php": ">=8.2",
"symfony/framework-bundle": "^7.1"
},
"type": "symfony-bundle",
"extra": {
"symfony": {
"require": "7.1.*"
}
},
"autoload": {
"psr-4": {
"MyCRM\\BillingModule\\": "src/"
}
},
"license": [
"proprietary"
],
"description": "Ausgangsrechnungsverwaltung für myCRM",
"transport-options": {
"relative": true
}
},
{
"name": "mycrm/test-module",
"version": "v1.0.1",
@ -11047,7 +11080,9 @@
],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": [],
"stability-flags": {
"mycrm/billing-module": 20
},
"prefer-stable": true,
"prefer-lowest": false,
"platform": {

View File

@ -19,4 +19,5 @@ return [
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
KnpU\OAuth2ClientBundle\KnpUOAuth2ClientBundle::class => ['all' => true],
MyCRM\TestModule\TestModuleBundle::class => ['all' => true],
MyCRM\BillingModule\BillingModuleBundle::class => ['all' => true],
];

View File

@ -0,0 +1,521 @@
# Plugin-Menüs und Permissions
Anleitung zur dynamischen Registrierung von Menü-Items und Permissions durch Plugins.
## Übersicht
Das Plugin-System unterstützt jetzt:
**Dynamische Menü-Items** - Plugins können eigene Menüpunkte hinzufügen
**Automatische Permission-Synchronisation** - Modul-Permissions werden automatisch in der DB angelegt
**Permission-basierte Sichtbarkeit** - Menüpunkte erscheinen nur für berechtigte User
**Gruppierung** - Plugin-Menüs können gruppiert werden
---
## 1. Menü-Items im Plugin definieren
### In deiner Plugin-Klasse:
```php
public function getMenuItems(): array
{
return [
[
'label' => 'Rechnungen', // Menü-Titel
'icon' => 'pi-file-pdf', // PrimeIcons Icon (ohne pi pi-fw prefix)
'group' => 'Finanzen', // Gruppierung im Menü
'items' => [
[
'label' => 'Alle Rechnungen',
'icon' => 'pi-list',
'to' => '/billing/invoices', // Vue Router path
'permission' => 'billing.view' // Optional: Permission-Check
],
[
'label' => 'Neue Rechnung',
'icon' => 'pi-plus',
'to' => '/billing/invoices/create',
'permission' => 'billing.create'
],
[
'separator' => true // Optional: Trennlinie
],
[
'label' => 'Einstellungen',
'icon' => 'pi-cog',
'to' => '/billing/settings',
'permission' => 'billing.manage'
]
]
]
];
}
```
### Menü-Item Optionen:
| Feld | Typ | Required | Beschreibung |
|------|-----|----------|--------------|
| `label` | string | ✅ | Anzeigename des Menüpunkts |
| `icon` | string | ❌ | PrimeIcons Icon (ohne `pi pi-fw` prefix) |
| `to` | string | ❌ | Vue Router path |
| `url` | string | ❌ | Externe URL (alternativ zu `to`) |
| `items` | array | ❌ | Sub-Menüpunkte |
| `permission` | string | ❌ | Permission-String für Sichtbarkeits-Check |
| `group` | string | ❌ | Gruppierung im Hauptmenü |
| `separator` | bool | ❌ | Trennlinie (nur in Sub-Items) |
---
## 2. Permissions definieren
### In deiner Plugin-Klasse:
```php
public function getPermissionModules(): array
{
return ['billing', 'invoicing'];
}
```
Das System erstellt automatisch folgende Permissions:
- `billing.view`
- `billing.create`
- `billing.edit`
- `billing.delete`
- `billing.export`
- `billing.manage`
### Permission-Synchronisation durchführen:
```bash
# Alle Plugins synchronisieren
php bin/console app:plugin:sync-permissions
# Nur ein bestimmtes Plugin
php bin/console app:plugin:sync-permissions billing
# Dry-Run (zeigt nur was passieren würde)
php bin/console app:plugin:sync-permissions --dry-run
```
**Output:**
```
Plugin Permission-Module Synchronisation
========================================
Synchronisiere alle Plugins...
------------------------------
Aktion Anzahl
Neu erstellt 2
Aktualisiert 0
Übersprungen 5
```
---
## 3. Frontend Integration
### API-Endpoints:
#### GET /api/plugin-menu
Gibt alle Plugin-Menü-Items als flache Liste zurück.
**Response:**
```json
{
"success": true,
"data": [
{
"label": "Alle Rechnungen",
"icon": "pi-list",
"to": "/billing/invoices",
"permission": "billing.view",
"source": "billing"
}
],
"count": 1
}
```
#### GET /api/plugin-menu/grouped
Gibt gruppierte Plugin-Menü-Items zurück.
**Response:**
```json
{
"success": true,
"data": {
"Finanzen": [
{
"label": "Rechnungen",
"icon": "pi-file-pdf",
"items": [...]
}
]
},
"groups": ["Finanzen"]
}
```
### AppMenu.vue
Das Menü lädt automatisch Plugin-Menüs beim Mount:
```vue
<script setup>
import { ref, onMounted } from 'vue';
const model = ref([...coreMenu]);
const loadPluginMenus = async () => {
const response = await fetch('/api/plugin-menu/grouped');
const data = await response.json();
// Plugin-Menüs werden automatisch hinzugefügt
// Permission-Checks werden automatisch durchgeführt
};
onMounted(() => {
loadPluginMenus();
});
</script>
```
---
## 4. Permission-Checks im Frontend
### Mit Composition API:
```vue
<script setup>
import { useAuthStore } from '@/stores/auth';
const authStore = useAuthStore();
// Permission prüfen
const canViewInvoices = authStore.hasPermission('billing.view');
</script>
<template>
<Button v-if="canViewInvoices" label="Rechnung anzeigen" />
</template>
```
### Im Template:
```vue
<template>
<div v-if="authStore.hasPermission('billing.create')">
<Button label="Neue Rechnung" @click="createInvoice" />
</div>
</template>
```
---
## 5. Beispiel: Vollständiges Plugin
### BillingModulePlugin.php
```php
<?php
namespace MyCRM\BillingModule;
use App\Plugin\LicenseValidatorInterface;
use App\Plugin\ModulePluginInterface;
class BillingModulePlugin implements ModulePluginInterface
{
public function __construct(
private readonly LicenseValidatorInterface $licenseValidator
) {}
public function getIdentifier(): string
{
return 'billing';
}
public function getDisplayName(): string
{
return 'Rechnungsmodul';
}
public function getVersion(): string
{
return '1.0.0';
}
public function getDescription(): string
{
return 'Verwaltung von Rechnungen, Angeboten und Zahlungen';
}
public function isLicensed(): bool
{
return $this->licenseValidator->validate('billing')['valid'];
}
public function getLicenseInfo(): array
{
return $this->licenseValidator->validate('billing');
}
public function boot(): void
{
if (!$this->isLicensed()) {
throw new \RuntimeException('Billing Module nicht lizenziert');
}
// Services, Routes, etc. registrieren
}
public function getPermissionModules(): array
{
return ['billing', 'invoicing', 'payments'];
}
public function getMenuItems(): array
{
return [
[
'label' => 'Rechnungen',
'icon' => 'pi-file-pdf',
'group' => 'Finanzen',
'items' => [
[
'label' => 'Dashboard',
'icon' => 'pi-chart-line',
'to' => '/billing/dashboard',
'permission' => 'billing.view'
],
[
'label' => 'Alle Rechnungen',
'icon' => 'pi-list',
'to' => '/billing/invoices',
'permission' => 'billing.view'
],
[
'label' => 'Neue Rechnung',
'icon' => 'pi-plus',
'to' => '/billing/invoices/create',
'permission' => 'billing.create'
],
[
'separator' => true
],
[
'label' => 'Zahlungen',
'icon' => 'pi-money-bill',
'to' => '/billing/payments',
'permission' => 'payments.view'
],
[
'label' => 'Einstellungen',
'icon' => 'pi-cog',
'to' => '/billing/settings',
'permission' => 'billing.manage'
]
]
]
];
}
public function canInstall(): array
{
$errors = [];
if (PHP_VERSION_ID < 80200) {
$errors[] = 'PHP 8.2+ erforderlich';
}
return [
'success' => empty($errors),
'errors' => $errors
];
}
}
```
---
## 6. Workflow: Neues Plugin mit Menüs
### Schritt 1: Plugin erstellen
```bash
cd ../mycrm-billing-module
composer init
# Name: mycrm/billing-module
```
### Schritt 2: Plugin-Klasse implementieren
Siehe Beispiel oben - implementiere `getMenuItems()` und `getPermissionModules()`.
### Schritt 3: In myCRM installieren
```bash
cd ../myCRM
composer config repositories.mycrm-billing-module path ../mycrm-billing-module
composer require mycrm/billing-module:@dev
```
### Schritt 4: Permissions synchronisieren
```bash
php bin/console app:plugin:sync-permissions billing
```
**Output:**
```
Neu erstellt: 3 (billing, invoicing, payments)
```
### Schritt 5: Cache leeren
```bash
php bin/console cache:clear
```
### Schritt 6: Lizenz aktivieren
```bash
php bin/console app:module:license billing YOUR_GITEA_TOKEN
```
### Schritt 7: Testen
1. Frontend öffnen
2. Plugin-Menü erscheint unter "Finanzen"
3. Menüpunkte sind nur sichtbar mit Berechtigung
---
## 7. Debugging
### Menü-Items überprüfen:
```bash
curl http://localhost:8000/api/plugin-menu | jq
```
### Permissions in DB überprüfen:
```sql
SELECT * FROM modules WHERE code IN ('billing', 'invoicing', 'payments');
```
### Plugin-Status:
```bash
php bin/console app:module:list
```
### Logs:
```bash
tail -f var/log/dev.log | grep -E "(Plugin|Menu|Permission)"
```
---
## 8. Best Practices
### ✅ DO's:
- **Gruppiere verwandte Menüs** - Nutze `group` für bessere Organisation
- **Permission-Checks** - Definiere `permission` für alle sensiblen Menüpunkte
- **Icons verwenden** - Macht das Menü übersichtlicher
- **Synchronisiere nach Installation** - `app:plugin:sync-permissions` nach jedem Plugin-Update
### ❌ DON'Ts:
- **Keine Core-Gruppen überschreiben** - "Home", "CRM", "Administration" sind reserviert
- **Keine tiefen Verschachtelungen** - Max. 2 Ebenen (Gruppe → Items)
- **Keine externen URLs ohne Warnung** - User erwarten interne Navigation
---
## 9. Troubleshooting
### Problem: Menü erscheint nicht
**Lösung:**
```bash
# Cache leeren
php bin/console cache:clear
# Plugin-Status prüfen
php bin/console app:module:list
# API testen
curl http://localhost:8000/api/plugin-menu/grouped
# Browser-Console überprüfen
```
### Problem: Permissions fehlen
**Lösung:**
```bash
# Permissions synchronisieren
php bin/console app:plugin:sync-permissions
# DB überprüfen
SELECT * FROM modules WHERE code = 'billing';
# Rolle zuweisen
# In der Rollenverwaltung das neue Modul auswählen
```
### Problem: Permission-Checks funktionieren nicht
**Lösung:**
- Prüfe ob User die Rolle hat
- Prüfe ob Rolle die Permission hat
- Prüfe `authStore.hasPermission()` Implementierung
---
## 10. Migration bestehender Plugins
Wenn du bereits Plugins hast, füge einfach `getMenuItems()` hinzu:
```php
// Altes Plugin
class MyPlugin implements ModulePluginInterface {
// ... bestehende Methoden ...
}
// Neu: Füge hinzu
class MyPlugin implements ModulePluginInterface {
// ... bestehende Methoden ...
public function getMenuItems(): array
{
return [
// Deine Menüs hier
];
}
}
```
Dann:
```bash
composer dump-autoload
php bin/console cache:clear
```
---
## Support
Bei Fragen siehe:
- `PLUGIN_SYSTEM.md` - Allgemeine Plugin-Architektur
- `GITEA_LICENSE_SYSTEM.md` - Lizenzierung
- `docs/PERMISSIONS.md` - Permission-System

View File

@ -0,0 +1,41 @@
<?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 Version20251205095156 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 invoice_items (id INT AUTO_INCREMENT NOT NULL, invoice_id INT NOT NULL, description LONGTEXT NOT NULL, quantity NUMERIC(10, 2) NOT NULL, unit_price NUMERIC(10, 2) NOT NULL, tax_rate NUMERIC(5, 2) NOT NULL, subtotal NUMERIC(10, 2) NOT NULL, tax_amount NUMERIC(10, 2) NOT NULL, total NUMERIC(10, 2) NOT NULL, INDEX IDX_DCC4B9F82989F1FD (invoice_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE invoices (id INT AUTO_INCREMENT NOT NULL, contact_id INT NOT NULL, invoice_number VARCHAR(50) NOT NULL, status VARCHAR(20) NOT NULL, invoice_date DATE NOT NULL, due_date DATE NOT NULL, subtotal NUMERIC(10, 2) NOT NULL, tax_total NUMERIC(10, 2) NOT NULL, total NUMERIC(10, 2) NOT NULL, paid_amount NUMERIC(10, 2) NOT NULL, pdf_path VARCHAR(255) DEFAULT NULL, notes LONGTEXT DEFAULT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, UNIQUE INDEX UNIQ_6A2F2F952DA68207 (invoice_number), INDEX IDX_6A2F2F95E7A1254A (contact_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE payments (id INT AUTO_INCREMENT NOT NULL, invoice_id INT NOT NULL, payment_date DATE NOT NULL, amount NUMERIC(10, 2) NOT NULL, payment_method VARCHAR(50) NOT NULL, notes LONGTEXT DEFAULT NULL, created_at DATETIME NOT NULL, INDEX IDX_65D29B322989F1FD (invoice_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('ALTER TABLE invoice_items ADD CONSTRAINT FK_DCC4B9F82989F1FD FOREIGN KEY (invoice_id) REFERENCES invoices (id)');
$this->addSql('ALTER TABLE invoices ADD CONSTRAINT FK_6A2F2F95E7A1254A FOREIGN KEY (contact_id) REFERENCES contacts (id)');
$this->addSql('ALTER TABLE payments ADD CONSTRAINT FK_65D29B322989F1FD FOREIGN KEY (invoice_id) REFERENCES invoices (id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE invoice_items DROP FOREIGN KEY FK_DCC4B9F82989F1FD');
$this->addSql('ALTER TABLE invoices DROP FOREIGN KEY FK_6A2F2F95E7A1254A');
$this->addSql('ALTER TABLE payments DROP FOREIGN KEY FK_65D29B322989F1FD');
$this->addSql('DROP TABLE invoice_items');
$this->addSql('DROP TABLE invoices');
$this->addSql('DROP TABLE payments');
}
}

View File

@ -0,0 +1,135 @@
<?php
namespace App\Command;
use App\Service\PermissionModuleSync;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Command zur Synchronisation von Plugin-Permissions
*/
#[AsCommand(
name: 'app:plugin:sync-permissions',
description: 'Synchronisiert Plugin-Permission-Module mit der Datenbank',
)]
class SyncPluginPermissionsCommand extends Command
{
public function __construct(
private readonly PermissionModuleSync $permissionSync
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('plugin', InputArgument::OPTIONAL, 'Plugin-Identifier (optional - sync alle wenn leer)')
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Zeigt Änderungen an ohne sie durchzuführen')
->addOption('force', 'f', InputOption::VALUE_NONE, 'Synchronisiert auch unlizenzierte Module (für Development)')
->setHelp(<<<'HELP'
Synchronisiert die Permission-Module von Plugins mit der Datenbank.
Für jedes lizenzierte Plugin werden die von getPermissionModules() zurückgegebenen
Module automatisch in der Module-Tabelle angelegt, falls sie noch nicht existieren.
Beispiele:
# Alle Plugins synchronisieren
php bin/console app:plugin:sync-permissions
# Nur ein bestimmtes Plugin
php bin/console app:plugin:sync-permissions test
# Dry-Run (zeigt nur was passieren würde)
php bin/console app:plugin:sync-permissions --dry-run
Nach der Synchronisation:
- Neue Module sind verfügbar im Permission-System
- Admins können Permissions für diese Module zuweisen
- Module erscheinen in der Rollenverwaltung
HELP
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$pluginId = $input->getArgument('plugin');
$dryRun = $input->getOption('dry-run');
$force = $input->getOption('force');
if ($dryRun) {
$io->warning('DRY-RUN Modus - Keine Änderungen werden gespeichert!');
}
if ($force) {
$io->note('FORCE Modus - Auch unlizenzierte Module werden synchronisiert!');
}
$io->title('Plugin Permission-Module Synchronisation');
if ($pluginId) {
$io->section(sprintf('Synchronisiere Plugin: %s', $pluginId));
try {
$stats = $this->permissionSync->syncPlugin($pluginId, $force);
$this->displayStats($io, $stats);
$io->success(sprintf('Plugin "%s" erfolgreich synchronisiert!', $pluginId));
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error(sprintf('Fehler: %s', $e->getMessage()));
return Command::FAILURE;
}
}
// Alle Plugins synchronisieren
$io->section('Synchronisiere alle Plugins...');
$stats = $this->permissionSync->syncAll($force);
$this->displayStats($io, $stats);
if (!empty($stats['errors'])) {
$io->warning('Es gab Fehler bei folgenden Plugins:');
$io->listing($stats['errors']);
}
if ($stats['created'] === 0 && $stats['updated'] === 0) {
$io->info('Keine Änderungen notwendig - alle Module sind bereits synchron.');
return Command::SUCCESS;
}
$io->success('Synchronisation abgeschlossen!');
$io->section('Nächste Schritte');
$io->listing([
'Rollenverwaltung: Neue Module erscheinen in den Permissions',
'Benutzerverwaltung: Admins können jetzt Permissions zuweisen',
'Cache leeren (falls nötig): php bin/console cache:clear',
]);
return Command::SUCCESS;
}
private function displayStats(SymfonyStyle $io, array $stats): void
{
$rows = [
['Neu erstellt', $stats['created']],
['Aktualisiert', $stats['updated']],
['Übersprungen', $stats['skipped']],
];
if (isset($stats['errors']) && !empty($stats['errors'])) {
$rows[] = ['Fehler', count($stats['errors'])];
}
$io->table(['Aktion', 'Anzahl'], $rows);
}
}

View File

@ -0,0 +1,56 @@
<?php
namespace App\Controller\Api;
use App\Service\MenuItemRegistry;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* API-Endpoint für Plugin-Menü-Items
*/
#[Route('/api/plugin-menu', name: 'api_plugin_menu_')]
#[IsGranted('ROLE_USER')]
class PluginMenuController extends AbstractController
{
public function __construct(
private readonly MenuItemRegistry $menuRegistry
) {
}
/**
* Gibt alle Plugin-Menü-Items zurück
*
* GET /api/plugin-menu
*/
#[Route('', name: 'get_all', methods: ['GET'])]
public function getAll(): JsonResponse
{
$menuItems = $this->menuRegistry->getFlatMenuItems();
return $this->json([
'success' => true,
'data' => $menuItems,
'count' => count($menuItems),
]);
}
/**
* Gibt gruppierte Plugin-Menü-Items zurück
*
* GET /api/plugin-menu/grouped
*/
#[Route('/grouped', name: 'get_grouped', methods: ['GET'])]
public function getGrouped(): JsonResponse
{
$grouped = $this->menuRegistry->getGroupedMenuItems();
return $this->json([
'success' => true,
'data' => $grouped,
'groups' => array_keys($grouped),
]);
}
}

View File

@ -74,4 +74,29 @@ interface ModulePluginInterface
* @return array{success: bool, errors: array<string>}
*/
public function canInstall(): array;
/**
* Gibt Menü-Items zurück, die in der Haupt-Navigation angezeigt werden sollen
*
* @return array<array{
* label: string,
* icon?: string,
* to?: string,
* url?: string,
* items?: array,
* permission?: string,
* separator?: bool
* }>
*
* Beispiel:
* [
* 'label' => 'Rechnungen',
* 'icon' => 'pi pi-fw pi-file',
* 'items' => [
* ['label' => 'Alle Rechnungen', 'to' => '/billing/invoices', 'permission' => 'billing.view'],
* ['label' => 'Neue Rechnung', 'to' => '/billing/invoices/create', 'permission' => 'billing.create'],
* ]
* ]
*/
public function getMenuItems(): array;
}

View File

@ -0,0 +1,119 @@
<?php
namespace App\Service;
use App\Plugin\ModuleRegistry;
use Psr\Log\LoggerInterface;
/**
* Registry für Plugin-Menü-Items
*
* Sammelt alle Menü-Items von installierten und lizenzierten Plugins
*/
class MenuItemRegistry
{
public function __construct(
private readonly ModuleRegistry $moduleRegistry,
private readonly LoggerInterface $logger
) {
}
/**
* Gibt alle Menü-Items von lizenzierten Plugins zurück
*
* @return array<array{
* label: string,
* icon?: string,
* to?: string,
* items?: array,
* permission?: string,
* separator?: bool,
* source: string
* }>
*/
public function getPluginMenuItems(): array
{
$menuItems = [];
foreach ($this->moduleRegistry->getAllModules() as $plugin) {
// Nur lizenzierte Module berücksichtigen
if (!$plugin->isLicensed()) {
$this->logger->debug(sprintf(
'Plugin "%s" übersprungen: Nicht lizenziert',
$plugin->getIdentifier()
));
continue;
}
try {
$pluginMenuItems = $plugin->getMenuItems();
foreach ($pluginMenuItems as $item) {
// Source hinzufügen für Debugging
$item['source'] = $plugin->getIdentifier();
$menuItems[] = $item;
}
$this->logger->debug(sprintf(
'Plugin "%s": %d Menü-Items geladen',
$plugin->getIdentifier(),
count($pluginMenuItems)
));
} catch (\Throwable $e) {
$this->logger->error(sprintf(
'Fehler beim Laden der Menü-Items von Plugin "%s": %s',
$plugin->getIdentifier(),
$e->getMessage()
));
}
}
return $menuItems;
}
/**
* Gibt alle Menü-Items gruppiert nach Kategorien zurück
*
* @return array<string, array> z.B. ['CRM' => [...], 'Finanzen' => [...]]
*/
public function getGroupedMenuItems(): array
{
$items = $this->getPluginMenuItems();
$grouped = [];
foreach ($items as $item) {
// Wenn Item eine Gruppe hat
if (isset($item['group'])) {
$group = $item['group'];
unset($item['group']);
if (!isset($grouped[$group])) {
$grouped[$group] = [];
}
$grouped[$group][] = $item;
} else {
// Fallback: Nach Plugin-Namen gruppieren
$pluginId = $item['source'] ?? 'unknown';
if (!isset($grouped[$pluginId])) {
$grouped[$pluginId] = [];
}
$grouped[$pluginId][] = $item;
}
}
return $grouped;
}
/**
* Gibt Menü-Items als flache Liste zurück
* (Nützlich für Frontend ohne Gruppierung)
*
* @return array
*/
public function getFlatMenuItems(): array
{
return $this->getPluginMenuItems();
}
}

View File

@ -0,0 +1,187 @@
<?php
namespace App\Service;
use App\Entity\Module;
use App\Plugin\ModuleRegistry;
use App\Repository\ModuleRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
/**
* Service zur Synchronisation von Plugin-Permissions mit der Datenbank
*
* Liest alle Permission-Module von Plugins und legt sie in der DB an,
* falls sie noch nicht existieren.
*/
class PermissionModuleSync
{
public function __construct(
private readonly ModuleRegistry $moduleRegistry,
private readonly ModuleRepository $moduleRepository,
private readonly EntityManagerInterface $em,
private readonly LoggerInterface $logger
) {
}
/**
* Synchronisiert alle Plugin-Module mit der Datenbank
*
* @param bool $force Synchronisiert auch unlizenzierte Module (für Development)
* @return array{created: int, updated: int, skipped: int, errors: array<string>}
*/
public function syncAll(bool $force = false): array
{
$stats = [
'created' => 0,
'updated' => 0,
'skipped' => 0,
'errors' => [],
];
foreach ($this->moduleRegistry->getAllModules() as $plugin) {
try {
$result = $this->syncPlugin($plugin->getIdentifier(), $force);
$stats['created'] += $result['created'];
$stats['updated'] += $result['updated'];
$stats['skipped'] += $result['skipped'];
} catch (\Throwable $e) {
$error = sprintf(
'Plugin "%s": %s',
$plugin->getIdentifier(),
$e->getMessage()
);
$stats['errors'][] = $error;
$this->logger->error($error);
}
}
return $stats;
}
/**
* Synchronisiert die Module eines bestimmten Plugins
*
* @param string $pluginIdentifier
* @param bool $force Synchronisiert auch unlizenzierte Module (für Development)
* @return array{created: int, updated: int, skipped: int}
*/
public function syncPlugin(string $pluginIdentifier, bool $force = false): array
{
$stats = ['created' => 0, 'updated' => 0, 'skipped' => 0];
$plugin = $this->moduleRegistry->getModule($pluginIdentifier);
if (!$plugin) {
throw new \RuntimeException(sprintf('Plugin "%s" nicht gefunden', $pluginIdentifier));
}
// Nur lizenzierte Module synchronisieren (außer force)
if (!$force && !$plugin->isLicensed()) {
$this->logger->info(sprintf(
'Plugin "%s" übersprungen: Nicht lizenziert',
$pluginIdentifier
));
return $stats;
}
$permissionModules = $plugin->getPermissionModules();
foreach ($permissionModules as $moduleCode) {
$result = $this->syncModule(
$moduleCode,
$plugin->getDisplayName(),
$plugin->getDescription()
);
$stats[$result]++;
}
$this->em->flush();
return $stats;
}
/**
* Synchronisiert ein einzelnes Modul
*
* @param string $code Modul-Code (z.B. 'billing', 'invoicing')
* @param string $displayName Display-Name des Plugins
* @param string $description Plugin-Beschreibung
* @return string 'created', 'updated' oder 'skipped'
*/
private function syncModule(string $code, string $displayName, string $description): string
{
$existingModule = $this->moduleRepository->findOneBy(['code' => $code]);
if ($existingModule) {
// Modul existiert bereits - Update nur wenn nötig
$updated = false;
if ($existingModule->getDescription() !== $description) {
$existingModule->setDescription($description);
$updated = true;
}
if ($updated) {
$this->logger->info(sprintf('Modul "%s" aktualisiert', $code));
return 'updated';
}
return 'skipped';
}
// Neues Modul anlegen
$module = new Module();
$module->setCode($code);
// Name: Plugin-Name + Code (falls unterschiedlich)
$moduleName = $displayName;
if (strcasecmp($code, $displayName) !== 0) {
$moduleName = sprintf('%s (%s)', $displayName, ucfirst($code));
}
$module->setName($moduleName);
$module->setDescription($description);
$module->setIsActive(true);
$module->setSortOrder($this->getNextSortOrder());
// Icon aus Code ableiten (optional)
$icon = $this->guessIcon($code);
if ($icon) {
$module->setIcon($icon);
}
$this->em->persist($module);
$this->logger->info(sprintf('Modul "%s" erstellt', $code));
return 'created';
}
/**
* Ermittelt die nächste Sort-Order
*/
private function getNextSortOrder(): int
{
$maxSortOrder = $this->em->createQuery(
'SELECT MAX(m.sortOrder) FROM App\Entity\Module m'
)->getSingleScalarResult();
return ($maxSortOrder ?? 0) + 10;
}
/**
* Versucht, ein passendes Icon zu raten
*/
private function guessIcon(string $code): ?string
{
$iconMap = [
'billing' => 'pi-file-pdf',
'invoicing' => 'pi-receipt',
'inventory' => 'pi-box',
'reporting' => 'pi-chart-bar',
'crm' => 'pi-users',
'test' => 'pi-code',
];
return $iconMap[$code] ?? 'pi-circle';
}
}

View File

@ -73,6 +73,9 @@
"config/packages/knpu_oauth2_client.yaml"
]
},
"mycrm/billing-module": {
"version": "dev-main"
},
"mycrm/test-module": {
"version": "dev-main"
},