Umstellung: Employee Image Generator speichert nur Foto, Customer Display baut Badge dynamisch

Employee Image Generator:
- Speichert nur zugeschnittenes 369×492px Foto (nicht mehr A4-Badge)
- Speichert job_focus zusätzlich zu image_1920
- html2canvas Dependency entfernt
- Vorschau bleibt zur Visualisierung

Customer Display:
- Baut Badge dynamisch aus Employee-Daten (Foto, Name, job_focus)
- Responsive Design basierend auf thekenheld print.html
- Neues employee_badge.css für Screen-Optimierung
- Footer bleibt immer am unteren Bildschirmrand
- job_focus mit max-height und overflow für lange Texte
- Lädt job_focus über hr.employee ID (nicht user_id)

Vorteile: Flexibler, aktuelle Daten, kleinere Dateien, wiederverwendbares Foto
This commit is contained in:
Matthias Lotz 2026-01-12 19:10:43 +01:00
parent 98c96018d6
commit 00df958985
7 changed files with 213 additions and 75 deletions

View File

@ -3,28 +3,29 @@
'name': 'Open Workshop - Employee Image Generator',
'version': '18.0.1.0.0',
'category': 'Human Resources',
'summary': 'Generate professional employee name badges with photo cropping',
'summary': 'Upload and crop employee photos (369x492) with job focus',
'description': """
Employee Image Generator
=========================
Creates professional employee name badges (Thekenschilder) directly in Odoo.
Upload and crop employee photos directly in Odoo.
Features:
---------
* Upload and crop employee photos with fixed aspect ratio (369x492)
* Add employee name and job description
* Upload and crop employee photos to fixed 369x492 pixels
* Add job focus areas (Schwerpunkte)
* Integrated Cropper.js for professional image editing
* Fixed crop frame - only image moves and zooms
* Direct integration in Employee form
* Replaces external thekenheld webapp
* Saved photo can be used in POS Customer Display
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
3. Upload photo, zoom and position it in fixed crop frame
4. Enter job focus areas (optional)
5. Save - photo and job_focus are saved to employee
""",
'author': 'Open Workshop',
'website': 'https://www.open-workshop.de',
@ -39,9 +40,8 @@
'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
# Cropper.js Library
'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',

View File

@ -166,46 +166,25 @@ class EmployeeImageGenerator extends Component {
// Keine zusätzliche Logik nötig - OWL reactive state macht das automatisch!
}
// Schritt 4: Finale Speicherung - Konvertiere HTML-Template zu Bild mit html2canvas
// Schritt 4: Finale Speicherung - Speichere nur das zugeschnittene Foto + job_focus
async saveFinalImage() {
try {
const badgeElement = this.badgeTemplate.el;
if (!badgeElement) {
this.notification.add('Badge-Template nicht gefunden', { type: 'danger' });
// Verwende das bereits zugeschnittene Foto (369×492px)
if (!this.state.croppedImageDataUrl) {
this.notification.add('Kein zugeschnittenes Foto vorhanden', { type: 'danger' });
return;
}
// Entferne temporär die Skalierung vom Wrapper (nicht vom Badge selbst)
const wrapper = badgeElement.parentElement;
const originalTransform = wrapper ? wrapper.style.transform : '';
if (wrapper) {
wrapper.style.transform = 'none';
}
// Konvertiere HTML-Element zu Canvas mit html2canvas in voller A4-Größe
const canvas = await window.html2canvas(badgeElement, {
width: this.badgeWidth,
height: this.badgeHeight,
scale: 1, // Wichtig: keine zusätzliche Skalierung
backgroundColor: '#ffffff',
});
// Stelle die Skalierung für die Vorschau wieder her
if (wrapper) {
wrapper.style.transform = originalTransform;
}
// Konvertiere Canvas zu Base64 PNG
const finalImageBase64 = canvas.toDataURL('image/png');
// Speichere in Odoo (entferne data:image/png;base64, prefix)
const imageData = finalImageBase64.split(',')[1];
// Extrahiere Base64-Daten (entferne data:image/jpeg;base64, prefix)
const imageData = this.state.croppedImageDataUrl.split(',')[1];
// Speichere Foto + job_focus in Odoo
await this.orm.write('hr.employee', [this.state.employeeId], {
image_1920: imageData,
job_focus: this.state.jobFocus || '',
});
this.notification.add('Namensschild erfolgreich gespeichert!', {
this.notification.add('Foto erfolgreich gespeichert!', {
type: 'success',
});

View File

@ -3,11 +3,21 @@
'name': 'Open Workshop - POS Customer Display Customization',
'version': '18.0.1.0.0',
'category': 'Point of Sale',
'summary': 'Anpassungen am POS Customer Display Sidebar',
'summary': 'Show employee badge (Thekenschild) in POS Customer Display',
'description': """
Dieses Modul erweitert das POS Customer Display und passt die Sidebar an.
- Zeigt das Bild des aktuellen Kassierers in der Sidebar an
- Aktualisiert die Anzeige bei Kassiererwechsel
POS Customer Display - Employee Badge
======================================
Zeigt ein dynamisches Mitarbeiter-Namensschild im POS Customer Display.
Features:
---------
* Baut Namensschild dynamisch aus Employee-Daten auf
* Zeigt Mitarbeiterfoto (von open_workshop_employee_imagegenerator)
* Zeigt Name und Schwerpunkte (job_focus)
* Responsive Design basierend auf thekenheld print.html
* Aktualisiert automatisch bei Kassiererwechsel
* Fallback auf Company-Logo wenn kein Foto vorhanden
""",
'author': 'Open Workshop',
'depends': [
@ -20,6 +30,7 @@
'open_workshop_pos_customer_display/static/src/js/pos_store.js',
],
'point_of_sale.customer_display_assets': [
'open_workshop_pos_customer_display/static/src/css/employee_badge.css',
'open_workshop_pos_customer_display/static/src/js/customer_display.js',
'open_workshop_pos_customer_display/static/src/xml/customer_display.xml',
],

View File

@ -0,0 +1,109 @@
/* Employee Badge Design - Basierend auf thekenheld print.html */
/* Responsive Version für Customer Display - Screen-optimiert */
.employee-badge-container {
width: 100%;
min-height: 100vh;
background: white;
display: flex;
flex-direction: column;
position: relative;
}
/* Header */
.employee-badge-header {
background-color: rgb(255, 253, 253);
padding: 1.5vh 4vw;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.employee-badge-logo {
max-width: 15vw;
max-height: 7vh;
height: auto;
object-fit: contain;
}
/* Content - wächst und scrollt bei Bedarf */
.employee-badge-content {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 2vh 3vw;
overflow-y: auto;
overflow-x: hidden;
}
.employee-badge-headline1 {
font-size: clamp(1.2rem, 3.5vw, 2.5rem);
font-weight: bold;
margin-bottom: 1vh;
}
.employee-badge-headline2 {
font-size: clamp(0.8rem, 2vw, 1.5rem);
margin-bottom: 2vh;
}
.employee-badge-photo {
max-width: 40%;
max-height: 45%;
width: auto;
height: auto;
object-fit: cover;
margin: 2vh 0;
border-radius: 4px;
}
.employee-badge-name {
font-size: clamp(1.5rem, 5vw, 4rem);
font-weight: bold;
margin: 2vh 0 1vh 0;
line-height: 1.2;
}
.employee-badge-divider {
width: 80%;
border: 1px solid #333;
margin: 1vh 0;
}
.employee-badge-headline3 {
font-size: clamp(1rem, 2.5vw, 1.8rem);
font-weight: bold;
margin: 1vh 0;
}
.employee-badge-topics {
font-size: clamp(0.9rem, 2.2vw, 1.6rem);
margin: 1vh 0;
max-width: 80%;
max-height: 15vh;
overflow-y: auto;
overflow-wrap: break-word;
word-wrap: break-word;
hyphens: auto;
}
/* Footer - bleibt immer am unteren Bildschirmrand */
.employee-badge-footer {
background-color: rgb(255, 255, 255);
color: red;
padding: 1.5vh 3vw;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.employee-badge-warning {
font-size: clamp(0.9rem, 2.5vw, 2rem);
font-weight: bold;
text-align: center;
}

View File

@ -4,9 +4,9 @@ import { patch } from "@web/core/utils/patch";
import { CustomerDisplay } from "@point_of_sale/customer_display/customer_display";
/**
* Erweitert das Customer Display um die Anzeige des aktuellen Kassierers.
* Erweitert das Customer Display um die Anzeige des aktuellen Kassierers als Badge.
* Lauscht auf BroadcastChannel Messages von der POS und aktualisiert
* das angezeigte Mitarbeiterbild dynamisch.
* das angezeigte Mitarbeiterbild + job_focus dynamisch.
*/
patch(CustomerDisplay.prototype, {
setup() {
@ -30,8 +30,6 @@ patch(CustomerDisplay.prototype, {
/**
* Gibt die URL des Mitarbeiterbilds zurück.
* Verwendet nur BroadcastChannel-Daten, kein Session-Fallback.
* Wenn keine Daten vorhanden sind, wird das Firmenlogo angezeigt.
*/
get currentEmployeeImage() {
return this.employeeData?.employee_image_url ||
@ -46,6 +44,20 @@ patch(CustomerDisplay.prototype, {
return this.employeeData?.employee_name || this.data?.employeeData?.employee_name || '';
},
/**
* Gibt die Schwerpunkte des aktuellen Mitarbeiters zurück.
*/
get currentEmployeeJobFocus() {
return this.employeeData?.employee_job_focus || this.data?.employeeData?.employee_job_focus || '';
},
/**
* Gibt die URL des Company-Logos zurück.
*/
get companyLogo() {
return `/logo?company=${this.session.company_id}`;
},
/**
* Prüft ob ein Mitarbeiterbild vorhanden ist.
*/

View File

@ -45,21 +45,37 @@ patch(Chrome.prototype, {
/**
* Sendet die Kassierer-Informationen an das Customer Display.
* Verwendet die gleiche URL-Struktur wie der POS Navbar für Employee-Bilder.
* Lädt auch job_focus aus hr.employee.
*
* @param {Object} cashier - Der aktuelle Kassierer (res.users)
*/
sendCashierToCustomerDisplay(cashier) {
async sendCashierToCustomerDisplay(cashier) {
if (!cashier) {
return;
}
// Verwende hr.employee.public für das Avatar-Bild
// (gleiche URL wie im POS Navbar verwendet wird)
// Hole job_focus direkt mit der cashier.id (ist bereits hr.employee ID)
let jobFocus = '';
try {
const employees = await this.pos.data.searchRead('hr.employee', [['id', '=', cashier.id]], ['job_focus']);
console.log('[POS] Employee search for employee_id', cashier.id, ':', employees);
if (employees && employees.length > 0) {
jobFocus = employees[0].job_focus || '';
console.log('[POS] job_focus loaded:', jobFocus);
}
} catch (error) {
console.error('[POS] Fehler beim Laden von job_focus:', error);
}
// Verwende hr.employee.public mit cashier.id
const employeeData = {
employee_id: cashier.id,
employee_name: cashier.name,
employee_image_url: `/web/image/hr.employee.public/${cashier.id}/avatar_1920`
employee_image_url: `/web/image/hr.employee.public/${cashier.id}/avatar_1920`,
employee_job_focus: jobFocus,
};
console.log('[POS] Sending to Customer Display:', employeeData);
// Füge employeeData zur Order-Display-Data hinzu
const selectedOrder = this.pos.get_order();

View File

@ -8,34 +8,45 @@
<div class="o_customer_display_sidebar d-flex flex-column align-items-center justify-content-center p-0 bg-view position-relative"
style="min-width: 30%; min-height: 100%; overflow: hidden;">
<!-- Kassiererbild als IMG (volle Größe, Seitenverhältnis beibehalten) -->
<img t-if="currentEmployeeImage"
t-att-src="currentEmployeeImage"
t-att-alt="currentEmployeeName"
class="position-absolute top-0 start-0 w-100 h-100 employee-image"
style="object-fit: contain; object-position: center;"
t-on-error="(ev) => ev.target.style.display = 'none'"/>
<!-- Employee Badge - dynamisch aufgebaut -->
<div t-if="currentEmployeeImage" class="employee-badge-container">
<!-- Header -->
<div class="employee-badge-header">
<img t-if="companyLogo"
t-att-src="companyLogo"
alt="Logo"
class="employee-badge-logo"/>
</div>
<!-- Content -->
<div class="employee-badge-content">
<div class="employee-badge-headline1">An der Theke für Dich</div>
<div class="employee-badge-headline2">Werkstattaufsicht und Werkzeugausgabe</div>
<img t-att-src="currentEmployeeImage"
t-att-alt="currentEmployeeName"
class="employee-badge-photo"
t-on-error="(ev) => ev.target.style.display = 'none'"/>
<div class="employee-badge-name" t-esc="currentEmployeeName || 'Dein Name'"/>
<hr class="employee-badge-divider"/>
<div class="employee-badge-headline3">Themenschwerpunkte:</div>
<div class="employee-badge-topics" t-esc="currentEmployeeJobFocus || 'Deine Schwerpunkte'"/>
</div>
<!-- Footer -->
<div class="employee-badge-footer">
<div class="employee-badge-warning">! Zugang zur Theke nur für Berechtigte !</div>
</div>
</div>
<!-- Fallback: Logo wenn kein Kassiererbild vorhanden oder Ladefehler -->
<!-- Fallback: Logo wenn kein Kassiererbild vorhanden -->
<div t-if="!currentEmployeeImage"
class="o_customer_display_logo d-flex align-items-center justify-content-center w-100 h-100 p-4">
<img class="img-fluid logo-image" t-attf-src="/logo?company={{session.company_id}}" style="max-width: 80%; max-height: 80%;"/>
</div>
<!-- Logo als Fallback auch wenn Employee-Image existiert aber nicht lädt -->
<div t-if="currentEmployeeImage"
class="o_customer_display_logo d-flex align-items-center justify-content-center w-100 h-100 p-4 position-absolute top-0 start-0"
style="pointer-events: none; z-index: -1;">
<img class="img-fluid" t-attf-src="/logo?company={{session.company_id}}" style="max-width: 80%; max-height: 80%;"/>
</div>
<!-- Optional: Kassierer-Name als Overlay unten -->
<div t-if="currentEmployeeImage and currentEmployeeName"
class="position-absolute bottom-0 w-100 mb-2 d-flex justify-content-center">
<div class="px-3 py-2 rounded-3 text-bg-dark bg-opacity-75 small text-center">
<t t-esc="currentEmployeeName"/>
</div>
</div>
</div>
</xpath>