Add POS customer display customization module

- Show current cashier's employee image in customer display sidebar
- Image updates automatically when cashier changes
- Fallback to company logo when no image available or before first order
- Uses BroadcastChannel to sync between POS and customer display
- Full-size image display with correct aspect ratio (object-fit: contain)
This commit is contained in:
Matthias Lotz 2026-01-10 14:49:50 +01:00
parent 5d1faa7b5b
commit daddb51327
11 changed files with 371 additions and 0 deletions

View File

@ -0,0 +1,46 @@
# Open Workshop - POS Customer Display Customization
## Beschreibung
Dieses Modul passt das POS Customer Display in Odoo 18 an und zeigt Informationen über den aktuellen Kassierer in der Sidebar an.
## Features
- **Kassiererbild**: Zeigt das Profilbild des aktuellen Kassierers (aus Personal/HR) in voller Sidebar-Größe
- **Automatische Aktualisierung**: Die Anzeige wird automatisch aktualisiert, wenn der Kassierer wechselt
- **Responsive Design**: Das Bild wird mit korrektem Seitenverhältnis skaliert (cover-Modus)
- **Fallback**: Bei fehlendem Bild wird das User-Avatar oder Company-Logo angezeigt
- **Kassierer-Name**: Optional wird der Name des Kassierers als Overlay am unteren Rand angezeigt
## Installation
1. Modul in den Addon-Pfad kopieren
2. Apps-Liste aktualisieren
3. Modul installieren
4. **Wichtig**: Stelle sicher, dass in Personal/Mitarbeiter ein Bild für die Kassierer hinterlegt ist
## Technische Details
### Dateien
- `__manifest__.py` - Modul-Definition mit Abhängigkeiten
- `static/src/js/customer_display.js` - JavaScript-Komponente für Employee-Daten
- `static/src/xml/customer_display.xml` - Template-Override für die Sidebar
### Funktionsweise
1. Beim Laden des Customer Displays wird der aktuelle User ermittelt
2. Der zugehörige Employee-Datensatz wird aus `hr.employee` geladen
3. Das Bild wird über `/web/image/hr.employee/{id}/image_1920` angezeigt
4. Bei Kassiererwechsel wird die Komponente neu gerendert
### Anpassungen
Die Sidebar verwendet folgende CSS-Eigenschaften für das Bild:
- `background-size: cover` - Bild füllt den gesamten Bereich
- `background-position: center` - Bild wird zentriert
- Das Seitenverhältnis wird automatisch beibehalten
## Abhängigkeiten
- point_of_sale
- hr (Personal/Human Resources)
## Version
18.0.1.0.0
## Lizenz
LGPL-3

View File

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

View File

@ -0,0 +1,31 @@
# -*- coding: utf-8 -*-
{
'name': 'Open Workshop - POS Customer Display Customization',
'version': '18.0.1.0.0',
'category': 'Point of Sale',
'summary': 'Anpassungen am POS Customer Display Sidebar',
'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
""",
'author': 'Open Workshop',
'depends': [
'point_of_sale',
'hr',
],
'data': [],
'assets': {
'point_of_sale.assets_prod': [
'open_workshop_pos_customer_display/static/src/js/pos_store.js',
],
'point_of_sale.customer_display_assets': [
'open_workshop_pos_customer_display/static/src/js/customer_display.js',
'open_workshop_pos_customer_display/static/src/xml/customer_display.xml',
],
},
'installable': True,
'application': False,
'auto_install': False,
'license': 'LGPL-3',
}

View File

@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
from . import pos_session
from . import pos_config
from . import hr_employee
from . import res_users

View File

@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
from odoo import models
class HrEmployee(models.Model):
_inherit = 'hr.employee'
def _load_pos_data_fields(self, config_id):
"""Define which fields to load for hr.employee in POS"""
return ['id', 'name', 'user_id', 'image_1920']

View File

@ -0,0 +1,41 @@
# -*- coding: utf-8 -*-
from odoo import models
class PosConfig(models.Model):
_inherit = 'pos.config'
def _get_customer_display_data(self):
"""Erweitere Customer Display Daten um Employee-Informationen"""
result = super()._get_customer_display_data()
# Hole die aktuelle offene Session für diese POS Config
session = self.env['pos.session'].search([
('config_id', '=', self.id),
('state', '=', 'opened')
], limit=1)
if not session:
return result
# Hole den aktuellen Kassierer aus der Session
# Der aktuelle Kassierer wird in pos.session nicht gespeichert,
# sondern nur im Frontend. Daher verwenden wir den Session-User als Fallback.
user_id = session.user_id.id
# Hole Employee-Daten für den User
employee = self.env['hr.employee'].search([
('user_id', '=', user_id)
], limit=1)
if employee and employee.image_1920:
result['employee_id'] = employee.id
result['employee_name'] = employee.name
result['employee_image_url'] = f'/web/image/hr.employee/{employee.id}/image_1920'
else:
# Fallback: User-Avatar
result['employee_id'] = user_id
result['employee_name'] = self.env['res.users'].browse(user_id).name
result['employee_image_url'] = f'/web/image/res.users/{user_id}/avatar_1920'
return result

View File

@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
from odoo import models, api
class PosSession(models.Model):
_inherit = 'pos.session'
@api.model
def _load_pos_data_models(self, config_id):
"""Füge hr.employee zu den zu ladenden Modellen hinzu"""
models = super()._load_pos_data_models(config_id)
if 'hr.employee' not in models:
models.append('hr.employee')
return models
def _load_pos_data(self, data):
"""Erweitere Session-Daten um Employee-Informationen"""
result = super()._load_pos_data(data)
# Hole Employee-Daten des aktuellen Users
employee = self.env['hr.employee'].search([
('user_id', '=', self.env.uid)
], limit=1)
if employee and employee.image_1920:
result['data'][0]['employee_id'] = employee.id
result['data'][0]['employee_name'] = employee.name
result['data'][0]['employee_image_url'] = f'/web/image/hr.employee/{employee.id}/image_1920'
else:
# Fallback: User-Avatar
result['data'][0]['employee_id'] = self.env.uid
result['data'][0]['employee_name'] = self.env.user.name
result['data'][0]['employee_image_url'] = f'/web/image/res.users/{self.env.uid}/avatar_1920'
return result

View File

@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-
from odoo import models
class ResUsers(models.Model):
_inherit = 'res.users'
def _load_pos_data_fields(self, config_id):
"""Define which fields to load for res.users in POS"""
fields = super()._load_pos_data_fields(config_id)
# Add employee_ids to load the related employee
if 'employee_ids' not in fields:
fields.append('employee_ids')
return fields

View File

@ -0,0 +1,52 @@
/** @odoo-module */
import { patch } from "@web/core/utils/patch";
import { CustomerDisplay } from "@point_of_sale/customer_display/customer_display";
console.log('[Employee Display] customer_display.js loaded');
// Patch CustomerDisplay to show employee data
patch(CustomerDisplay.prototype, {
setup() {
super.setup(...arguments);
console.log('[Employee Display] CustomerDisplay.setup(), initial this.data:', this.data);
console.log('[Employee Display] Session:', this.session);
// Listen to BroadcastChannel to receive employee updates
this.employeeData = null;
if (this.session.type === "local") {
const channel = new BroadcastChannel("UPDATE_CUSTOMER_DISPLAY");
channel.onmessage = (event) => {
console.log('[Employee Display] ✅ BroadcastChannel message received:', event.data);
if (event.data.employeeData) {
console.log('[Employee Display] Employee data:', event.data.employeeData);
console.log('[Employee Display] Image URL:', event.data.employeeData.employee_image_url);
this.employeeData = event.data.employeeData;
// Trigger re-render
this.render();
}
};
}
},
get currentEmployeeImage() {
// Only use BroadcastChannel data, no session fallback
// This ensures we show the logo until we get the real cashier data
const image = this.employeeData?.employee_image_url ||
this.data?.employeeData?.employee_image_url ||
'';
console.log('[Employee Display] currentEmployeeImage returning:', image);
console.log('[Employee Display] Source: this.employeeData:', this.employeeData?.employee_image_url,
'this.data:', this.data?.employeeData?.employee_image_url);
return image;
},
get currentEmployeeName() {
// Only use BroadcastChannel data, no session fallback
return this.employeeData?.employee_name || this.data?.employeeData?.employee_name || '';
},
get hasEmployeeImage() {
return Boolean(this.currentEmployeeImage);
}
});

View File

@ -0,0 +1,87 @@
/** @odoo-module */
import { patch } from "@web/core/utils/patch";
import { Chrome } from "@point_of_sale/app/pos_app";
import { effect } from "@web/core/utils/reactive";
import { batched } from "@web/core/utils/timing";
console.log('[Employee Display] pos_store.js loaded');
patch(Chrome.prototype, {
setup() {
console.log('[Employee Display] Chrome.setup() patched - starting');
super.setup(...arguments);
console.log('[Employee Display] customer_display_type:', this.pos.config.customer_display_type);
if (this.pos.config.customer_display_type === "none") {
console.log('[Employee Display] Customer display disabled, skipping');
return;
}
console.log('[Employee Display] Setting up cashier effect');
// Add reactive effect for cashier changes (same pattern as order effect in Chrome)
effect(
batched(({ cashier }) => {
console.log('[Employee Display] Effect triggered! cashier:', cashier);
if (cashier) {
this.sendCashierToCustomerDisplay(cashier);
}
}),
[this.pos]
);
// Also send when any order changes (piggyback on order updates)
// This ensures customer display gets cashier data even if opened late
const originalSendOrder = this.sendOrderToCustomerDisplay.bind(this);
this.sendOrderToCustomerDisplay = function(selectedOrder, scaleData) {
originalSendOrder(selectedOrder, scaleData);
// Also send cashier data with every order update
const cashier = this.pos.get_cashier();
if (cashier) {
this.sendCashierToCustomerDisplay(cashier);
}
};
},
sendCashierToCustomerDisplay(cashier) {
console.log('[Employee Display] sendCashierToCustomerDisplay called with:', cashier);
if (!cashier) {
console.log('[Employee Display] No cashier, skipping');
return;
}
// Use cashier directly - same as POS navbar uses hr.employee.public
const employeeData = {
employee_id: cashier.id,
employee_name: cashier.name,
employee_image_url: `/web/image/hr.employee.public/${cashier.id}/avatar_1920`
};
console.log('[Employee Display] Created employeeData:', employeeData);
// Get current order data and add employee data
const selectedOrder = this.pos.get_order();
console.log('[Employee Display] Current order:', selectedOrder);
if (selectedOrder) {
const customerDisplayData = selectedOrder.getCustomerDisplayData();
customerDisplayData.employeeData = employeeData;
console.log('[Employee Display] Sending to customer display:', customerDisplayData);
console.log('[Employee Display] employeeData in object:', customerDisplayData.employeeData);
console.log('[Employee Display] Full object keys:', Object.keys(customerDisplayData));
// Send using the same channels as order updates
if (this.pos.config.customer_display_type === "local") {
console.log('[Employee Display] Posting to BroadcastChannel');
this.customerDisplayChannel.postMessage(customerDisplayData);
}
if (this.pos.config.customer_display_type === "remote") {
this.pos.data.call("pos.config", "update_customer_display", [
[this.pos.config.id],
customerDisplayData,
this.pos.config.access_token,
]);
}
}
},
});

View File

@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<!-- Override des customer_display_sidebar -->
<t t-inherit="point_of_sale.CustomerDisplay" t-inherit-mode="extension">
<!-- Ersetze den kompletten Sidebar-Inhalt -->
<xpath expr="//div[hasclass('o_customer_display_sidebar')]" position="replace">
<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'"/>
<!-- Fallback: Logo wenn kein Kassiererbild vorhanden oder Ladefehler -->
<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>
<!-- Debug Info -->
<div class="position-absolute top-0 start-0 p-2 text-white bg-dark small" style="z-index: 1000;">
Debug: <t t-esc="session.employee_image_url or 'NO IMAGE URL'"/>
</div>
</div>
</xpath>
</t>
</templates>