diff --git a/open_workshop_employee_imagegenerator/README.md b/open_workshop_employee_imagegenerator/README.md new file mode 100644 index 0000000..01cf2a5 --- /dev/null +++ b/open_workshop_employee_imagegenerator/README.md @@ -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 diff --git a/open_workshop_employee_imagegenerator/__init__.py b/open_workshop_employee_imagegenerator/__init__.py new file mode 100644 index 0000000..a0fdc10 --- /dev/null +++ b/open_workshop_employee_imagegenerator/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import models diff --git a/open_workshop_employee_imagegenerator/__manifest__.py b/open_workshop_employee_imagegenerator/__manifest__.py new file mode 100644 index 0000000..7731409 --- /dev/null +++ b/open_workshop_employee_imagegenerator/__manifest__.py @@ -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, +} diff --git a/open_workshop_employee_imagegenerator/models/__init__.py b/open_workshop_employee_imagegenerator/models/__init__.py new file mode 100644 index 0000000..62e0430 --- /dev/null +++ b/open_workshop_employee_imagegenerator/models/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import hr_employee diff --git a/open_workshop_employee_imagegenerator/models/hr_employee.py b/open_workshop_employee_imagegenerator/models/hr_employee.py new file mode 100644 index 0000000..504707b --- /dev/null +++ b/open_workshop_employee_imagegenerator/models/hr_employee.py @@ -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 '', + } + } diff --git a/open_workshop_employee_imagegenerator/static/src/css/badge_template.css b/open_workshop_employee_imagegenerator/static/src/css/badge_template.css new file mode 100644 index 0000000..34b419b --- /dev/null +++ b/open_workshop_employee_imagegenerator/static/src/css/badge_template.css @@ -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; +} diff --git a/open_workshop_employee_imagegenerator/static/src/css/employee_image_widget.css b/open_workshop_employee_imagegenerator/static/src/css/employee_image_widget.css new file mode 100644 index 0000000..007ac12 --- /dev/null +++ b/open_workshop_employee_imagegenerator/static/src/css/employee_image_widget.css @@ -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; + } +} diff --git a/open_workshop_employee_imagegenerator/static/src/js/employee_image_widget.js b/open_workshop_employee_imagegenerator/static/src/js/employee_image_widget.js new file mode 100644 index 0000000..ed863ec --- /dev/null +++ b/open_workshop_employee_imagegenerator/static/src/js/employee_image_widget.js @@ -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); diff --git a/open_workshop_employee_imagegenerator/static/src/xml/employee_image_widget.xml b/open_workshop_employee_imagegenerator/static/src/xml/employee_image_widget.xml new file mode 100644 index 0000000..dbc35f8 --- /dev/null +++ b/open_workshop_employee_imagegenerator/static/src/xml/employee_image_widget.xml @@ -0,0 +1,173 @@ + + + + + + + + + + Namensschild erstellen + + + + + + + + + + + + + 1. Bild hochladen + 2. Bild zuschneiden + 3. Text & Vorschau + + + + + + + Wähle ein Foto aus + Das Bild sollte mindestens 369x492 Pixel groß sein + + + + Datei auswählen + + + + + + + + Bewege und zoome das Bild, um den gewünschten Ausschnitt zu wählen + + + + + + + + + + + + Textinformationen + + + Name + + + Dieser Name wird auf dem Namensschild angezeigt + + + + + Schwerpunkte + + + Tätigkeitsbereiche oder Spezialisierung (optional) + + + + + Tipp: Kurze Texte sehen besser aus. + Maximal 2 Zeilen für Schwerpunkte. + + + + + + Vorschau + + + + + + + + + + An der Theke für Dich + Werkstattaufsicht und Werkzeugausgabe + + + + Themenschwerpunkte: + + ! Zugang zur Theke nur für Berechtigte ! + + + + + + A4-Format (793 × 1122 Pixel) - 50% Vorschau + + + + + + + + + + + + diff --git a/open_workshop_employee_imagegenerator/views/hr_employee_views.xml b/open_workshop_employee_imagegenerator/views/hr_employee_views.xml new file mode 100644 index 0000000..482ebc1 --- /dev/null +++ b/open_workshop_employee_imagegenerator/views/hr_employee_views.xml @@ -0,0 +1,27 @@ + + + + + hr.employee.form.image.generator + hr.employee + + + + + + + + + + + + + + + +
Das Bild sollte mindestens 369x492 Pixel groß sein