feat: open_workshop_employee_imagegenerator - Thekenheld Badge-Generator als Odoo Modul

- OWL Component mit 3-step Wizard (Upload → Crop → Text/Preview)
- Cropper.js für Bildausschnitt (369×492px)
- html2canvas für PNG-Generierung (794×1123px A4)
- Badge-Template mit originalen CSS von Thekenheld
- Speicherung in hr.employee.image_1920
This commit is contained in:
Matthias Lotz 2026-01-10 16:42:39 +01:00
parent 1abeb97afa
commit 1c9829a7aa
10 changed files with 839 additions and 0 deletions

View File

@ -0,0 +1,139 @@
# Open Workshop - Employee Image Generator
Erstelle professionelle Namensschilder (Thekenschilder) für Mitarbeiter direkt in Odoo.
## Beschreibung
Dieses Modul ersetzt die externe "thekenheld" WebApp und integriert die Funktionalität direkt in Odoo's HR-Modul. Mitarbeiter-Namensschilder können nun bequem aus dem Employee-Formular heraus erstellt werden.
## Features
- ✅ **Foto-Upload & Zuschnitt**: Cropper.js Integration für professionelle Bildbearbeitung
- ✅ **Festes Seitenverhältnis**: 369x492 Pixel (optimiert für Thekenschilder)
- ✅ **Text-Overlay**: Name und Schwerpunkte werden automatisch hinzugefügt
- ✅ **Direkte Integration**: Button im Employee-Formular
- ✅ **Sofortige Verfügbarkeit**: Generiertes Bild wird direkt als Employee Avatar gespeichert
- ✅ **POS Integration**: Bilder werden automatisch im Customer Display angezeigt (via open_workshop_pos_customer_display)
## Installation
1. Modul in Odoo installieren
2. Berechtigungen: HR / Mitarbeiter Module erforderlich
## Verwendung
### Namensschild erstellen:
1. Öffne einen Mitarbeiter-Datensatz (HR → Mitarbeiter)
2. Klicke auf den Button **"Namensschild erstellen"** im Header
3. **Schritt 1**: Wähle ein Foto aus (mindestens 369x492 Pixel empfohlen)
4. **Schritt 2**: Schneide das Foto zu - bewege und zoome für den perfekten Ausschnitt
5. **Schritt 3**: Überprüfe Name und Schwerpunkte, passe bei Bedarf an
6. **Vorschau**: Sieh dir das finale Namensschild an
7. Klicke **"Speichern"** - das Bild wird als Employee Avatar gesetzt
### Schwerpunkte definieren:
Im Employee-Formular gibt es ein neues Feld **"Schwerpunkte"** (job_focus):
- Trage hier die Tätigkeitsschwerpunkte ein
- Z.B. "Fahrrad-Reparatur, E-Bikes, Werkstatt"
- Max. 2 Zeilen für optimale Darstellung
- Falls leer, wird alternativ das job_title Feld verwendet
## Technische Details
### Architektur
**Frontend-only Lösung** mit:
- **OWL Component**: Client Action für Dialog
- **Cropper.js**: Professionelles Bild-Cropping
- **Canvas API**: Text-Overlay Generation
- **Base64 Upload**: Direktes Speichern als image_1920
### Dateien
```
open_workshop_employee_imagegenerator/
├── __init__.py
├── __manifest__.py
├── models/
│ ├── __init__.py
│ └── hr_employee.py # Erweiterung von hr.employee
├── views/
│ └── hr_employee_views.xml # Button im Form + job_focus Feld
├── static/
│ ├── src/
│ │ ├── js/
│ │ │ └── employee_image_widget.js # OWL Component
│ │ ├── xml/
│ │ │ └── employee_image_widget.xml # Template
│ │ └── css/
│ │ └── employee_image_widget.css # Styles
│ └── lib/
│ └── cropperjs/
│ ├── cropper.js # Cropper.js Library
│ └── cropper.css # Cropper.js Styles
```
### Workflow
1. User klickt "Namensschild erstellen"
2. Python Action öffnet Client Action mit Context (employee_id, name, job_focus)
3. JavaScript Dialog (3 Schritte):
- Upload → FileReader lädt Bild als Data URL
- Crop → Cropper.js erzeugt zugeschnittenes Bild (369x492)
- Text → Canvas API zeichnet Text-Overlay
4. Base64 Image wird zu hr.employee.image_1920 gespeichert
5. Page Reload zeigt neues Bild
### Canvas Text-Overlay
Das generierte Namensschild hat:
- **Foto**: 369x492 Pixel (Vollbild)
- **Text-Overlay**: 120px Höhe am unteren Rand
- **Hintergrund**: Halbtransparent weiß (92% Opazität)
- **Name**: Fett, 24px, zentriert
- **Schwerpunkte**: Normal, 16px, zentriert, max. 2 Zeilen
## Integration
### Mit POS Customer Display
Dieses Modul arbeitet nahtlos mit `open_workshop_pos_customer_display` zusammen:
- Generierte Bilder werden automatisch im Customer Display angezeigt
- Verwendung der hr.employee.public URL
- Keine zusätzliche Konfiguration nötig
### Ersetzt thekenheld WebApp
Die externe Docker-Container WebApp ist nicht mehr nötig:
- ✅ Keine separate Instanz erforderlich
- ✅ Alle Daten in Odoo
- ✅ Berechtigungsverwaltung über Odoo
- ✅ Besserer Workflow
## Migration von thekenheld
Falls bereits Bilder in der alten WebApp erstellt wurden:
1. Bilder aus `/var/www/html/uploads/` kopieren
2. Manuell in Odoo Employee-Datensätze hochladen
3. Oder: Neues Namensschild mit diesem Tool erstellen
## Abhängigkeiten
- `hr` - Human Resources Module
- `web` - Odoo Web Framework
- Cropper.js v1.6.1 (inkludiert)
## Version
18.0.1.0.0
## Lizenz
LGPL-3
## Credits
Entwickelt für Open Workshop / Hobbyhimmel
Basierend auf der thekenheld WebApp

View File

@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import models

View File

@ -0,0 +1,53 @@
# -*- coding: utf-8 -*-
{
'name': 'Open Workshop - Employee Image Generator',
'version': '18.0.1.0.0',
'category': 'Human Resources',
'summary': 'Generate professional employee name badges with photo cropping',
'description': """
Employee Image Generator
=========================
Creates professional employee name badges (Thekenschilder) directly in Odoo.
Features:
---------
* Upload and crop employee photos with fixed aspect ratio (369x492)
* Add employee name and job description
* Integrated Cropper.js for professional image editing
* Direct integration in Employee form
* Replaces external thekenheld webapp
Usage:
------
1. Go to Employee form
2. Click "Namensschild erstellen" button
3. Upload photo and crop it
4. Enter name and job focus areas
5. Save - the generated image becomes the employee avatar
""",
'author': 'Open Workshop',
'website': 'https://www.open-workshop.de',
'license': 'LGPL-3',
'depends': ['hr', 'web'],
'data': [
'views/hr_employee_views.xml',
],
'assets': {
'web.assets_backend': [
# Cropper.js CSS
'open_workshop_employee_imagegenerator/static/lib/cropperjs/cropper.css',
'open_workshop_employee_imagegenerator/static/src/css/employee_image_widget.css',
'open_workshop_employee_imagegenerator/static/src/css/badge_template.css',
# Libraries
'open_workshop_employee_imagegenerator/static/lib/cropperjs/cropper.js',
'open_workshop_employee_imagegenerator/static/lib/html2canvas.min.js',
# Our custom widget
'open_workshop_employee_imagegenerator/static/src/js/employee_image_widget.js',
'open_workshop_employee_imagegenerator/static/src/xml/employee_image_widget.xml',
],
},
'installable': True,
'application': False,
'auto_install': False,
}

View File

@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import hr_employee

View File

@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
class HrEmployee(models.Model):
_inherit = 'hr.employee'
# Zusätzliches Feld für Schwerpunkte (falls nicht job_title verwendet wird)
job_focus = fields.Char(
string='Schwerpunkte',
help='Tätigkeitsschwerpunkte für Namensschild'
)
def action_open_image_generator(self):
"""Öffnet den Image Generator Dialog"""
self.ensure_one()
return {
'type': 'ir.actions.client',
'tag': 'employee_image_generator',
'name': 'Namensschild erstellen',
'target': 'new',
'params': {
'employee_id': self.id,
'employee_name': self.name,
'job_focus': self.job_focus or self.job_title or '',
},
'context': {
'employee_id': self.id,
'employee_name': self.name,
'job_focus': self.job_focus or self.job_title or '',
}
}

View File

@ -0,0 +1,94 @@
/* Badge Template CSS - 1:1 von thekenheld print.html */
.badge-template {
position: relative;
background: white;
width: 21cm;
height: 29.7cm;
margin: 0 auto;
display: block;
}
.badge-template header,
.badge-template footer {
position: absolute;
left: 0;
right: 0;
background-color: rgb(64, 64, 64);
padding-right: 1.5cm;
padding-left: 1.5cm;
height: 30mm;
}
.badge-template header {
top: 0;
padding-top: 5mm;
padding-bottom: 3mm;
}
.badge-template footer {
bottom: 0;
color: rgb(241, 245, 247);
padding-top: 3mm;
padding-bottom: 5mm;
}
.badge-template .content {
padding-top: 40mm;
padding-bottom: 40mm;
text-align: center;
}
.badge-template .logo {
width: 43mm;
height: 20mm;
padding: 0;
}
.badge-template .headline1 {
height: 13mm;
font-size: 10mm;
font-weight: bold;
}
.badge-template .headline2 {
height: 9mm;
font-size: 6mm;
text-align: center;
}
.badge-template .headline3 {
height: 10mm;
font-size: 7mm;
font-weight: bold;
}
.badge-template .centered-image {
height: 130mm;
width: 100mm;
display: block;
margin: 0 auto;
padding: 5mm 0 0 0;
object-fit: cover;
}
.badge-template .your-name {
height: 22mm;
font-size: 19mm;
padding: 0;
font-weight: bold;
line-height: 1;
}
.badge-template .topics {
height: 10mm;
font-size: 8mm;
text-align: center;
}
.badge-template .warnung {
font-size: 10mm;
color: red;
flex: 1;
font-weight: bold;
}

View File

@ -0,0 +1,94 @@
/* Employee Image Generator Styles */
.o_employee_image_generator {
max-width: 900px;
}
.o_employee_image_generator .img-container {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
}
.o_employee_image_generator .img-container img {
max-width: 100%;
display: block;
}
.o_employee_image_generator canvas {
border: 1px solid #dee2e6;
border-radius: 4px;
}
/* Cropper.js Container Anpassungen */
.o_employee_image_generator .cropper-container {
border-radius: 4px;
}
/* Progress Bar */
.o_employee_image_generator .progress {
height: 3px;
background-color: #e9ecef;
}
.o_employee_image_generator .progress-bar {
background-color: #0d6efd;
transition: width 0.3s ease;
}
/* Step Indicators */
.o_employee_image_generator .step-indicator {
font-size: 0.875rem;
}
/* Upload Area */
.o_employee_image_generator .upload-area {
cursor: pointer;
transition: all 0.2s;
}
.o_employee_image_generator .upload-area:hover {
background-color: #f8f9fa;
}
/* Canvas Preview */
.o_employee_image_generator canvas {
display: block;
margin: 0 auto;
}
/* Modal Anpassungen */
.o_employee_image_generator .modal-dialog {
margin: 1.75rem auto;
}
.o_employee_image_generator .modal-content {
border: none;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
}
.o_employee_image_generator .modal-header {
border-bottom: 1px solid #dee2e6;
padding: 1rem 1.5rem;
}
.o_employee_image_generator .modal-body {
padding: 1.5rem;
}
.o_employee_image_generator .modal-footer {
border-top: 1px solid #dee2e6;
padding: 1rem 1.5rem;
}
/* Responsive */
@media (max-width: 768px) {
.o_employee_image_generator .modal-dialog {
max-width: 95%;
margin: 0.5rem auto;
}
.o_employee_image_generator .modal-body {
padding: 1rem;
}
}

View File

@ -0,0 +1,223 @@
/** @odoo-module */
import { registry } from "@web/core/registry";
import { Component, useState, useRef, onMounted } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
/**
* Employee Image Generator Client Action
*
* Ermöglicht das Erstellen von professionellen Namensschildern für Mitarbeiter.
* Verwendet Cropper.js für Bildbearbeitung und Canvas API für Text-Overlay.
*/
class EmployeeImageGenerator extends Component {
static template = "open_workshop_employee_imagegenerator.ImageGeneratorDialog";
static props = {
"*": true,
};
setup() {
this.orm = useService("orm");
this.action = useService("action");
this.notification = useService("notification");
// Hole Context aus props oder action
const context = this.props.action?.context || this.props.context || {};
this.state = useState({
step: 1, // 1: Upload, 2: Crop, 3: Text, 4: Preview
employeeId: context.employee_id,
employeeName: context.employee_name || '',
jobFocus: context.job_focus || '',
companyLogo: null,
imageDataUrl: null,
croppedImageDataUrl: null,
finalImageDataUrl: null,
});
this.fileInput = useRef("fileInput");
this.imagePreview = useRef("imagePreview");
this.cropperImage = useRef("cropperImage");
this.badgeTemplate = useRef("badgeTemplate");
this.cropper = null;
// A4-Format wie Original: 794×1123px (wie thekenheld)
this.targetWidth = 794;
this.targetHeight = 1123;
// Umrechnungsfaktor: 793px / 210mm = 3.776 px/mm
this.pxPerMm = this.targetWidth / 210;
// Lade Company-Logo beim Start
this.loadCompanyLogo();
}
async loadCompanyLogo() {
try {
// Hole das Logo der aktuellen Company
const company = await this.orm.call('res.company', 'search_read', [[]], {
fields: ['logo'],
limit: 1,
});
if (company && company.length > 0 && company[0].logo) {
// Company-Logo ist base64-codiert, füge data:image/png prefix hinzu
this.state.companyLogo = `data:image/png;base64,${company[0].logo}`;
}
} catch (error) {
console.warn('Konnte Company-Logo nicht laden:', error);
}
}
// Schritt 1: Datei-Upload
onFileSelect(ev) {
const files = ev.target.files;
if (files && files.length > 0) {
const file = files[0];
// Prüfe ob es ein Bild ist
if (!file.type.startsWith('image/')) {
this.notification.add('Bitte wähle eine Bilddatei aus', {
type: 'warning'
});
return;
}
// Lade Bild als Data URL
const reader = new FileReader();
reader.onload = (e) => {
this.state.imageDataUrl = e.target.result;
this.state.step = 2;
// Initialisiere Cropper im nächsten Tick
setTimeout(() => this.initCropper(), 100);
};
reader.readAsDataURL(file);
}
}
// Schritt 2: Initialisiere Cropper.js
initCropper() {
if (this.cropper) {
this.cropper.destroy();
}
const imageElement = this.cropperImage.el;
if (!imageElement || !window.Cropper) {
console.error('Cropper.js not loaded or image element not found');
return;
}
const aspectRatio = this.targetWidth / this.targetHeight;
this.cropper = new window.Cropper(imageElement, {
aspectRatio: aspectRatio,
viewMode: 3,
dragMode: 'move',
autoCropArea: 1,
restore: false,
guides: true,
center: true,
highlight: false,
cropBoxMovable: true,
cropBoxResizable: true,
toggleDragModeOnDblclick: false,
});
}
// Bild zuschneiden und weiter zu Schritt 3
cropImage() {
if (!this.cropper) {
return;
}
const canvas = this.cropper.getCroppedCanvas({
width: this.targetWidth,
height: this.targetHeight,
});
if (canvas) {
this.state.croppedImageDataUrl = canvas.toDataURL('image/jpeg', 0.9);
this.state.step = 3;
// Destroy cropper um Ressourcen freizugeben
this.cropper.destroy();
this.cropper = null;
// Gehe zu Schritt 3 (das Template ist bereits sichtbar via t-if)
// Die Vorschau wird automatisch angezeigt durch das HTML-Template
}
}
// Schritt 3: Text-Änderung → Vorschau ist Live durch HTML-Template
onTextChange() {
// Das Template aktualisiert sich automatisch durch t-model
// Keine zusätzliche Logik nötig - OWL reactive state macht das automatisch!
}
// Schritt 4: Finale Speicherung - Konvertiere HTML-Template zu Bild mit html2canvas
async saveFinalImage() {
try {
const badgeElement = this.badgeTemplate.el;
if (!badgeElement) {
this.notification.add('Badge-Template nicht gefunden', { type: 'danger' });
return;
}
// Konvertiere HTML-Element zu Canvas mit html2canvas (exakt wie thekenheld)
const canvas = await window.html2canvas(badgeElement, {
width: this.targetWidth,
height: this.targetHeight,
});
// Konvertiere Canvas zu Base64 PNG (wie Original thekenheld)
const finalImageBase64 = canvas.toDataURL('image/png');
// Speichere in Odoo (entferne data:image/jpeg;base64, prefix)
const imageData = finalImageBase64.split(',')[1];
await this.orm.write('hr.employee', [this.state.employeeId], {
image_1920: imageData,
});
this.notification.add('Namensschild erfolgreich gespeichert!', {
type: 'success',
});
// Schließe Dialog und kehre zurück zum Mitarbeiter-Formular
await this.action.doAction({
type: 'ir.actions.act_window',
res_model: 'hr.employee',
res_id: this.state.employeeId,
views: [[false, 'form']],
target: 'current',
});
} catch (error) {
console.error('Fehler beim Speichern:', error);
this.notification.add(`Fehler beim Speichern: ${error.message}`, {
type: 'danger',
});
}
}
// Abbrechen
cancel() {
if (this.props.close) {
this.props.close();
} else {
this.action.doAction({
type: 'ir.actions.act_window_close'
});
}
}
// Zurück zum vorherigen Schritt
previousStep() {
if (this.state.step > 1) {
this.state.step--;
}
}
}
// Registriere als Client Action
registry.category("actions").add("employee_image_generator", EmployeeImageGenerator);

View File

@ -0,0 +1,173 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="open_workshop_employee_imagegenerator.ImageGeneratorDialog">
<div class="o_employee_image_generator modal-dialog modal-lg">
<div class="modal-content">
<!-- Header -->
<div class="modal-header">
<h4 class="modal-title">
<i class="fa fa-image me-2"/>
Namensschild erstellen
</h4>
<button type="button" class="btn-close" t-on-click="cancel"/>
</div>
<!-- Body -->
<div class="modal-body">
<!-- Schritt-Anzeige -->
<div class="mb-3">
<div class="progress" style="height: 3px;">
<div class="progress-bar"
role="progressbar"
t-att-style="'width: ' + (state.step * 33) + '%'"
aria-valuenow="state.step"
aria-valuemin="1"
aria-valuemax="3"/>
</div>
<div class="d-flex justify-content-between mt-2 text-muted small">
<span t-att-class="{'text-primary fw-bold': state.step === 1}">1. Bild hochladen</span>
<span t-att-class="{'text-primary fw-bold': state.step === 2}">2. Bild zuschneiden</span>
<span t-att-class="{'text-primary fw-bold': state.step === 3}">3. Text &amp; Vorschau</span>
</div>
</div>
<!-- Schritt 1: Upload -->
<div t-if="state.step === 1" class="text-center p-5">
<i class="fa fa-cloud-upload fa-5x text-muted mb-3"/>
<h5>Wähle ein Foto aus</h5>
<p class="text-muted">Das Bild sollte mindestens 369x492 Pixel groß sein</p>
<input type="file"
t-ref="fileInput"
class="d-none"
accept="image/*"
t-on-change="onFileSelect"/>
<button class="btn btn-primary btn-lg"
t-on-click="() => this.fileInput.el.click()">
<i class="fa fa-folder-open me-2"/>
Datei auswählen
</button>
</div>
<!-- Schritt 2: Crop -->
<div t-if="state.step === 2">
<div class="alert alert-info mb-3">
<i class="fa fa-info-circle me-2"/>
Bewege und zoome das Bild, um den gewünschten Ausschnitt zu wählen
</div>
<div class="img-container" style="max-height: 500px; overflow: hidden;">
<img t-ref="cropperImage"
t-att-src="state.imageDataUrl"
style="max-width: 100%; display: block;"/>
</div>
</div>
<!-- Schritt 3: Text und Vorschau -->
<div t-if="state.step === 3">
<div class="row">
<!-- Linke Spalte: Eingabefelder -->
<div class="col-md-6">
<h5 class="mb-3">Textinformationen</h5>
<div class="mb-3">
<label for="employeeName" class="form-label">Name</label>
<input type="text"
class="form-control"
id="employeeName"
t-model="state.employeeName"
t-on-input="onTextChange"
placeholder="Max Mustermann"/>
<small class="form-text text-muted">
Dieser Name wird auf dem Namensschild angezeigt
</small>
</div>
<div class="mb-3">
<label for="jobFocus" class="form-label">Schwerpunkte</label>
<textarea class="form-control"
id="jobFocus"
rows="3"
t-model="state.jobFocus"
t-on-input="onTextChange"
placeholder="z.B. Fahrrad-Reparatur, E-Bikes, Werkstatt"/>
<small class="form-text text-muted">
Tätigkeitsbereiche oder Spezialisierung (optional)
</small>
</div>
<div class="alert alert-light">
<strong>Tipp:</strong> Kurze Texte sehen besser aus.
Maximal 2 Zeilen für Schwerpunkte.
</div>
</div>
<!-- Rechte Spalte: Vorschau -->
<div class="col-md-6">
<h5 class="mb-3">Vorschau</h5>
<div class="border rounded p-2 bg-light" style="overflow-y: auto; max-height: 600px;">
<!-- HTML Badge Template (wird mit html2canvas konvertiert) -->
<div t-ref="badgeTemplate" class="badge-template">
<header>
<div class="logo">
<img t-if="state.companyLogo" t-att-src="state.companyLogo" alt="Logo" class="logo"/>
</div>
</header>
<div class="content">
<div class="headline1">An der Theke für Dich</div>
<div class="headline2">Werkstattaufsicht und Werkzeugausgabe</div>
<img t-if="state.croppedImageDataUrl"
t-att-src="state.croppedImageDataUrl"
alt="avatar"
class="centered-image"/>
<div class="your-name" t-esc="state.employeeName || 'Dein Name'"/>
<hr/>
<div class="headline3">Themenschwerpunkte:</div>
<div class="topics" t-esc="state.jobFocus || 'Deine Schwerpunkte'"/>
<div class="warnung">! Zugang zur Theke nur für Berechtigte !</div>
</div>
<footer></footer>
</div>
</div>
<small class="text-muted d-block mt-2 text-center">
A4-Format (793 × 1122 Pixel) - 50% Vorschau
</small>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="modal-footer">
<button type="button"
class="btn btn-secondary"
t-on-click="cancel">
Abbrechen
</button>
<button t-if="state.step > 1 and state.step &lt; 3"
type="button"
class="btn btn-light"
t-on-click="previousStep">
<i class="fa fa-arrow-left me-2"/>
Zurück
</button>
<button t-if="state.step === 2"
type="button"
class="btn btn-primary"
t-on-click="cropImage">
Weiter
<i class="fa fa-arrow-right ms-2"/>
</button>
<button t-if="state.step === 3"
type="button"
class="btn btn-success"
t-on-click="saveFinalImage">
<i class="fa fa-check me-2"/>
Speichern
</button>
</div>
</div>
</div>
</t>
</templates>

View File

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Erweitere das Employee Form View -->
<record id="view_employee_form_image_generator" model="ir.ui.view">
<field name="name">hr.employee.form.image.generator</field>
<field name="model">hr.employee</field>
<field name="inherit_id" ref="hr.view_employee_form"/>
<field name="arch" type="xml">
<!-- Füge Button zum Header hinzu -->
<xpath expr="//sheet" position="before">
<header>
<button name="action_open_image_generator"
type="object"
string="Namensschild erstellen"
class="oe_highlight"
icon="fa-image"
help="Erstelle ein professionelles Namensschild mit Foto"/>
</header>
</xpath>
<!-- Füge job_focus Feld nach job_title ein -->
<xpath expr="//field[@name='job_title']" position="after">
<field name="job_focus" placeholder="z.B. Fahrrad-Reparatur, E-Bikes, Werkstatt"/>
</xpath>
</field>
</record>
</odoo>