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:
parent
5d1faa7b5b
commit
daddb51327
46
open_workshop_pos_customer_display/README.md
Normal file
46
open_workshop_pos_customer_display/README.md
Normal 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
|
||||
2
open_workshop_pos_customer_display/__init__.py
Normal file
2
open_workshop_pos_customer_display/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from . import models
|
||||
31
open_workshop_pos_customer_display/__manifest__.py
Normal file
31
open_workshop_pos_customer_display/__manifest__.py
Normal 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',
|
||||
}
|
||||
5
open_workshop_pos_customer_display/models/__init__.py
Normal file
5
open_workshop_pos_customer_display/models/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from . import pos_session
|
||||
from . import pos_config
|
||||
from . import hr_employee
|
||||
from . import res_users
|
||||
10
open_workshop_pos_customer_display/models/hr_employee.py
Normal file
10
open_workshop_pos_customer_display/models/hr_employee.py
Normal 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']
|
||||
41
open_workshop_pos_customer_display/models/pos_config.py
Normal file
41
open_workshop_pos_customer_display/models/pos_config.py
Normal 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
|
||||
35
open_workshop_pos_customer_display/models/pos_session.py
Normal file
35
open_workshop_pos_customer_display/models/pos_session.py
Normal 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
|
||||
14
open_workshop_pos_customer_display/models/res_users.py
Normal file
14
open_workshop_pos_customer_display/models/res_users.py
Normal 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
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue
Block a user