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:
parent
7d6ef9f0eb
commit
a787019a3b
@ -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>
|
||||
|
||||
@ -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({
|
||||
|
||||
205
assets/js/views/InvoiceForm.vue
Normal file
205
assets/js/views/InvoiceForm.vue
Normal 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>
|
||||
281
assets/js/views/InvoiceManagement.vue
Normal file
281
assets/js/views/InvoiceManagement.vue
Normal 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>
|
||||
102
assets/js/views/PDFUploadForm.vue
Normal file
102
assets/js/views/PDFUploadForm.vue
Normal 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>
|
||||
121
assets/js/views/PaymentForm.vue
Normal file
121
assets/js/views/PaymentForm.vue
Normal 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>
|
||||
@ -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
39
composer.lock
generated
@ -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": {
|
||||
|
||||
@ -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],
|
||||
];
|
||||
|
||||
521
docs/PLUGIN_MENUS_AND_PERMISSIONS.md
Normal file
521
docs/PLUGIN_MENUS_AND_PERMISSIONS.md
Normal 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
|
||||
41
migrations/Version20251205095156.php
Normal file
41
migrations/Version20251205095156.php
Normal 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');
|
||||
}
|
||||
}
|
||||
135
src/Command/SyncPluginPermissionsCommand.php
Normal file
135
src/Command/SyncPluginPermissionsCommand.php
Normal 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);
|
||||
}
|
||||
}
|
||||
56
src/Controller/Api/PluginMenuController.php
Normal file
56
src/Controller/Api/PluginMenuController.php
Normal 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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
119
src/Service/MenuItemRegistry.php
Normal file
119
src/Service/MenuItemRegistry.php
Normal 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();
|
||||
}
|
||||
}
|
||||
187
src/Service/PermissionModuleSync.php
Normal file
187
src/Service/PermissionModuleSync.php
Normal 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';
|
||||
}
|
||||
}
|
||||
@ -73,6 +73,9 @@
|
||||
"config/packages/knpu_oauth2_client.yaml"
|
||||
]
|
||||
},
|
||||
"mycrm/billing-module": {
|
||||
"version": "dev-main"
|
||||
},
|
||||
"mycrm/test-module": {
|
||||
"version": "dev-main"
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user