Files
espocrm/custom/README.md

975 lines
29 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# EspoCRM Custom Actions - Implementierungsprinzip
**Version:** 1.0
**Datum:** 24. Januar 2026
**Blueprint für:** Custom Button Actions mit Entity-Erstellung und Relationen
---
## 📋 Inhaltsverzeichnis
1. [Überblick](#überblick)
2. [Architektur-Prinzipien](#architektur-prinzipien)
3. [Implementierungs-Schritte](#implementierungs-schritte)
4. [Code-Template](#code-template)
5. [Best Practices](#best-practices)
6. [Sicherheit](#sicherheit)
7. [Testing & Debugging](#testing--debugging)
8. [Beispiel-Implementierung](#beispiel-implementierung)
---
## Überblick
Dieses Dokument beschreibt das standardisierte Vorgehen zur Implementierung von Custom Button Actions in EspoCRM, die:
- Von einer Entity-Detailansicht ausgelöst werden
- Eine neue verwandte Entity erstellen
- Automatisch Daten und Relationen übertragen
- ACL-konform und transaktionssicher arbeiten
**Basis-Beispiel:** Button "Räumungsklage einleiten" in Mietobjekt → Erstellt CVmhRumungsklage mit allen Relationen
---
## Architektur-Prinzipien
### 1. Drei-Schichten-Architektur
```
┌─────────────────────────────────────────────────────┐
│ FRONTEND (client/custom/) │
│ • JavaScript Handler (Action Trigger) │
│ • Button Definition (clientDefs) │
│ • i18n Translations │
└────────────────┬────────────────────────────────────┘
│ AJAX POST Request
┌────────────────▼────────────────────────────────────┐
│ CONTROLLER (custom/Espo/Custom/Controllers/) │
│ • Request Validation │
│ • Service Call │
│ • Response Formatting │
└────────────────┬────────────────────────────────────┘
│ Method Call
┌────────────────▼────────────────────────────────────┐
│ SERVICE (custom/Espo/Custom/Services/) │
│ • ACL Security Checks │
│ • Business Logic │
│ • Transaction Management │
│ • Entity Operations │
└─────────────────────────────────────────────────────┘
```
### 2. Separation of Concerns
| Schicht | Verantwortlichkeit | Nicht erlaubt |
|---------|-------------------|---------------|
| **Frontend** | User Interaction, Confirmation, Navigation | Business Logic, Direct DB Access |
| **Controller** | HTTP Request/Response Handling | Business Logic, Direct Entity Manipulation |
| **Service** | Business Logic, ACL, Transactions, Entity Operations | HTTP Handling, View Logic |
---
## Implementierungs-Schritte
### Schritt 1: Backend Controller erstellen
**Datei:** `custom/Espo/Custom/Controllers/[EntityName].php`
```php
<?php
namespace Espo\Custom\Controllers;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Api\Request;
class CYourEntity extends \Espo\Core\Templates\Controllers\Base
{
/**
* POST Action: Beschreibung der Aktion
*/
public function postActionYourAction(Request $request): array
{
$data = $request->getParsedBody();
$id = $data->id ?? null;
if (!$id) {
throw new BadRequest('No Entity ID provided');
}
$service = $this->getRecordService();
$result = $service->yourAction($id);
return $result;
}
}
```
**Wichtig:**
- Methode MUSS `postAction[Name]` heißen
- Parameter ist `Request $request`
- Return Type ist `array`
- Extends `\Espo\Core\Templates\Controllers\Base`
---
### Schritt 2: Backend Service erstellen
**Datei:** `custom/Espo/Custom/Services/[EntityName].php`
```php
<?php
namespace Espo\Custom\Services;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\NotFound;
class CYourEntity extends \Espo\Services\Record
{
/**
* Beschreibung der Business-Logik
*
* @param string $sourceId
* @return array
* @throws NotFound
* @throws Forbidden
*/
public function yourAction(string $sourceId): array
{
// 1. LADEN: Source Entity laden
$source = $this->entityManager->getEntity('CYourEntity', $sourceId);
if (!$source) {
throw new NotFound('Entity not found');
}
// 2. SECURITY: ACL-Checks
if (!$this->acl->check($source, 'read')) {
throw new Forbidden('No read access to source entity');
}
if (!$this->acl->checkScope('CTargetEntity', 'create')) {
throw new Forbidden('No create access to target entity');
}
// 3. TRANSACTION: Start
$this->entityManager->getTransactionManager()->start();
try {
// 4. ERSTELLEN: Target Entity
$data = new \stdClass();
$data->name = 'Generated from ' . $source->get('name');
// Optional: User und Teams übernehmen
if ($source->get('assignedUserId')) {
$data->assignedUserId = $source->get('assignedUserId');
}
$teamsIds = $source->getLinkMultipleIdList('teams');
if (!empty($teamsIds)) {
$data->teamsIds = $teamsIds;
}
$target = $this->entityManager->createEntity('CTargetEntity', (array)$data);
if (!$target) {
throw new \RuntimeException('Failed to create target entity');
}
// 5. RELATIONEN: Verknüpfen
$targetRepo = $this->entityManager->getRepository('CTargetEntity');
// Source → Target
$targetRepo
->getRelation($target, 'sources')
->relate($source);
// Related Entities übertragen
$relatedEntities = $this->entityManager
->getRepository('CYourEntity')
->getRelation($source, 'relatedThings')
->find();
foreach ($relatedEntities as $related) {
$targetRepo
->getRelation($target, 'relatedThings')
->relate($related);
}
// 6. COMMIT: Transaktion abschließen
$this->entityManager->getTransactionManager()->commit();
// 7. RETURN: ID und Name für Frontend
return [
'id' => $target->getId(),
'name' => $target->get('name')
];
} catch (\Exception $e) {
// ROLLBACK: Bei jedem Fehler
$this->entityManager->getTransactionManager()->rollback();
throw $e;
}
}
}
```
**Wichtig:**
- Extends `\Espo\Services\Record`
- Immer Transactions verwenden
- Alle Exceptions im catch-Block fangen
- Rollback bei Fehlern
---
### Schritt 3: Button im Frontend konfigurieren
**Datei:** `custom/Espo/Custom/Resources/metadata/clientDefs/[EntityName].json`
```json
{
"menu": {
"detail": {
"buttons": [
{
"name": "initiateYourAction",
"label": "Your Action Button",
"handler": "custom:handlers/your-entity/your-action",
"iconHtml": "<span class=\"fas fa-bolt\"></span>",
"style": "danger",
"acl": "edit"
}
]
}
}
}
```
**Button-Optionen:**
- `name`: Eindeutiger Identifier
- `label`: Translation-Key (siehe i18n)
- `handler`: Pfad zum JavaScript Handler
- `iconHtml`: FontAwesome Icon oder HTML
- `style`: `default`, `success`, `danger`, `warning`, `info`
- `acl`: `read`, `edit`, `delete` (benötigte Permission)
---
### Schritt 4: JavaScript Handler implementieren
**Datei:** `client/custom/src/handlers/your-entity/your-action.js`
```javascript
define('custom:handlers/your-entity/your-action', [], function () {
class YourActionHandler {
constructor(view) {
this.view = view;
}
/**
* Setup-Methode wird automatisch aufgerufen
*/
initYourAction() {
this.view.listenTo(
this.view,
'after:render',
() => this.controlButtonVisibility()
);
}
/**
* Button-Sichtbarkeit steuern (optional)
*/
controlButtonVisibility() {
const model = this.view.model;
// Beispiel: Button nur bei bestimmten Bedingungen zeigen
if (!model.get('someField')) {
this.view.hideHeaderActionItem('initiateYourAction');
} else {
this.view.showHeaderActionItem('initiateYourAction');
}
}
/**
* Action-Handler (wird bei Button-Click aufgerufen)
*/
actionInitiateYourAction() {
const model = this.view.model;
// 1. CONFIRMATION: User bestätigen lassen
this.view.confirm(
this.view.translate('confirmationMessage', 'messages', 'CYourEntity'),
() => {
this.initiateAction(model.id);
}
);
}
/**
* AJAX Request zum Backend
*/
initiateAction(entityId) {
Espo.Ui.notify(this.view.translate('pleaseWait', 'messages'));
Espo.Ajax.postRequest('CYourEntity/action/yourAction', {
id: entityId
})
.then(response => {
Espo.Ui.success(
this.view.translate('successMessage', 'messages', 'CYourEntity')
);
// Navigation zur erstellten Entity
this.view.getRouter().navigate(
'#CTargetEntity/view/' + response.id,
{trigger: true}
);
})
.catch(xhr => {
console.error('Action failed:', xhr);
let errorMessage = this.view.translate('errorMessage', 'messages', 'CYourEntity');
if (xhr.status === 403) {
errorMessage = this.view.translate('Access denied', 'messages');
} else if (xhr.status === 404) {
errorMessage = this.view.translate('Not found', 'messages');
}
Espo.Ui.error(errorMessage);
});
}
}
return YourActionHandler;
});
```
**Wichtig:**
- Class-Name muss mit Filename übereinstimmen (CamelCase)
- Methode `init[ButtonName]()` für Setup
- Methode `action[ButtonName]()` für Click-Handler
- Immer Confirmation Dialog für kritische Actions
- Error Handling mit spezifischen HTTP-Status-Codes
---
### Schritt 5: Übersetzungen hinzufügen
**Datei:** `custom/Espo/Custom/Resources/i18n/de_DE/[EntityName].json`
```json
{
"labels": {
"Your Action Button": "Ihre Aktion"
},
"messages": {
"confirmationMessage": "Möchten Sie diese Aktion wirklich ausführen?",
"successMessage": "Die Aktion wurde erfolgreich ausgeführt",
"errorMessage": "Fehler beim Ausführen der Aktion"
}
}
```
**Datei:** `custom/Espo/Custom/Resources/i18n/en_US/[EntityName].json`
```json
{
"labels": {
"Your Action Button": "Your Action"
},
"messages": {
"confirmationMessage": "Do you really want to execute this action?",
"successMessage": "Action executed successfully",
"errorMessage": "Error executing action"
}
}
```
---
### Schritt 6: Validierung & Rebuild
```bash
# Im Root-Verzeichnis von EspoCRM
python3 custom/scripts/validate_and_rebuild.py
```
**Prüfungen:**
- ✅ JSON-Syntax aller Dateien
- ✅ PHP-Syntax (Service, Controller)
- ✅ JavaScript-Syntax (Handler)
- ✅ Relationship-Konsistenz
- ✅ Automatischer Cache-Clear & Rebuild
---
## Best Practices
### 1. Naming Conventions
| Element | Pattern | Beispiel |
|---------|---------|----------|
| Controller Action | `postAction[Name]` | `postActionInitiateEviction` |
| Service Method | `camelCase` | `initiateEviction` |
| Button Name | `camelCase` | `initiateEviction` |
| JS Handler File | `kebab-case.js` | `eviction-action.js` |
| JS Class Name | `PascalCase` | `EvictionActionHandler` |
| JS Init Method | `init[ButtonName]` | `initInitiateEviction` |
| JS Action Method | `action[ButtonName]` | `actionInitiateEviction` |
### 2. Transaction Management
**Immer Transactions verwenden** bei:
- Entity-Erstellung
- Mehreren Relationierungen
- Datenänderungen über mehrere Entities
```php
// ✅ RICHTIG
$this->entityManager->getTransactionManager()->start();
try {
// ... operations
$this->entityManager->getTransactionManager()->commit();
} catch (\Exception $e) {
$this->entityManager->getTransactionManager()->rollback();
throw $e;
}
// ❌ FALSCH - Keine Transaction
$entity = $this->entityManager->createEntity(...);
// Bei Fehler in nächstem Schritt: Inkonsistente Daten!
$this->entityManager->getRepository(...)->relate(...);
```
### 3. Repository Caching
```php
// ✅ RICHTIG - Repository nur 1× holen
$targetRepo = $this->entityManager->getRepository('Target');
foreach ($items as $item) {
$targetRepo->getRelation($target, 'items')->relate($item);
}
// ❌ FALSCH - Repository in Schleife holen
foreach ($items as $item) {
$this->entityManager->getRepository('Target')
->getRelation($target, 'items')
->relate($item);
}
```
### 4. Error Messages
```php
// ✅ RICHTIG - Spezifische Exceptions
if (!$source) {
throw new NotFound('Mietobjekt not found');
}
if (!$this->acl->check($source, 'read')) {
throw new Forbidden('No read access to Mietobjekt');
}
// ❌ FALSCH - Generische Fehler
if (!$source) {
throw new \Exception('Error');
}
```
### 5. Null Checks
```php
// ✅ RICHTIG
$entity = $this->entityManager->createEntity(...);
if (!$entity) {
throw new \RuntimeException('Failed to create entity');
}
// ❌ FALSCH - Keine Prüfung
$entity = $this->entityManager->createEntity(...);
$entity->getId(); // Kann zu Fehler führen!
```
---
## Sicherheit
### 1. ACL-Checks (Access Control Lists)
**Minimum:** Immer beide Checks durchführen:
```php
// Check 1: Kann User Source-Entity lesen?
if (!$this->acl->check($sourceEntity, 'read')) {
throw new Forbidden('No read access');
}
// Check 2: Kann User Target-Entity erstellen?
if (!$this->acl->checkScope('TargetEntity', 'create')) {
throw new Forbidden('No create access');
}
```
**Optional:** Zusätzliche Checks je nach Business-Logik:
```php
// Check 3: Kann User Source-Entity bearbeiten?
if (!$this->acl->check($sourceEntity, 'edit')) {
throw new Forbidden('No edit access');
}
// Check 4: Ownership-Check
if ($sourceEntity->get('assignedUserId') !== $this->user->getId()) {
throw new Forbidden('Not the owner');
}
```
### 2. Input Validation
```php
// ✅ RICHTIG - ID validieren
$id = $data->id ?? null;
if (!$id || !is_string($id)) {
throw new BadRequest('Invalid ID provided');
}
// Optional: Format prüfen
if (!preg_match('/^[a-f0-9]{17}$/', $id)) {
throw new BadRequest('Invalid ID format');
}
```
### 3. SQL Injection Prevention
**Automatisch geschützt** durch:
- ✅ EntityManager ORM (immer verwenden!)
- ✅ Repository Pattern
- ✅ Prepared Statements
**Niemals:**
- ❌ Direct SQL Queries
- ❌ String Concatenation in Queries
-`$pdo->query()` direkt verwenden
### 4. XSS Prevention
**Automatisch geschützt** durch:
- ✅ EspoCRM Template Engine
- ✅ Automatic HTML Escaping
- ✅ Content Security Policy Headers
**Im Frontend:**
```javascript
// ✅ RICHTIG - translate() escaped automatisch
this.view.translate('message', 'messages', 'Entity');
// ❌ FALSCH - Rohe HTML-Injection
$('.container').html('<div>' + userInput + '</div>');
```
### 5. CSRF Protection
**Automatisch geschützt** durch:
- ✅ EspoCRM API Token System
- ✅ Espo.Ajax verwendet automatisch CSRF-Token
- ✅ Session-basierte Validierung
**Kein manueller Code nötig!**
---
## Testing & Debugging
### 1. Entwickler-Workflow
```bash
# 1. Code ändern
vim custom/Espo/Custom/Services/YourEntity.php
# 2. Validieren & Rebuild
python3 custom/scripts/validate_and_rebuild.py
# 3. Browser Hard-Refresh
# Ctrl + Shift + R (Chrome/Firefox)
# Cmd + Shift + R (Mac)
# 4. Test durchführen
# 5. Logs prüfen bei Fehlern
tail -f data/logs/espo-$(date +%Y-%m-%d).log
```
### 2. Häufige Fehler & Lösungen
| Fehler | Ursache | Lösung |
|--------|---------|--------|
| `Error 500: Call to undefined method getAcl()` | Falsche ACL-Methode | Nutze `$this->acl` statt `$this->getAcl()` |
| `Error 500: Entity does not have a relation 'xyz'` | Relation existiert nicht | Prüfe entityDefs, korrekter Relationname |
| Button erscheint nicht | Falsche clientDefs-Struktur | Nutze `menu.detail.buttons` nicht `detailActionList` |
| JavaScript-Fehler | Handler nicht gefunden | Prüfe Pfad und Class-Name, Cache leeren |
| Transaction-Fehler | Verschachtelte Transactions | Nur eine Transaction auf Service-Level |
### 3. Debugging-Kommandos
```bash
# Logs live verfolgen
tail -f data/logs/espo-$(date +%Y-%m-%d).log
# Letzten Fehler finden
tail -n 200 data/logs/espo-$(date +%Y-%m-%d).log | grep -i "error"
# PHP-Syntax prüfen
find custom/Espo/Custom -name "*.php" -exec php -l {} \;
# JSON-Syntax prüfen
find custom/Espo/Custom/Resources/metadata -name "*.json" -exec python3 -m json.tool {} \; > /dev/null
# Cache manuell löschen
rm -rf data/cache/*
```
### 4. Browser DevTools
**Console (F12):**
```javascript
// Current Model inspizieren
console.log(this.model.attributes);
// AJAX Request debuggen
Espo.Ajax.postRequest('Entity/action/test', {id: 'xyz'})
.then(r => console.log('Success:', r))
.catch(e => console.error('Error:', e));
```
---
## Beispiel-Implementierung
### Use Case: "Räumungsklage einleiten"
**Requirement:**
- Button in Mietobjekt-Detailansicht
- Erstellt CVmhRumungsklage
- Verknüpft Mietverhältnisse, Vermieter (Kläger), Mieter (Beklagte)
- Kopiert assignedUser und Teams
### Dateien-Struktur
```
custom/Espo/Custom/
├── Controllers/
│ └── CMietobjekt.php # POST Action Endpoint
├── Services/
│ └── CMietobjekt.php # Business Logic
└── Resources/
├── metadata/clientDefs/
│ └── CMietobjekt.json # Button Definition
└── i18n/
├── de_DE/
│ └── CMietobjekt.json # Deutsche Übersetzungen
└── en_US/
└── CMietobjekt.json # Englische Übersetzungen
client/custom/src/handlers/
└── mietobjekt/
└── eviction-action.js # Frontend Handler
```
### Vollständiger Code
**1. Controller** ([CMietobjekt.php](custom/Espo/Custom/Controllers/CMietobjekt.php)):
```php
<?php
namespace Espo\Custom\Controllers;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Api\Request;
class CMietobjekt extends \Espo\Core\Templates\Controllers\Base
{
public function postActionInitiateEviction(Request $request): array
{
$data = $request->getParsedBody();
$id = $data->id ?? null;
if (!$id) {
throw new BadRequest('No Mietobjekt ID provided');
}
return $this->getRecordService()->initiateEviction($id);
}
}
```
**2. Service** ([CMietobjekt.php](custom/Espo/Custom/Services/CMietobjekt.php)):
```php
<?php
namespace Espo\Custom\Services;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\NotFound;
class CMietobjekt extends \Espo\Services\Record
{
public function initiateEviction(string $mietobjektId): array
{
// 1. Load & Validate
$mietobjekt = $this->entityManager->getEntity('CMietobjekt', $mietobjektId);
if (!$mietobjekt) {
throw new NotFound('Mietobjekt not found');
}
// 2. ACL Checks
if (!$this->acl->check($mietobjekt, 'read')) {
throw new Forbidden('No read access to Mietobjekt');
}
if (!$this->acl->checkScope('CVmhRumungsklage', 'create')) {
throw new Forbidden('No create access to Räumungsklage');
}
// 3. Start Transaction
$this->entityManager->getTransactionManager()->start();
try {
// 4. Create Räumungsklage
$data = new \stdClass();
$data->name = 'Räumungsklage - ' . $mietobjekt->get('name');
if ($mietobjekt->get('assignedUserId')) {
$data->assignedUserId = $mietobjekt->get('assignedUserId');
}
$teamsIds = $mietobjekt->getLinkMultipleIdList('teams');
if (!empty($teamsIds)) {
$data->teamsIds = $teamsIds;
}
$raeumungsklage = $this->entityManager->createEntity(
'CVmhRumungsklage',
(array)$data
);
if (!$raeumungsklage) {
throw new \RuntimeException('Failed to create Räumungsklage');
}
$repo = $this->entityManager->getRepository('CVmhRumungsklage');
// 5. Link Relations
$repo->getRelation($raeumungsklage, 'mietobjekte')
->relate($mietobjekt);
$mietverhaeltnisse = $this->entityManager
->getRepository('CMietobjekt')
->getRelation($mietobjekt, 'vmhMietverhltnises')
->find();
foreach ($mietverhaeltnisse as $mv) {
$repo->getRelation($raeumungsklage, 'vmhMietverhltnises')
->relate($mv);
// Link Vermieter as Kläger
$vermieter = $this->entityManager
->getRepository('CVmhMietverhltnis')
->getRelation($mv, 'vmhbeteiligtevermieter')
->find();
foreach ($vermieter as $v) {
$repo->getRelation($raeumungsklage, 'klaeger')->relate($v);
}
// Link Mieter as Beklagte
$mieter = $this->entityManager
->getRepository('CVmhMietverhltnis')
->getRelation($mv, 'vmhbeteiligtemieter')
->find();
foreach ($mieter as $m) {
$repo->getRelation($raeumungsklage, 'beklagte')->relate($m);
}
}
// 6. Commit
$this->entityManager->getTransactionManager()->commit();
return [
'id' => $raeumungsklage->getId(),
'name' => $raeumungsklage->get('name')
];
} catch (\Exception $e) {
$this->entityManager->getTransactionManager()->rollback();
throw $e;
}
}
}
```
**3. Button Config** ([clientDefs/CMietobjekt.json](custom/Espo/Custom/Resources/metadata/clientDefs/CMietobjekt.json)):
```json
{
"menu": {
"detail": {
"buttons": [
{
"name": "initiateEviction",
"label": "Initiate Eviction",
"handler": "custom:handlers/mietobjekt/eviction-action",
"iconHtml": "<span class=\"fas fa-gavel\"></span>",
"style": "danger",
"acl": "edit"
}
]
}
}
}
```
**4. JavaScript Handler** ([eviction-action.js](client/custom/src/handlers/mietobjekt/eviction-action.js)):
```javascript
define('custom:handlers/mietobjekt/eviction-action', [], function () {
class EvictionActionHandler {
constructor(view) {
this.view = view;
}
initInitiateEviction() {
// Optional: Button-Logik nach Render
}
actionInitiateEviction() {
this.view.confirm(
this.view.translate('confirmEviction', 'messages', 'CMietobjekt'),
() => this.initiateEviction(this.view.model.id)
);
}
initiateEviction(id) {
Espo.Ui.notify(this.view.translate('pleaseWait', 'messages'));
Espo.Ajax.postRequest('CMietobjekt/action/initiateEviction', { id })
.then(response => {
Espo.Ui.success(
this.view.translate('evictionCreated', 'messages', 'CMietobjekt')
);
this.view.getRouter().navigate(
'#CVmhRumungsklage/view/' + response.id,
{ trigger: true }
);
})
.catch(xhr => {
let msg = this.view.translate('evictionError', 'messages', 'CMietobjekt');
if (xhr.status === 403) {
msg = this.view.translate('Access denied', 'messages');
}
Espo.Ui.error(msg);
});
}
}
return EvictionActionHandler;
});
```
**5. Translations** ([i18n/de_DE/CMietobjekt.json](custom/Espo/Custom/Resources/i18n/de_DE/CMietobjekt.json)):
```json
{
"labels": {
"Initiate Eviction": "Räumungsklage einleiten"
},
"messages": {
"confirmEviction": "Möchten Sie wirklich eine Räumungsklage einleiten?",
"evictionCreated": "Räumungsklage wurde erfolgreich erstellt",
"evictionError": "Fehler beim Erstellen der Räumungsklage"
}
}
```
---
## Checkliste für neue Implementierungen
### Vor dem Start
- [ ] Requirements klar definiert (Welche Entity? Welche Relationen?)
- [ ] Entity-Relationen in entityDefs geprüft
- [ ] ACL-Requirements geklärt
- [ ] Datenfluss dokumentiert
### Implementierung
- [ ] Controller erstellt mit `postAction[Name]` Methode
- [ ] Service erstellt mit Business-Logik
- [ ] ACL-Checks: `check()` für Source + `checkScope()` für Target
- [ ] Transaction: `start()``try/catch``commit()/rollback()`
- [ ] Null-Check nach `createEntity()`
- [ ] Repository-Caching bei Loops
- [ ] Button in clientDefs konfiguriert (`menu.detail.buttons`)
- [ ] JavaScript Handler erstellt
- [ ] Init-Methode: `init[ButtonName]()`
- [ ] Action-Methode: `action[ButtonName]()`
- [ ] Confirmation Dialog implementiert
- [ ] Error Handling mit HTTP-Status-Codes
- [ ] Translations: Deutsch + Englisch
- [ ] Labels + Messages vollständig
### Testing
- [ ] Validierung erfolgreich: `validate_and_rebuild.py`
- [ ] Browser Hard-Refresh durchgeführt
- [ ] Button erscheint in Detailansicht
- [ ] Confirmation Dialog funktioniert
- [ ] Entity wird korrekt erstellt
- [ ] Relationen sind korrekt verknüpft
- [ ] Navigation zur neuen Entity funktioniert
- [ ] ACL wird respektiert (Test mit eingeschränktem User)
- [ ] Error Cases getestet (keine Permission, Entity nicht gefunden)
- [ ] Logs geprüft: Keine Errors
### Dokumentation
- [ ] Code-Kommentare hinzugefügt
- [ ] PHPDoc vollständig
- [ ] README aktualisiert (falls generische Änderungen)
---
## Support & Weiterentwicklung
### Erweiterungen des Templates
**Optional implementierbar:**
- Conditional Button Visibility (nur unter bestimmten Bedingungen)
- Batch Operations (mehrere Entities gleichzeitig)
- Background Jobs (lange Operationen asynchron)
- Email-Benachrichtigungen nach Erstellung
- PDF-Generierung
- Webhook-Trigger
- Audit Logging
### Performance-Optimierungen
Bei großen Datenmengen (> 100 Relations):
- Batch-Insert mit `saveEntity()` statt `createEntity()`
- Eager Loading mit `with()`
- Query Optimization mit `select()` und `where()`
- Background Jobs für lange Operationen
### Nützliche Links
- [EspoCRM Dokumentation](https://docs.espocrm.com)
- [GitHub Repository](https://github.com/espocrm/espocrm)
- [Community Forum](https://forum.espocrm.com)
- [API Dokumentation](https://docs.espocrm.com/development/api/)
---
## Versionierung
| Version | Datum | Änderungen |
|---------|-------|------------|
| 1.0 | 24.01.2026 | Initiale Version basierend auf Räumungsklage-Implementierung |
---
**Erstellt von:** EspoCRM Development Team
**Letzte Aktualisierung:** 24. Januar 2026
**Status:** Production Ready ✅