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>
|
<script setup>
|
||||||
import { ref } from 'vue';
|
import { ref, onMounted } from 'vue';
|
||||||
import { useAuthStore } from '../stores/auth';
|
import { useAuthStore } from '../stores/auth';
|
||||||
import AppMenuItem from './AppMenuItem.vue';
|
import AppMenuItem from './AppMenuItem.vue';
|
||||||
|
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
const model = ref([
|
// Core-Menü (immer vorhanden)
|
||||||
|
const coreMenu = [
|
||||||
{
|
{
|
||||||
label: 'Home',
|
label: 'Home',
|
||||||
items: [{ label: 'Dashboard', icon: 'pi pi-fw pi-home', to: '/' }]
|
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: 'Projekte', icon: 'pi pi-fw pi-briefcase', to: '/projects' },
|
||||||
{ label: 'Tätigkeiten', icon: 'pi pi-fw pi-list-check', to: '/project-tasks' }
|
{ 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import ProjectStatusManagement from './views/ProjectStatusManagement.vue';
|
|||||||
import UserManagement from './views/UserManagement.vue';
|
import UserManagement from './views/UserManagement.vue';
|
||||||
import RoleManagement from './views/RoleManagement.vue';
|
import RoleManagement from './views/RoleManagement.vue';
|
||||||
import SettingsManagement from './views/SettingsManagement.vue';
|
import SettingsManagement from './views/SettingsManagement.vue';
|
||||||
|
import InvoiceManagement from './views/InvoiceManagement.vue';
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{ path: '/', name: 'dashboard', component: Dashboard },
|
{ path: '/', name: 'dashboard', component: Dashboard },
|
||||||
@ -17,6 +18,8 @@ const routes = [
|
|||||||
{ path: '/users', name: 'users', component: UserManagement, meta: { requiresAdmin: true } },
|
{ path: '/users', name: 'users', component: UserManagement, meta: { requiresAdmin: true } },
|
||||||
{ path: '/roles', name: 'roles', component: RoleManagement, meta: { requiresAdmin: true } },
|
{ path: '/roles', name: 'roles', component: RoleManagement, meta: { requiresAdmin: true } },
|
||||||
{ path: '/settings', name: 'settings', component: SettingsManagement, meta: { requiresAdmin: true } },
|
{ path: '/settings', name: 'settings', component: SettingsManagement, meta: { requiresAdmin: true } },
|
||||||
|
// Billing Module Routes
|
||||||
|
{ path: '/billing/invoices', name: 'invoices', component: InvoiceManagement, meta: { requiresPermission: { module: 'billing', action: 'view' } } },
|
||||||
];
|
];
|
||||||
|
|
||||||
const router = createRouter({
|
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",
|
"doctrine/orm": "^3.5",
|
||||||
"knpuniversity/oauth2-client-bundle": "*",
|
"knpuniversity/oauth2-client-bundle": "*",
|
||||||
"league/oauth2-client": "*",
|
"league/oauth2-client": "*",
|
||||||
|
"mycrm/billing-module": "@dev",
|
||||||
"mycrm/test-module": "*",
|
"mycrm/test-module": "*",
|
||||||
"nelmio/cors-bundle": "^2.6",
|
"nelmio/cors-bundle": "^2.6",
|
||||||
"phpdocumentor/reflection-docblock": "^5.6",
|
"phpdocumentor/reflection-docblock": "^5.6",
|
||||||
@ -118,6 +119,10 @@
|
|||||||
"mycrm-test-module": {
|
"mycrm-test-module": {
|
||||||
"type": "vcs",
|
"type": "vcs",
|
||||||
"url": "https://git.osdata-home.de/mycrm/mycrm-test-module"
|
"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",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "caa943b57e0e058e754382b31531775b",
|
"content-hash": "04f5bf6ec79dae707473e4ea7e9892b4",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "api-platform/core",
|
"name": "api-platform/core",
|
||||||
@ -2038,6 +2038,39 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-03-24T10:02:05+00:00"
|
"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",
|
"name": "mycrm/test-module",
|
||||||
"version": "v1.0.1",
|
"version": "v1.0.1",
|
||||||
@ -11047,7 +11080,9 @@
|
|||||||
],
|
],
|
||||||
"aliases": [],
|
"aliases": [],
|
||||||
"minimum-stability": "stable",
|
"minimum-stability": "stable",
|
||||||
"stability-flags": [],
|
"stability-flags": {
|
||||||
|
"mycrm/billing-module": 20
|
||||||
|
},
|
||||||
"prefer-stable": true,
|
"prefer-stable": true,
|
||||||
"prefer-lowest": false,
|
"prefer-lowest": false,
|
||||||
"platform": {
|
"platform": {
|
||||||
|
|||||||
@ -19,4 +19,5 @@ return [
|
|||||||
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
|
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
|
||||||
KnpU\OAuth2ClientBundle\KnpUOAuth2ClientBundle::class => ['all' => true],
|
KnpU\OAuth2ClientBundle\KnpUOAuth2ClientBundle::class => ['all' => true],
|
||||||
MyCRM\TestModule\TestModuleBundle::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>}
|
* @return array{success: bool, errors: array<string>}
|
||||||
*/
|
*/
|
||||||
public function canInstall(): array;
|
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"
|
"config/packages/knpu_oauth2_client.yaml"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"mycrm/billing-module": {
|
||||||
|
"version": "dev-main"
|
||||||
|
},
|
||||||
"mycrm/test-module": {
|
"mycrm/test-module": {
|
||||||
"version": "dev-main"
|
"version": "dev-main"
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user