diff --git a/assets/js/layout/AppMenu.vue b/assets/js/layout/AppMenu.vue index a793775..ad67c5f 100644 --- a/assets/js/layout/AppMenu.vue +++ b/assets/js/layout/AppMenu.vue @@ -1,11 +1,12 @@ diff --git a/assets/js/router.js b/assets/js/router.js index 9c1bea6..cdb380b 100644 --- a/assets/js/router.js +++ b/assets/js/router.js @@ -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({ diff --git a/assets/js/views/InvoiceForm.vue b/assets/js/views/InvoiceForm.vue new file mode 100644 index 0000000..5c5c689 --- /dev/null +++ b/assets/js/views/InvoiceForm.vue @@ -0,0 +1,205 @@ + + + + Rechnungsformular (Phase 1 MVP - wird erweitert) + + + + + + Rechnungsnummer * + + + + + + Status + + + + + + Kunde * + + + + + + Rechnungsdatum * + + + + + + Fälligkeitsdatum * + + + + + + Notizen + + + + + + Rechnungspositionen + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/js/views/InvoiceManagement.vue b/assets/js/views/InvoiceManagement.vue new file mode 100644 index 0000000..d94ed99 --- /dev/null +++ b/assets/js/views/InvoiceManagement.vue @@ -0,0 +1,281 @@ + + + Rechnungsverwaltung + + + + + + + {{ slotProps.data.invoiceNumber }} + + + + + + + {{ slotProps.data.contact?.name || '-' }} + + + + + + {{ formatDate(slotProps.data.invoiceDate) }} + + + + + + {{ formatDate(slotProps.data.dueDate) }} + + + + + + {{ formatCurrency(slotProps.data.total) }} + + + + + + {{ formatCurrency(slotProps.data.openAmount) }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/js/views/PDFUploadForm.vue b/assets/js/views/PDFUploadForm.vue new file mode 100644 index 0000000..cdfe327 --- /dev/null +++ b/assets/js/views/PDFUploadForm.vue @@ -0,0 +1,102 @@ + + + + PDF-Upload Funktionalität (Phase 1 MVP - wird in späteren Phasen implementiert) + + + + + + Rechnung: {{ invoice?.invoiceNumber }} + + + + + + PDF-Datei hochladen + + Nur PDF-Dateien erlaubt (max. 10 MB) + + + + + Datei ausgewählt: {{ selectedFile.name }} ({{ formatFileSize(selectedFile.size) }}) + + + + + + + + + + + + + + + diff --git a/assets/js/views/PaymentForm.vue b/assets/js/views/PaymentForm.vue new file mode 100644 index 0000000..446d697 --- /dev/null +++ b/assets/js/views/PaymentForm.vue @@ -0,0 +1,121 @@ + + + + + + Rechnung: {{ invoice?.invoiceNumber }} + Offener Betrag: {{ formatCurrency(invoice?.openAmount) }} + + + + + + Zahlungsdatum * + + + + + + Betrag * + + + + + + Zahlungsart + + + + + + Notizen + + + + + + + + + + + + + + + diff --git a/composer.json b/composer.json index 331069e..89a3603 100644 --- a/composer.json +++ b/composer.json @@ -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" } } } diff --git a/composer.lock b/composer.lock index 96afb53..60f4496 100644 --- a/composer.lock +++ b/composer.lock @@ -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": { diff --git a/config/bundles.php b/config/bundles.php index d6b4866..72f5e4a 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -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], ]; diff --git a/docs/PLUGIN_MENUS_AND_PERMISSIONS.md b/docs/PLUGIN_MENUS_AND_PERMISSIONS.md new file mode 100644 index 0000000..3a2e177 --- /dev/null +++ b/docs/PLUGIN_MENUS_AND_PERMISSIONS.md @@ -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 + +``` + +--- + +## 4. Permission-Checks im Frontend + +### Mit Composition API: + +```vue + + + + + +``` + +### Im Template: + +```vue + + + + + +``` + +--- + +## 5. Beispiel: Vollständiges Plugin + +### BillingModulePlugin.php + +```php +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 diff --git a/migrations/Version20251205095156.php b/migrations/Version20251205095156.php new file mode 100644 index 0000000..1aa89d0 --- /dev/null +++ b/migrations/Version20251205095156.php @@ -0,0 +1,41 @@ +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'); + } +} diff --git a/src/Command/SyncPluginPermissionsCommand.php b/src/Command/SyncPluginPermissionsCommand.php new file mode 100644 index 0000000..ce3bf60 --- /dev/null +++ b/src/Command/SyncPluginPermissionsCommand.php @@ -0,0 +1,135 @@ +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); + } +} diff --git a/src/Controller/Api/PluginMenuController.php b/src/Controller/Api/PluginMenuController.php new file mode 100644 index 0000000..4591688 --- /dev/null +++ b/src/Controller/Api/PluginMenuController.php @@ -0,0 +1,56 @@ +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), + ]); + } +} diff --git a/src/Plugin/ModulePluginInterface.php b/src/Plugin/ModulePluginInterface.php index 18781b3..72f927e 100644 --- a/src/Plugin/ModulePluginInterface.php +++ b/src/Plugin/ModulePluginInterface.php @@ -74,4 +74,29 @@ interface ModulePluginInterface * @return array{success: bool, errors: array} */ public function canInstall(): array; + + /** + * Gibt Menü-Items zurück, die in der Haupt-Navigation angezeigt werden sollen + * + * @return array + * + * 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; } diff --git a/src/Service/MenuItemRegistry.php b/src/Service/MenuItemRegistry.php new file mode 100644 index 0000000..e2000a3 --- /dev/null +++ b/src/Service/MenuItemRegistry.php @@ -0,0 +1,119 @@ + + */ + 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 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(); + } +} diff --git a/src/Service/PermissionModuleSync.php b/src/Service/PermissionModuleSync.php new file mode 100644 index 0000000..9722230 --- /dev/null +++ b/src/Service/PermissionModuleSync.php @@ -0,0 +1,187 @@ +} + */ + 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'; + } +} diff --git a/symfony.lock b/symfony.lock index aee5f57..d7637f0 100644 --- a/symfony.lock +++ b/symfony.lock @@ -73,6 +73,9 @@ "config/packages/knpu_oauth2_client.yaml" ] }, + "mycrm/billing-module": { + "version": "dev-main" + }, "mycrm/test-module": { "version": "dev-main" },
+ Rechnung: {{ invoice?.invoiceNumber }} +
+ Rechnung: {{ invoice?.invoiceNumber }} + Offener Betrag: {{ formatCurrency(invoice?.openAmount) }} +