forked from alaguraj/odoo-testing-addons
Introduce comprehensive order channel management, including online, self-order, and KDS integration, with detailed fulfilment and delivery options for POS orders and receipts.
This commit is contained in:
parent
216c627369
commit
7aafe0c6fb
50
.agents/workflows/testing.md
Normal file
50
.agents/workflows/testing.md
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
---
|
||||||
|
description: Dine360 End-to-End (E2E) Integration Testing Workflow
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🚀 Dine360 E2E Testing Workflow
|
||||||
|
|
||||||
|
This workflow ensures all modules (`Self-Order`, `Online Orders`, `KDS`, and `POS`) are communicating correctly.
|
||||||
|
|
||||||
|
## 1. Environment Check
|
||||||
|
Before testing, verify the services are up:
|
||||||
|
// turbo
|
||||||
|
`docker ps`
|
||||||
|
Ensure `odoo_client2` and `db` are in a 'Healthy' or 'Up' state.
|
||||||
|
|
||||||
|
## 2. Setup POS Session
|
||||||
|
1. Open your Odoo instance (usually `http://localhost:8069`).
|
||||||
|
2. Go to **Point of Sale**.
|
||||||
|
3. **Open** a new session for your main Shop/Restaurant.
|
||||||
|
|
||||||
|
## 3. Test Flow: Self-Order (Table QR)
|
||||||
|
1. Go to **Point of Sale > Configuration > Floor Plans**.
|
||||||
|
2. Select a floor and a table (e.g., "Table 1").
|
||||||
|
3. Click the **Open Front-end** button (this opens the Self-Order menu).
|
||||||
|
4. **Action**: Add 2-3 items to the cart and click **Send to Kitchen**.
|
||||||
|
5. **Verification**:
|
||||||
|
- [ ] Go to the **Kitchen (KDS)** module.
|
||||||
|
- [ ] Check that the items appear in the **Waiting** column.
|
||||||
|
- [ ] Confirm the source badge shows **QR Table Order / Table 1**.
|
||||||
|
|
||||||
|
## 4. Test Flow: Online Orders (Website)
|
||||||
|
1. Navigate to the Website Shop (`/shop`).
|
||||||
|
2. **Action**: Add items to the cart, proceed to checkout, and complete the order.
|
||||||
|
3. **Internal POS Verification**:
|
||||||
|
- [ ] Open the POS UI.
|
||||||
|
- [ ] Click the **Online Orders** tab in the top navbar.
|
||||||
|
- [ ] Select your order and click **Confirm & Send to Kitchen**.
|
||||||
|
4. **KDS Verification**:
|
||||||
|
- [ ] Check the **Kitchen (KDS)** module.
|
||||||
|
- [ ] Source badge should show **Online / eCommerce**.
|
||||||
|
|
||||||
|
## 5. Test Flow: KDS Management
|
||||||
|
1. In the **Kitchen (KDS)** dashboard:
|
||||||
|
2. **Action**: Click the **Preparing** button on one of the cards.
|
||||||
|
3. **Action**: Click the **Ready** button when finished.
|
||||||
|
4. **Verification**:
|
||||||
|
- [ ] Confirm the item moves to the correct column.
|
||||||
|
- [ ] If you are in the POS UI, check if any notifications appear regarding readiness (if implemented).
|
||||||
|
|
||||||
|
## 6. Verification Summary
|
||||||
|
If all checks above pass, the integration between the Frontend (Customer), Middle-end (POS), and Backend (KDS) is working perfectly.
|
||||||
@ -12,7 +12,7 @@
|
|||||||
- Floor/Table based organization
|
- Floor/Table based organization
|
||||||
""",
|
""",
|
||||||
'author': 'Dine360',
|
'author': 'Dine360',
|
||||||
'depends': ['point_of_sale', 'pos_restaurant', 'dine360_restaurant', 'sale_management', 'website_sale'],
|
'depends': ['point_of_sale', 'pos_restaurant', 'dine360_restaurant', 'sale_management', 'website_sale', 'dine360_order_channels'],
|
||||||
'data': [
|
'data': [
|
||||||
'security/ir.model.access.csv',
|
'security/ir.model.access.csv',
|
||||||
'views/pos_order_line_views.xml',
|
'views/pos_order_line_views.xml',
|
||||||
|
|||||||
@ -4,13 +4,14 @@ import { registry } from "@web/core/registry";
|
|||||||
import { KanbanController } from "@web/views/kanban/kanban_controller";
|
import { KanbanController } from "@web/views/kanban/kanban_controller";
|
||||||
import { kanbanView } from "@web/views/kanban/kanban_view";
|
import { kanbanView } from "@web/views/kanban/kanban_view";
|
||||||
import { onWillUnmount } from "@odoo/owl";
|
import { onWillUnmount } from "@odoo/owl";
|
||||||
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
|
||||||
export class KdsKanbanController extends KanbanController {
|
export class KdsKanbanController extends KanbanController {
|
||||||
setup() {
|
setup() {
|
||||||
super.setup();
|
super.setup();
|
||||||
console.log("[KDS Controller] Setup");
|
console.log("[KDS Controller] Setup");
|
||||||
|
|
||||||
// Direct access to services to avoid useService potential conflicts
|
// Direct access to services to avoid 'methods is not iterable' error in Owl lifecycle
|
||||||
this.busService = this.env.services.bus_service;
|
this.busService = this.env.services.bus_service;
|
||||||
this.notification = this.env.services.notification;
|
this.notification = this.env.services.notification;
|
||||||
|
|
||||||
@ -28,8 +29,6 @@ export class KdsKanbanController extends KanbanController {
|
|||||||
this.busService.removeEventListener("notification", handler);
|
this.busService.removeEventListener("notification", handler);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
console.error("[KDS Controller] Bus service not found!");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,11 +12,13 @@
|
|||||||
<field name="order_id"/>
|
<field name="order_id"/>
|
||||||
<field name="table_id"/>
|
<field name="table_id"/>
|
||||||
<field name="floor_id"/>
|
<field name="floor_id"/>
|
||||||
|
<field name="order_source"/>
|
||||||
|
<field name="fulfilment_type"/>
|
||||||
<field name="customer_note"/>
|
<field name="customer_note"/>
|
||||||
<field name="create_date"/>
|
<field name="create_date"/>
|
||||||
<templates>
|
<templates>
|
||||||
<t t-name="kanban-box">
|
<t t-name="kanban-box">
|
||||||
<div t-attf-class="oe_kanban_global_click role_kitchen_card {{'oe_kanban_color_' + kanban_getcolor(record.color.raw_value)}} shadow-sm border-0 mb-3" style="border-radius: 12px; border-left: 5px solid #fecd4f !important;">
|
<div t-attf-class="oe_kanban_global_click role_kitchen_card #{'oe_kanban_color_' + kanban_getcolor(record.color.raw_value)} shadow-sm border-0 mb-3" style="border-radius: 12px; border-left: 5px solid #fecd4f !important;">
|
||||||
<div class="oe_kanban_content p-3">
|
<div class="oe_kanban_content p-3">
|
||||||
<div class="o_kanban_record_top mb-2">
|
<div class="o_kanban_record_top mb-2">
|
||||||
<div class="o_kanban_record_headings">
|
<div class="o_kanban_record_headings">
|
||||||
@ -26,22 +28,30 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ms-auto h5 mb-0">
|
<div class="ms-auto h5 mb-0">
|
||||||
<span t-if="record.table_id.raw_value" class="badge rounded-pill bg-light text-dark border">
|
<span t-if="record.table_id.raw_value" class="badge rounded-pill bg-light text-dark border">
|
||||||
<i class="fa fa-map-marker me-1"/> <field name="table_id"/>
|
<i class="fa fa-map-marker me-1" title="Table"/> <field name="table_id"/>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<t t-if="record.customer_note.raw_value">
|
<t t-if="record.customer_note.raw_value">
|
||||||
<div class="alert alert-warning py-2 px-3 mb-3 border-0" style="background: rgba(254, 205, 79, 0.1); border-radius: 8px;">
|
<div class="alert alert-warning py-2 px-3 mb-3 border-0" role="status" style="background: rgba(254, 205, 79, 0.1); border-radius: 8px;">
|
||||||
<i class="fa fa-sticky-note-o me-2"/> <strong>Note:</strong> <field name="customer_note"/>
|
<i class="fa fa-sticky-note-o me-2" title="Note"/> <strong>Note:</strong> <field name="customer_note"/>
|
||||||
</div>
|
</div>
|
||||||
</t>
|
</t>
|
||||||
|
|
||||||
<div class="o_kanban_record_body small text-muted mb-3">
|
<div class="o_kanban_record_body small text-muted mb-3">
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||||
<span><i class="fa fa-clock-o me-1"/> <field name="create_date"/></span>
|
<span><i class="fa fa-clock-o me-1" title="Time"/> <field name="create_date"/></span>
|
||||||
<span class="text-uppercase fw-bold" style="font-size: 0.7rem;"><field name="floor_id"/></span>
|
<span class="text-uppercase fw-bold" style="font-size: 0.7rem;"><field name="floor_id"/></span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<span t-if="record.order_source.raw_value" class="badge bg-info-light text-info border-info" style="font-size: 0.65rem; background: rgba(23, 162, 184, 0.1);">
|
||||||
|
<i class="fa fa-plug me-1" title="Source"/> <field name="order_source"/>
|
||||||
|
</span>
|
||||||
|
<span t-if="record.fulfilment_type.raw_value" class="badge bg-warning-light text-warning border-warning" style="font-size: 0.65rem; background: rgba(254, 205, 79, 0.1);">
|
||||||
|
<i class="fa fa-truck me-1" title="Fulfilment"/> <field name="fulfilment_type"/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="o_kanban_record_bottom border-top pt-3 mt-2">
|
<div class="o_kanban_record_bottom border-top pt-3 mt-2">
|
||||||
@ -81,6 +91,8 @@
|
|||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<tree string="Kitchen Orders" create="false" edit="false" decoration-info="preparation_status == 'preparing'" decoration-success="preparation_status == 'ready'" decoration-muted="preparation_status == 'served'">
|
<tree string="Kitchen Orders" create="false" edit="false" decoration-info="preparation_status == 'preparing'" decoration-success="preparation_status == 'ready'" decoration-muted="preparation_status == 'served'">
|
||||||
<field name="order_id"/>
|
<field name="order_id"/>
|
||||||
|
<field name="order_source" widget="badge"/>
|
||||||
|
<field name="fulfilment_type" widget="badge"/>
|
||||||
<field name="floor_id"/>
|
<field name="floor_id"/>
|
||||||
<field name="table_id"/>
|
<field name="table_id"/>
|
||||||
<field name="product_id"/>
|
<field name="product_id"/>
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
- Real-time notifications via bus service
|
- Real-time notifications via bus service
|
||||||
""",
|
""",
|
||||||
'author': 'Dine360',
|
'author': 'Dine360',
|
||||||
'depends': ['point_of_sale', 'pos_restaurant', 'dine360_kds', 'website_sale', 'sale_management'],
|
'depends': ['point_of_sale', 'pos_restaurant', 'dine360_kds', 'website_sale', 'sale_management', 'dine360_order_channels'],
|
||||||
'data': [
|
'data': [
|
||||||
'security/ir.model.access.csv',
|
'security/ir.model.access.csv',
|
||||||
'views/pos_order_views.xml',
|
'views/pos_order_views.xml',
|
||||||
|
|||||||
@ -8,7 +8,7 @@ class Dine360OnlineOrders(http.Controller):
|
|||||||
order = request.website.sale_get_order()
|
order = request.website.sale_get_order()
|
||||||
if order and service_mode in ['pickup', 'delivery', 'dine_in']:
|
if order and service_mode in ['pickup', 'delivery', 'dine_in']:
|
||||||
order.sudo().write({
|
order.sudo().write({
|
||||||
'dine360_service_mode': service_mode,
|
'fulfilment_type': service_mode,
|
||||||
'dine360_order_source': 'web'
|
'order_source': 'online'
|
||||||
})
|
})
|
||||||
return True
|
return True
|
||||||
|
|||||||
@ -31,17 +31,8 @@ class PosOrder(models.Model):
|
|||||||
default=fields.Datetime.now
|
default=fields.Datetime.now
|
||||||
)
|
)
|
||||||
|
|
||||||
dine360_order_source = fields.Selection([
|
# Note: order_source and fulfilment_type fields are defined in dine360_order_channels
|
||||||
('web', 'Customer Self (Web)'),
|
# dine360_online_orders just uses these fields
|
||||||
('kiosk', 'Store Self (Kiosk)'),
|
|
||||||
('pos', 'Standard POS')
|
|
||||||
], string='Order Source', default='pos')
|
|
||||||
|
|
||||||
dine360_service_mode = fields.Selection([
|
|
||||||
('pickup', 'Pickup'),
|
|
||||||
('delivery', 'Delivery'),
|
|
||||||
('dine_in', 'Dine-In')
|
|
||||||
], string='Service Mode', default='dine_in')
|
|
||||||
|
|
||||||
@api.depends('partner_id', 'partner_id.name')
|
@api.depends('partner_id', 'partner_id.name')
|
||||||
def _compute_online_customer_name(self):
|
def _compute_online_customer_name(self):
|
||||||
@ -98,8 +89,8 @@ class PosOrder(models.Model):
|
|||||||
if 'session_id' in vals:
|
if 'session_id' in vals:
|
||||||
session = self.env['pos.session'].browse(vals['session_id'])
|
session = self.env['pos.session'].browse(vals['session_id'])
|
||||||
if session.config_id.is_kiosk:
|
if session.config_id.is_kiosk:
|
||||||
vals['dine360_order_source'] = 'kiosk'
|
vals['order_source'] = 'kiosk'
|
||||||
vals['dine360_service_mode'] = session.config_id.kiosk_service_mode
|
vals['fulfilment_type'] = session.config_id.kiosk_service_mode or 'pickup'
|
||||||
return super().create(vals_list)
|
return super().create(vals_list)
|
||||||
|
|
||||||
@api.model
|
@api.model
|
||||||
@ -134,8 +125,8 @@ class PosOrder(models.Model):
|
|||||||
'amount_total': order.amount_total,
|
'amount_total': order.amount_total,
|
||||||
'date_order': order.date_order.isoformat() if order.date_order else '',
|
'date_order': order.date_order.isoformat() if order.date_order else '',
|
||||||
'sale_order_name': order.sale_order_id.name if order.sale_order_id else '',
|
'sale_order_name': order.sale_order_id.name if order.sale_order_id else '',
|
||||||
'service_mode': order.dine360_service_mode,
|
'service_mode': order.fulfilment_type,
|
||||||
'order_source': order.dine360_order_source,
|
'order_source': order.order_source,
|
||||||
'note': order.note or '',
|
'note': order.note or '',
|
||||||
'lines': lines,
|
'lines': lines,
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,15 +1,5 @@
|
|||||||
from odoo import models, fields, api
|
from odoo import models
|
||||||
|
|
||||||
class PosOrderLine(models.Model):
|
class PosOrderLine(models.Model):
|
||||||
_inherit = 'pos.order.line'
|
_inherit = 'pos.order.line'
|
||||||
|
# Related fields order_source and fulfilment_type are now provided by dine360_order_channels
|
||||||
dine360_order_source = fields.Selection(
|
|
||||||
related='order_id.dine360_order_source',
|
|
||||||
string='Order Source',
|
|
||||||
store=True
|
|
||||||
)
|
|
||||||
dine360_service_mode = fields.Selection(
|
|
||||||
related='order_id.dine360_service_mode',
|
|
||||||
string='Service Mode',
|
|
||||||
store=True
|
|
||||||
)
|
|
||||||
|
|||||||
@ -12,17 +12,24 @@ class SaleOrderOnline(models.Model):
|
|||||||
help='The POS order created from this website sale order'
|
help='The POS order created from this website sale order'
|
||||||
)
|
)
|
||||||
|
|
||||||
dine360_order_source = fields.Selection([
|
# order_source is now canonical field from dine360_order_channels (pos.order)
|
||||||
('web', 'Customer Self (Web)'),
|
# We add it to sale.order for tracking which channel the web sale originated from
|
||||||
('kiosk', 'Store Self (Kiosk)'),
|
order_source = fields.Selection([
|
||||||
('pos', 'Standard POS')
|
('walk_in', 'Walk-In'),
|
||||||
], string='Order Source', default='web')
|
('phone', 'Phone'),
|
||||||
|
('online', 'Online / eCommerce'),
|
||||||
|
('whatsapp', 'WhatsApp'),
|
||||||
|
('social_media', 'Social Media'),
|
||||||
|
('platform', 'Platform'),
|
||||||
|
('kiosk', 'Kiosk'),
|
||||||
|
('qr', 'QR Code'),
|
||||||
|
], string='Order Source', default='online')
|
||||||
|
|
||||||
dine360_service_mode = fields.Selection([
|
fulfilment_type = fields.Selection([
|
||||||
|
('dine_in', 'Dine-In'),
|
||||||
('pickup', 'Pickup'),
|
('pickup', 'Pickup'),
|
||||||
('delivery', 'Delivery'),
|
('delivery', 'Delivery'),
|
||||||
('dine_in', 'Dine-In')
|
], string='Fulfilment Type', default='pickup')
|
||||||
], string='Service Mode', default='pickup')
|
|
||||||
|
|
||||||
def _create_pos_order_for_kds(self, sale_order):
|
def _create_pos_order_for_kds(self, sale_order):
|
||||||
"""
|
"""
|
||||||
@ -47,8 +54,8 @@ class SaleOrderOnline(models.Model):
|
|||||||
'online_order_status': 'pending',
|
'online_order_status': 'pending',
|
||||||
'sale_order_id': sale_order.id,
|
'sale_order_id': sale_order.id,
|
||||||
'online_order_date': fields.Datetime.now(),
|
'online_order_date': fields.Datetime.now(),
|
||||||
'dine360_order_source': sale_order.dine360_order_source,
|
'order_source': sale_order.order_source or 'online',
|
||||||
'dine360_service_mode': sale_order.dine360_service_mode,
|
'fulfilment_type': sale_order.fulfilment_type or 'pickup',
|
||||||
})
|
})
|
||||||
|
|
||||||
# Link back to sale order
|
# Link back to sale order
|
||||||
|
|||||||
@ -6,12 +6,12 @@ import { jsonrpc } from "@web/core/network/rpc_service";
|
|||||||
publicWidget.registry.ServiceModeSelector = publicWidget.Widget.extend({
|
publicWidget.registry.ServiceModeSelector = publicWidget.Widget.extend({
|
||||||
selector: '#service_mode_selector',
|
selector: '#service_mode_selector',
|
||||||
events: {
|
events: {
|
||||||
'change input[name="dine360_service_mode"]': '_onChangeServiceMode',
|
'change input[name="fulfilment_type"]': '_onChangeServiceMode',
|
||||||
},
|
},
|
||||||
|
|
||||||
start: function () {
|
start: function () {
|
||||||
// Init visual selection
|
// Init visual selection
|
||||||
this.$('input[name="dine360_service_mode"]:checked').closest('.service-option').find('.service-card')
|
this.$('input[name="fulfilment_type"]:checked').closest('.service-option').find('.service-card')
|
||||||
.css({ 'border-color': '#FECD4F', 'background-color': '#fffdf6', 'box-shadow': '0 4px 10px rgba(254, 205, 79, 0.2)' });
|
.css({ 'border-color': '#FECD4F', 'background-color': '#fffdf6', 'box-shadow': '0 4px 10px rgba(254, 205, 79, 0.2)' });
|
||||||
return this._super.apply(this, arguments);
|
return this._super.apply(this, arguments);
|
||||||
},
|
},
|
||||||
@ -46,7 +46,7 @@ publicWidget.registry.CartCheckoutValidation = publicWidget.Widget.extend({
|
|||||||
_onCheckoutClicked: function (ev) {
|
_onCheckoutClicked: function (ev) {
|
||||||
// If there's a selector on the page
|
// If there's a selector on the page
|
||||||
if (this.$('#service_mode_selector').length > 0) {
|
if (this.$('#service_mode_selector').length > 0) {
|
||||||
var selectedMode = this.$('input[name="dine360_service_mode"]:checked').val();
|
var selectedMode = this.$('input[name="fulfilment_type"]:checked').val();
|
||||||
if (!selectedMode) {
|
if (!selectedMode) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
this.$('#service_mode_error').removeClass('d-none');
|
this.$('#service_mode_error').removeClass('d-none');
|
||||||
|
|||||||
@ -10,31 +10,4 @@
|
|||||||
'|', ('order_id.is_online_order', '=', False), ('order_id.online_order_status', '!=', 'pending')
|
'|', ('order_id.is_online_order', '=', False), ('order_id.online_order_status', '!=', 'pending')
|
||||||
]</field>
|
]</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
<!-- Extend KDS Kanban to show Service Mode -->
|
|
||||||
<record id="view_pos_order_line_kds_kanban_inherit" model="ir.ui.view">
|
|
||||||
<field name="name">pos.order.line.kds.kanban.inherit</field>
|
|
||||||
<field name="model">pos.order.line</field>
|
|
||||||
<field name="inherit_id" ref="dine360_kds.view_pos_order_line_kds_kanban"/>
|
|
||||||
<field name="arch" type="xml">
|
|
||||||
<xpath expr="//field[@name='create_date']" position="after">
|
|
||||||
<field name="dine360_order_source"/>
|
|
||||||
<field name="dine360_service_mode"/>
|
|
||||||
</xpath>
|
|
||||||
<xpath expr="//div[hasclass('ms-auto')]" position="replace">
|
|
||||||
<div class="ms-auto h5 mb-0 d-flex flex-column align-items-end">
|
|
||||||
<span t-if="record.dine360_service_mode.raw_value" class="badge rounded-pill mb-1"
|
|
||||||
t-attf-class="{{record.dine360_service_mode.raw_value == 'pickup' ? 'bg-info' : (record.dine360_service_mode.raw_value == 'delivery' ? 'bg-primary' : 'bg-secondary')}} text-white">
|
|
||||||
<i t-if="record.dine360_service_mode.raw_value == 'pickup'" class="fa fa-shopping-basket me-1" title="Pickup"/>
|
|
||||||
<i t-if="record.dine360_service_mode.raw_value == 'delivery'" class="fa fa-truck me-1" title="Delivery"/>
|
|
||||||
<i t-if="record.dine360_service_mode.raw_value == 'dine_in'" class="fa fa-cutlery me-1" title="Dine-In"/>
|
|
||||||
<field name="dine360_service_mode"/>
|
|
||||||
</span>
|
|
||||||
<span t-if="record.table_id.raw_value" class="badge rounded-pill bg-light text-dark border">
|
|
||||||
<i class="fa fa-map-marker me-1" title="Table"/> <field name="table_id"/>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</xpath>
|
|
||||||
</field>
|
|
||||||
</record>
|
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|||||||
@ -15,11 +15,11 @@
|
|||||||
decoration-success="online_order_status=='confirmed'"
|
decoration-success="online_order_status=='confirmed'"
|
||||||
decoration-danger="online_order_status=='rejected'"/>
|
decoration-danger="online_order_status=='rejected'"/>
|
||||||
<field name="sale_order_id"/>
|
<field name="sale_order_id"/>
|
||||||
<field name="dine360_order_source"/>
|
<field name="order_source"/>
|
||||||
<field name="dine360_service_mode" widget="badge"
|
<field name="fulfilment_type" widget="badge"
|
||||||
decoration-info="dine360_service_mode=='pickup'"
|
decoration-info="fulfilment_type=='pickup'"
|
||||||
decoration-primary="dine360_service_mode=='delivery'"
|
decoration-primary="fulfilment_type=='delivery'"
|
||||||
decoration-muted="dine360_service_mode=='dine_in'"/>
|
decoration-muted="fulfilment_type=='dine_in'"/>
|
||||||
<field name="config_id"/>
|
<field name="config_id"/>
|
||||||
</tree>
|
</tree>
|
||||||
</field>
|
</field>
|
||||||
@ -38,8 +38,8 @@
|
|||||||
decoration-success="online_order_status=='confirmed'"
|
decoration-success="online_order_status=='confirmed'"
|
||||||
decoration-danger="online_order_status=='rejected'"/>
|
decoration-danger="online_order_status=='rejected'"/>
|
||||||
<field name="sale_order_id" invisible="not is_online_order"/>
|
<field name="sale_order_id" invisible="not is_online_order"/>
|
||||||
<field name="dine360_order_source" string="Source"/>
|
<field name="order_source" string="Source"/>
|
||||||
<field name="dine360_service_mode" string="Service"/>
|
<field name="fulfilment_type" string="Service"/>
|
||||||
</xpath>
|
</xpath>
|
||||||
</field>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
<h4 class="mb-3 fw-bold">How would you like your order?</h4>
|
<h4 class="mb-3 fw-bold">How would you like your order?</h4>
|
||||||
<div class="d-flex gap-3">
|
<div class="d-flex gap-3">
|
||||||
<label class="service-option position-relative flex-fill cursor-pointer">
|
<label class="service-option position-relative flex-fill cursor-pointer">
|
||||||
<input type="radio" name="dine360_service_mode" value="pickup" class="d-none" t-att-checked="'checked' if website_sale_order.dine360_service_mode == 'pickup' else None"/>
|
<input type="radio" name="fulfilment_type" value="pickup" class="d-none" t-att-checked="'checked' if website_sale_order.fulfilment_type == 'pickup' else None"/>
|
||||||
<div class="service-card p-3 rounded-3 border text-center transition-all">
|
<div class="service-card p-3 rounded-3 border text-center transition-all">
|
||||||
<i class="fa fa-shopping-basket fs-3 mb-2 text-primary"></i>
|
<i class="fa fa-shopping-basket fs-3 mb-2 text-primary"></i>
|
||||||
<h6 class="mb-1 fw-bold">Pickup</h6>
|
<h6 class="mb-1 fw-bold">Pickup</h6>
|
||||||
@ -14,7 +14,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<label class="service-option position-relative flex-fill cursor-pointer">
|
<label class="service-option position-relative flex-fill cursor-pointer">
|
||||||
<input type="radio" name="dine360_service_mode" value="delivery" class="d-none" t-att-checked="'checked' if website_sale_order.dine360_service_mode == 'delivery' else None"/>
|
<input type="radio" name="fulfilment_type" value="delivery" class="d-none" t-att-checked="'checked' if website_sale_order.fulfilment_type == 'delivery' else None"/>
|
||||||
<div class="service-card p-3 rounded-3 border text-center transition-all">
|
<div class="service-card p-3 rounded-3 border text-center transition-all">
|
||||||
<i class="fa fa-truck fs-3 mb-2 text-info"></i>
|
<i class="fa fa-truck fs-3 mb-2 text-info"></i>
|
||||||
<h6 class="mb-1 fw-bold">Delivery</h6>
|
<h6 class="mb-1 fw-bold">Delivery</h6>
|
||||||
|
|||||||
2
addons/dine360_order_channels/__init__.py
Normal file
2
addons/dine360_order_channels/__init__.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
from . import models
|
||||||
|
from . import controllers
|
||||||
34
addons/dine360_order_channels/__manifest__.py
Normal file
34
addons/dine360_order_channels/__manifest__.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
'name': 'Dine360 Order Channels',
|
||||||
|
'version': '17.0.1.0',
|
||||||
|
'category': 'Sales/Point of Sale',
|
||||||
|
'summary': 'Multi-channel order intake: Phone, WhatsApp, Social, Kiosk, Online',
|
||||||
|
'description': """
|
||||||
|
Extends POS to support multiple order intake channels:
|
||||||
|
- Channel 1: Telephone Orders (order_source=phone, fulfilment, delivery address)
|
||||||
|
- Channel 2: WhatsApp Orders (order_source=whatsapp, number tracking)
|
||||||
|
- Channel 3: Social Media Orders (order_source=social_media, ref tracking)
|
||||||
|
- Channel 4: Kiosk / QR Orders (order_source=kiosk/qr)
|
||||||
|
- Fulfilment Type: Dine-In, Pickup, Delivery
|
||||||
|
- Address capture for Delivery orders with partner search
|
||||||
|
""",
|
||||||
|
'author': 'Dine360',
|
||||||
|
'depends': ['point_of_sale'],
|
||||||
|
'data': [
|
||||||
|
'security/ir.model.access.csv',
|
||||||
|
'views/pos_order_views.xml',
|
||||||
|
],
|
||||||
|
'assets': {
|
||||||
|
'point_of_sale._assets_pos': [
|
||||||
|
'dine360_order_channels/static/src/css/channel_panel.css',
|
||||||
|
'dine360_order_channels/static/src/js/order_channel_model.js',
|
||||||
|
'dine360_order_channels/static/src/js/channel_panel.js',
|
||||||
|
'dine360_order_channels/static/src/js/product_screen_patch.js',
|
||||||
|
'dine360_order_channels/static/src/xml/channel_panel.xml',
|
||||||
|
'dine360_order_channels/static/src/xml/receipt_extension.xml',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'installable': True,
|
||||||
|
'application': False,
|
||||||
|
'license': 'LGPL-3',
|
||||||
|
}
|
||||||
1
addons/dine360_order_channels/controllers/__init__.py
Normal file
1
addons/dine360_order_channels/controllers/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from . import main
|
||||||
44
addons/dine360_order_channels/controllers/main.py
Normal file
44
addons/dine360_order_channels/controllers/main.py
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
from odoo import http
|
||||||
|
from odoo.http import request
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Dine360OrderChannelsController(http.Controller):
|
||||||
|
|
||||||
|
@http.route('/dine360/order_channels/partners', type='json', auth='user', methods=['POST'])
|
||||||
|
def search_partners(self, query='', limit=10):
|
||||||
|
"""Search for partners (for delivery address lookup from POS)"""
|
||||||
|
domain = [('name', 'ilike', query)]
|
||||||
|
partners = request.env['res.partner'].search(domain, limit=limit)
|
||||||
|
return [{
|
||||||
|
'id': p.id,
|
||||||
|
'name': p.name,
|
||||||
|
'phone': p.phone or p.mobile or '',
|
||||||
|
'street': p.street or '',
|
||||||
|
'city': p.city or '',
|
||||||
|
'zip': p.zip or '',
|
||||||
|
'display_name': p.display_name,
|
||||||
|
} for p in partners]
|
||||||
|
|
||||||
|
@http.route('/dine360/order_channels/create_partner', type='json', auth='user', methods=['POST'])
|
||||||
|
def create_partner(self, name, phone='', street='', city='', zip_code=''):
|
||||||
|
"""Quick create a delivery partner from POS"""
|
||||||
|
partner = request.env['res.partner'].create({
|
||||||
|
'name': name,
|
||||||
|
'phone': phone,
|
||||||
|
'street': street,
|
||||||
|
'city': city,
|
||||||
|
'zip': zip_code,
|
||||||
|
'type': 'delivery',
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
'id': partner.id,
|
||||||
|
'name': partner.name,
|
||||||
|
'phone': partner.phone or '',
|
||||||
|
'street': partner.street or '',
|
||||||
|
'city': partner.city or '',
|
||||||
|
'zip': partner.zip or '',
|
||||||
|
}
|
||||||
2
addons/dine360_order_channels/models/__init__.py
Normal file
2
addons/dine360_order_channels/models/__init__.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
from . import pos_order
|
||||||
|
from . import pos_config
|
||||||
30
addons/dine360_order_channels/models/pos_config.py
Normal file
30
addons/dine360_order_channels/models/pos_config.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
from odoo import models, fields
|
||||||
|
|
||||||
|
|
||||||
|
class PosConfigChannels(models.Model):
|
||||||
|
_inherit = 'pos.config'
|
||||||
|
|
||||||
|
default_order_source = fields.Selection([
|
||||||
|
('walk_in', 'Walk-In (Standard POS)'),
|
||||||
|
('phone', 'Telephone Order'),
|
||||||
|
('online', 'Online / eCommerce'),
|
||||||
|
('whatsapp', 'WhatsApp'),
|
||||||
|
('social_media', 'Social Media'),
|
||||||
|
('platform', 'Third-Party Platform'),
|
||||||
|
('kiosk', 'Self-Order Kiosk'),
|
||||||
|
('qr', 'QR Table Order'),
|
||||||
|
], string='Default Order Source', default='walk_in',
|
||||||
|
help='Pre-select this order source when opening a new order in this terminal')
|
||||||
|
|
||||||
|
default_fulfilment_type = fields.Selection([
|
||||||
|
('dine_in', 'Dine-In'),
|
||||||
|
('pickup', 'Pickup'),
|
||||||
|
('delivery', 'Delivery'),
|
||||||
|
], string='Default Fulfilment Type', default='dine_in',
|
||||||
|
help='Pre-select this fulfilment type for new orders on this terminal')
|
||||||
|
|
||||||
|
show_channel_panel = fields.Boolean(
|
||||||
|
string='Show Channel / Fulfilment Panel',
|
||||||
|
default=True,
|
||||||
|
help='Show the Order Source and Fulfilment Type selector on the order screen'
|
||||||
|
)
|
||||||
142
addons/dine360_order_channels/models/pos_order.py
Normal file
142
addons/dine360_order_channels/models/pos_order.py
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
from odoo import models, fields, api, _
|
||||||
|
from odoo.exceptions import ValidationError
|
||||||
|
import logging
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class PosOrderChannels(models.Model):
|
||||||
|
_inherit = 'pos.order'
|
||||||
|
|
||||||
|
# -----------------------------------------------------------
|
||||||
|
# Channel 1-5: Order Source Field
|
||||||
|
# -----------------------------------------------------------
|
||||||
|
order_source = fields.Selection([
|
||||||
|
('walk_in', 'Walk-In (Standard POS)'),
|
||||||
|
('phone', 'Telephone Order'),
|
||||||
|
('online', 'Online / eCommerce'),
|
||||||
|
('whatsapp', 'WhatsApp'),
|
||||||
|
('social_media', 'Social Media'),
|
||||||
|
('platform', 'Third-Party Platform'),
|
||||||
|
('kiosk', 'Self-Order Kiosk'),
|
||||||
|
('qr', 'QR Table Order'),
|
||||||
|
], string='Order Source', default='walk_in', index=True,
|
||||||
|
help='Channel through which this order was received')
|
||||||
|
|
||||||
|
# -----------------------------------------------------------
|
||||||
|
# All Channels: Fulfilment Type
|
||||||
|
# -----------------------------------------------------------
|
||||||
|
fulfilment_type = fields.Selection([
|
||||||
|
('dine_in', 'Dine-In'),
|
||||||
|
('pickup', 'Pickup'),
|
||||||
|
('delivery', 'Delivery'),
|
||||||
|
], string='Fulfilment Type', default='dine_in', index=True,
|
||||||
|
help='How the customer wants to receive the order')
|
||||||
|
|
||||||
|
# -----------------------------------------------------------
|
||||||
|
# Delivery Address (linked to res.partner)
|
||||||
|
# -----------------------------------------------------------
|
||||||
|
delivery_partner_id = fields.Many2one(
|
||||||
|
'res.partner', string='Delivery Address',
|
||||||
|
domain="[('type', 'in', ['delivery', 'contact', 'other'])]",
|
||||||
|
help='Delivery address for this order'
|
||||||
|
)
|
||||||
|
delivery_street = fields.Char('Delivery Street', compute='_compute_delivery_address', store=True, readonly=False)
|
||||||
|
delivery_city = fields.Char('Delivery City', compute='_compute_delivery_address', store=True, readonly=False)
|
||||||
|
delivery_zip = fields.Char('Delivery Zip', compute='_compute_delivery_address', store=True, readonly=False)
|
||||||
|
delivery_phone = fields.Char('Delivery Phone', compute='_compute_delivery_address', store=True, readonly=False)
|
||||||
|
delivery_notes = fields.Text('Delivery Notes', help='Special delivery instructions')
|
||||||
|
|
||||||
|
# -----------------------------------------------------------
|
||||||
|
# WhatsApp: sender info
|
||||||
|
# -----------------------------------------------------------
|
||||||
|
whatsapp_number = fields.Char('WhatsApp Number', help='Customer WhatsApp phone number')
|
||||||
|
whatsapp_msg_id = fields.Char('WhatsApp Msg ID', help='Original message ID for thread linking')
|
||||||
|
|
||||||
|
# -----------------------------------------------------------
|
||||||
|
# Social Media: reference
|
||||||
|
# -----------------------------------------------------------
|
||||||
|
social_ref = fields.Char('Social Ref', help='Instagram/Facebook message/post reference')
|
||||||
|
|
||||||
|
# -----------------------------------------------------------
|
||||||
|
# Computed helpers
|
||||||
|
# -----------------------------------------------------------
|
||||||
|
is_delivery_order = fields.Boolean(
|
||||||
|
string='Is Delivery', compute='_compute_is_delivery_order', store=True
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends('fulfilment_type')
|
||||||
|
def _compute_is_delivery_order(self):
|
||||||
|
for order in self:
|
||||||
|
order.is_delivery_order = (order.fulfilment_type == 'delivery')
|
||||||
|
|
||||||
|
@api.depends('delivery_partner_id')
|
||||||
|
def _compute_delivery_address(self):
|
||||||
|
for order in self:
|
||||||
|
p = order.delivery_partner_id
|
||||||
|
if p:
|
||||||
|
order.delivery_street = p.street or ''
|
||||||
|
order.delivery_city = p.city or ''
|
||||||
|
order.delivery_zip = p.zip or ''
|
||||||
|
order.delivery_phone = p.phone or p.mobile or ''
|
||||||
|
else:
|
||||||
|
order.delivery_street = order.delivery_street or ''
|
||||||
|
order.delivery_city = order.delivery_city or ''
|
||||||
|
order.delivery_zip = order.delivery_zip or ''
|
||||||
|
order.delivery_phone = order.delivery_phone or ''
|
||||||
|
|
||||||
|
# -----------------------------------------------------------
|
||||||
|
# Validation
|
||||||
|
# -----------------------------------------------------------
|
||||||
|
@api.constrains('fulfilment_type', 'delivery_street')
|
||||||
|
def _check_delivery_address(self):
|
||||||
|
for order in self:
|
||||||
|
if order.fulfilment_type == 'delivery' and order.state == 'done':
|
||||||
|
if not (order.delivery_street or order.delivery_partner_id):
|
||||||
|
raise ValidationError(
|
||||||
|
_("A delivery address is required for delivery orders.")
|
||||||
|
)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------
|
||||||
|
# Override _order_fields for POS sync (these values come from POS UI)
|
||||||
|
# -----------------------------------------------------------
|
||||||
|
@api.model
|
||||||
|
def _order_fields(self, ui_order):
|
||||||
|
fields_dict = super()._order_fields(ui_order)
|
||||||
|
fields_dict['order_source'] = ui_order.get('order_source', 'walk_in')
|
||||||
|
fields_dict['fulfilment_type'] = ui_order.get('fulfilment_type', 'dine_in')
|
||||||
|
fields_dict['delivery_street'] = ui_order.get('delivery_street', '')
|
||||||
|
fields_dict['delivery_city'] = ui_order.get('delivery_city', '')
|
||||||
|
fields_dict['delivery_zip'] = ui_order.get('delivery_zip', '')
|
||||||
|
fields_dict['delivery_phone'] = ui_order.get('delivery_phone', '')
|
||||||
|
fields_dict['delivery_notes'] = ui_order.get('delivery_notes', '')
|
||||||
|
fields_dict['whatsapp_number'] = ui_order.get('whatsapp_number', '')
|
||||||
|
fields_dict['social_ref'] = ui_order.get('social_ref', '')
|
||||||
|
return fields_dict
|
||||||
|
|
||||||
|
# -----------------------------------------------------------
|
||||||
|
# Export fields back to POS (needed for POS to read saved orders)
|
||||||
|
# -----------------------------------------------------------
|
||||||
|
def _export_for_ui(self, order):
|
||||||
|
result = super()._export_for_ui(order)
|
||||||
|
result['order_source'] = order.order_source
|
||||||
|
result['fulfilment_type'] = order.fulfilment_type
|
||||||
|
result['delivery_street'] = order.delivery_street or ''
|
||||||
|
result['delivery_city'] = order.delivery_city or ''
|
||||||
|
result['delivery_zip'] = order.delivery_zip or ''
|
||||||
|
result['delivery_phone'] = order.delivery_phone or ''
|
||||||
|
result['delivery_notes'] = order.delivery_notes or ''
|
||||||
|
result['whatsapp_number'] = order.whatsapp_number or ''
|
||||||
|
result['social_ref'] = order.social_ref or ''
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class PosOrderLineChannels(models.Model):
|
||||||
|
_inherit = 'pos.order.line'
|
||||||
|
|
||||||
|
order_source = fields.Selection(
|
||||||
|
related='order_id.order_source', string='Order Source', store=True
|
||||||
|
)
|
||||||
|
fulfilment_type = fields.Selection(
|
||||||
|
related='order_id.fulfilment_type', string='Fulfilment Type', store=True
|
||||||
|
)
|
||||||
@ -0,0 +1 @@
|
|||||||
|
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||||
|
@ -0,0 +1,43 @@
|
|||||||
|
.channel-panel {
|
||||||
|
background: #f9fafb;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-btn {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 20px !important;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-btn.active {
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fulfilment-btn {
|
||||||
|
font-size: 12px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delivery-panel {
|
||||||
|
background-color: #eef3ff !important;
|
||||||
|
border: 1px solid #c7d8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-dropdown {
|
||||||
|
z-index: 999;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-result:hover {
|
||||||
|
background-color: #f0f4ff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-label {
|
||||||
|
color: #666;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
125
addons/dine360_order_channels/static/src/js/channel_panel.js
Normal file
125
addons/dine360_order_channels/static/src/js/channel_panel.js
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import { Component, useState } from "@odoo/owl";
|
||||||
|
import { usePos } from "@point_of_sale/app/store/pos_hook";
|
||||||
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
import { jsonrpc } from "@web/core/network/rpc_service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ChannelPanel - shown in the POS OrderScreen.
|
||||||
|
* Allows staff to select:
|
||||||
|
* - Order Source (Walk-in / Phone / WhatsApp / Social / Online)
|
||||||
|
* - Fulfilment Type (Dine-In / Pickup / Delivery)
|
||||||
|
* - Delivery address (when Delivery is selected)
|
||||||
|
*/
|
||||||
|
export class ChannelPanel extends Component {
|
||||||
|
static template = "dine360_order_channels.ChannelPanel";
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
this.pos = usePos();
|
||||||
|
this.dialog = useService("dialog");
|
||||||
|
|
||||||
|
this.SOURCE_LABELS = {
|
||||||
|
walk_in: '🚶 Walk-In',
|
||||||
|
phone: '📞 Phone',
|
||||||
|
whatsapp: '💬 WhatsApp',
|
||||||
|
social_media: '📱 Social',
|
||||||
|
online: '🌐 Online',
|
||||||
|
kiosk: '📟 Kiosk',
|
||||||
|
qr: '📷 QR Code',
|
||||||
|
platform: '🛒 Platform',
|
||||||
|
};
|
||||||
|
|
||||||
|
this.FULFILMENT_LABELS = {
|
||||||
|
dine_in: '🍽️ Dine-In',
|
||||||
|
pickup: '🛍️ Pickup',
|
||||||
|
delivery: '🚚 Delivery',
|
||||||
|
};
|
||||||
|
|
||||||
|
this.state = useState({
|
||||||
|
showDelivery: false,
|
||||||
|
searchQuery: '',
|
||||||
|
searchResults: [],
|
||||||
|
searching: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get currentOrder() {
|
||||||
|
return this.pos.get_order();
|
||||||
|
}
|
||||||
|
|
||||||
|
get orderSource() {
|
||||||
|
return this.currentOrder?.order_source || 'walk_in';
|
||||||
|
}
|
||||||
|
|
||||||
|
get fulfilmentType() {
|
||||||
|
return this.currentOrder?.fulfilment_type || 'dine_in';
|
||||||
|
}
|
||||||
|
|
||||||
|
get isDelivery() {
|
||||||
|
return this.fulfilmentType === 'delivery';
|
||||||
|
}
|
||||||
|
|
||||||
|
get showPanel() {
|
||||||
|
return this.pos.config.show_channel_panel;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Source Selector ---
|
||||||
|
onSourceChange(source) {
|
||||||
|
const order = this.currentOrder;
|
||||||
|
if (!order) return;
|
||||||
|
order.setOrderSource(source);
|
||||||
|
// Trigger re-render
|
||||||
|
this.state.showDelivery = this.isDelivery;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Fulfilment Selector ---
|
||||||
|
onFulfilmentChange(type) {
|
||||||
|
const order = this.currentOrder;
|
||||||
|
if (!order) return;
|
||||||
|
order.setFulfilmentType(type);
|
||||||
|
this.state.showDelivery = (type === 'delivery');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Partner Address Search ---
|
||||||
|
async onAddressSearch(ev) {
|
||||||
|
const query = ev.target.value;
|
||||||
|
this.state.searchQuery = query;
|
||||||
|
if (query.length < 2) {
|
||||||
|
this.state.searchResults = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.state.searching = true;
|
||||||
|
const results = await jsonrpc('/dine360/order_channels/partners', { query, limit: 8 });
|
||||||
|
this.state.searchResults = results;
|
||||||
|
this.state.searching = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelectPartner(partner) {
|
||||||
|
const order = this.currentOrder;
|
||||||
|
if (!order) return;
|
||||||
|
order.setDeliveryAddress({
|
||||||
|
street: partner.street,
|
||||||
|
city: partner.city,
|
||||||
|
zip: partner.zip,
|
||||||
|
phone: partner.phone,
|
||||||
|
});
|
||||||
|
this.state.searchQuery = partner.display_name;
|
||||||
|
this.state.searchResults = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
onDeliveryFieldChange(field, ev) {
|
||||||
|
const order = this.currentOrder;
|
||||||
|
if (!order) return;
|
||||||
|
// Ad-hoc street/city/zip/phone/notes editing
|
||||||
|
const current = {
|
||||||
|
street: order.delivery_street,
|
||||||
|
city: order.delivery_city,
|
||||||
|
zip: order.delivery_zip,
|
||||||
|
phone: order.delivery_phone,
|
||||||
|
notes: order.delivery_notes,
|
||||||
|
};
|
||||||
|
current[field] = ev.target.value;
|
||||||
|
order.setDeliveryAddress(current);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,103 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import { patch } from "@web/core/utils/patch";
|
||||||
|
import { Order } from "@point_of_sale/app/store/models";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patch the POS Order model to carry channel data.
|
||||||
|
* These values are sent to the backend via _order_fields which we overrode in Python.
|
||||||
|
*/
|
||||||
|
patch(Order.prototype, {
|
||||||
|
setup(_defaultObj, options) {
|
||||||
|
super.setup(...arguments);
|
||||||
|
// Initialize with POS config defaults
|
||||||
|
const config = this.pos.config;
|
||||||
|
this.order_source = config.default_order_source || 'walk_in';
|
||||||
|
this.fulfilment_type = config.default_fulfilment_type || 'dine_in';
|
||||||
|
this.delivery_street = '';
|
||||||
|
this.delivery_city = '';
|
||||||
|
this.delivery_zip = '';
|
||||||
|
this.delivery_phone = '';
|
||||||
|
this.delivery_notes = '';
|
||||||
|
this.whatsapp_number = '';
|
||||||
|
this.social_ref = '';
|
||||||
|
},
|
||||||
|
|
||||||
|
export_as_JSON() {
|
||||||
|
const json = super.export_as_JSON(...arguments);
|
||||||
|
json.order_source = this.order_source;
|
||||||
|
json.fulfilment_type = this.fulfilment_type;
|
||||||
|
json.delivery_street = this.delivery_street;
|
||||||
|
json.delivery_city = this.delivery_city;
|
||||||
|
json.delivery_zip = this.delivery_zip;
|
||||||
|
json.delivery_phone = this.delivery_phone;
|
||||||
|
json.delivery_notes = this.delivery_notes;
|
||||||
|
json.whatsapp_number = this.whatsapp_number;
|
||||||
|
json.social_ref = this.social_ref;
|
||||||
|
return json;
|
||||||
|
},
|
||||||
|
|
||||||
|
init_from_JSON(json) {
|
||||||
|
super.init_from_JSON(...arguments);
|
||||||
|
this.order_source = json.order_source || 'walk_in';
|
||||||
|
this.fulfilment_type = json.fulfilment_type || 'dine_in';
|
||||||
|
this.delivery_street = json.delivery_street || '';
|
||||||
|
this.delivery_city = json.delivery_city || '';
|
||||||
|
this.delivery_zip = json.delivery_zip || '';
|
||||||
|
this.delivery_phone = json.delivery_phone || '';
|
||||||
|
this.delivery_notes = json.delivery_notes || '';
|
||||||
|
this.whatsapp_number = json.whatsapp_number || '';
|
||||||
|
this.social_ref = json.social_ref || '';
|
||||||
|
},
|
||||||
|
|
||||||
|
setOrderSource(source) {
|
||||||
|
this.order_source = source;
|
||||||
|
},
|
||||||
|
|
||||||
|
setFulfilmentType(type) {
|
||||||
|
this.fulfilment_type = type;
|
||||||
|
},
|
||||||
|
|
||||||
|
setDeliveryAddress(fields) {
|
||||||
|
this.delivery_street = fields.street || '';
|
||||||
|
this.delivery_city = fields.city || '';
|
||||||
|
this.delivery_zip = fields.zip || '';
|
||||||
|
this.delivery_phone = fields.phone || '';
|
||||||
|
this.delivery_notes = fields.notes || '';
|
||||||
|
},
|
||||||
|
|
||||||
|
export_for_printing() {
|
||||||
|
const result = super.export_for_printing(...arguments);
|
||||||
|
|
||||||
|
const SOURCE_LABELS = {
|
||||||
|
walk_in: 'Walk-In',
|
||||||
|
phone: 'Phone',
|
||||||
|
whatsapp: 'WhatsApp',
|
||||||
|
social_media: 'Social',
|
||||||
|
online: 'Online',
|
||||||
|
kiosk: 'Kiosk',
|
||||||
|
qr: 'QR Code',
|
||||||
|
platform: 'Platform',
|
||||||
|
};
|
||||||
|
|
||||||
|
const FULFILMENT_LABELS = {
|
||||||
|
dine_in: 'Dine-In',
|
||||||
|
pickup: 'Pickup',
|
||||||
|
delivery: 'Delivery',
|
||||||
|
};
|
||||||
|
|
||||||
|
result.order_source = this.order_source;
|
||||||
|
result.order_source_label = SOURCE_LABELS[this.order_source] || this.order_source;
|
||||||
|
result.fulfilment_type = this.fulfilment_type;
|
||||||
|
result.fulfilment_type_label = FULFILMENT_LABELS[this.fulfilment_type] || this.fulfilment_type;
|
||||||
|
result.delivery_street = this.delivery_street;
|
||||||
|
result.delivery_city = this.delivery_city;
|
||||||
|
result.delivery_zip = this.delivery_zip;
|
||||||
|
result.delivery_phone = this.delivery_phone;
|
||||||
|
result.delivery_notes = this.delivery_notes;
|
||||||
|
result.whatsapp_number = this.whatsapp_number;
|
||||||
|
result.social_ref = this.social_ref;
|
||||||
|
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import { patch } from "@web/core/utils/patch";
|
||||||
|
import { ProductScreen } from "@point_of_sale/app/screens/product_screen/product_screen";
|
||||||
|
import { ChannelPanel } from "./channel_panel";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patch ProductScreen to:
|
||||||
|
* 1. Register ChannelPanel as a component
|
||||||
|
* 2. Expose showChannelPanel computed property to the template
|
||||||
|
*/
|
||||||
|
patch(ProductScreen, {
|
||||||
|
components: {
|
||||||
|
...ProductScreen.components,
|
||||||
|
ChannelPanel,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
patch(ProductScreen.prototype, {
|
||||||
|
get showChannelPanel() {
|
||||||
|
return this.pos?.config?.show_channel_panel !== false;
|
||||||
|
},
|
||||||
|
});
|
||||||
116
addons/dine360_order_channels/static/src/xml/channel_panel.xml
Normal file
116
addons/dine360_order_channels/static/src/xml/channel_panel.xml
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
|
||||||
|
<!-- ChannelPanel Component -->
|
||||||
|
<t t-name="dine360_order_channels.ChannelPanel" owl="1">
|
||||||
|
<t t-if="showPanel and currentOrder">
|
||||||
|
<div class="channel-panel d-flex flex-column gap-2 p-2 border-bottom">
|
||||||
|
|
||||||
|
<!-- Order Source Row -->
|
||||||
|
<div class="channel-section">
|
||||||
|
<div class="channel-label mb-1">📋 ORDER SOURCE</div>
|
||||||
|
<div class="d-flex flex-wrap gap-1">
|
||||||
|
<t t-foreach="Object.entries(SOURCE_LABELS)" t-as="entry" t-key="entry[0]">
|
||||||
|
<button
|
||||||
|
t-attf-class="btn btn-sm channel-btn #{orderSource === entry[0] ? 'btn-dark active' : 'btn-outline-secondary'}"
|
||||||
|
t-on-click="() => this.onSourceChange(entry[0])">
|
||||||
|
<t t-esc="entry[1]"/>
|
||||||
|
</button>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- WhatsApp number field -->
|
||||||
|
<div t-if="orderSource === 'whatsapp'" class="channel-extra">
|
||||||
|
<input type="text" class="form-control form-control-sm"
|
||||||
|
placeholder="📱 WhatsApp Number"
|
||||||
|
t-att-value="currentOrder.whatsapp_number"
|
||||||
|
t-on-change="(ev) => { currentOrder.whatsapp_number = ev.target.value; }"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Social media ref field -->
|
||||||
|
<div t-if="orderSource === 'social_media'" class="channel-extra">
|
||||||
|
<input type="text" class="form-control form-control-sm"
|
||||||
|
placeholder="📲 Post / Message Reference"
|
||||||
|
t-att-value="currentOrder.social_ref"
|
||||||
|
t-on-change="(ev) => { currentOrder.social_ref = ev.target.value; }"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fulfilment Row -->
|
||||||
|
<div class="channel-section mt-1">
|
||||||
|
<div class="channel-label mb-1">🚀 FULFILMENT TYPE</div>
|
||||||
|
<div class="btn-group w-100" role="group">
|
||||||
|
<t t-foreach="Object.entries(FULFILMENT_LABELS)" t-as="entry" t-key="entry[0]">
|
||||||
|
<button
|
||||||
|
t-attf-class="btn btn-sm fulfilment-btn #{fulfilmentType === entry[0] ? 'btn-primary active' : 'btn-outline-primary'}"
|
||||||
|
t-on-click="() => this.onFulfilmentChange(entry[0])">
|
||||||
|
<t t-esc="entry[1]"/>
|
||||||
|
</button>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delivery Address Section -->
|
||||||
|
<div t-if="isDelivery" class="delivery-panel p-2 rounded mt-1">
|
||||||
|
<div class="fw-bold small mb-2">🚚 Delivery Address</div>
|
||||||
|
|
||||||
|
<!-- Partner search -->
|
||||||
|
<div class="position-relative mb-2">
|
||||||
|
<input type="text" class="form-control form-control-sm"
|
||||||
|
placeholder="🔍 Search saved address..."
|
||||||
|
t-att-value="state.searchQuery"
|
||||||
|
t-on-input="onAddressSearch"/>
|
||||||
|
<div t-if="state.searching" class="text-muted small ps-1">Searching...</div>
|
||||||
|
<div t-if="state.searchResults.length > 0"
|
||||||
|
class="address-dropdown position-absolute bg-white border rounded shadow-sm w-100">
|
||||||
|
<t t-foreach="state.searchResults" t-as="partner" t-key="partner.id">
|
||||||
|
<div class="address-result p-2 border-bottom small"
|
||||||
|
t-on-click="() => this.onSelectPartner(partner)">
|
||||||
|
<span class="fw-bold" t-esc="partner.name"/> —
|
||||||
|
<span class="text-muted">
|
||||||
|
<t t-esc="(partner.street || '') + ', ' + (partner.city || '')"/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Manual fields -->
|
||||||
|
<input type="text" class="form-control form-control-sm mb-1"
|
||||||
|
placeholder="Street"
|
||||||
|
t-att-value="currentOrder.delivery_street"
|
||||||
|
t-on-change="(ev) => this.onDeliveryFieldChange('street', ev)"/>
|
||||||
|
<div class="d-flex gap-1 mb-1">
|
||||||
|
<input type="text" class="form-control form-control-sm"
|
||||||
|
placeholder="City"
|
||||||
|
t-att-value="currentOrder.delivery_city"
|
||||||
|
t-on-change="(ev) => this.onDeliveryFieldChange('city', ev)"/>
|
||||||
|
<input type="text" class="form-control form-control-sm"
|
||||||
|
placeholder="Zip" style="max-width:90px"
|
||||||
|
t-att-value="currentOrder.delivery_zip"
|
||||||
|
t-on-change="(ev) => this.onDeliveryFieldChange('zip', ev)"/>
|
||||||
|
</div>
|
||||||
|
<input type="tel" class="form-control form-control-sm mb-1"
|
||||||
|
placeholder="Phone"
|
||||||
|
t-att-value="currentOrder.delivery_phone"
|
||||||
|
t-on-change="(ev) => this.onDeliveryFieldChange('phone', ev)"/>
|
||||||
|
<textarea class="form-control form-control-sm" rows="2"
|
||||||
|
placeholder="Delivery notes (gate code, floor...)"
|
||||||
|
t-att-value="currentOrder.delivery_notes"
|
||||||
|
t-on-change="(ev) => this.onDeliveryFieldChange('notes', ev)"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- Inject ChannelPanel into the left pane of ProductScreen, above OrderWidget -->
|
||||||
|
<t t-name="point_of_sale.ProductScreen"
|
||||||
|
t-inherit="point_of_sale.ProductScreen"
|
||||||
|
t-inherit-mode="extension" owl="1">
|
||||||
|
<xpath expr="//div[hasclass('leftpane')]//OrderWidget" position="before">
|
||||||
|
<ChannelPanel/>
|
||||||
|
</xpath>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
</templates>
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
|
||||||
|
<t t-name="dine360_order_channels.OrderReceipt" t-inherit="point_of_sale.OrderReceipt" t-inherit-mode="extension" owl="1">
|
||||||
|
<xpath expr="//div[hasclass('pos-receipt-order-data')]" position="before">
|
||||||
|
<div class="channel-receipt-info mt-2 border-top pt-2">
|
||||||
|
<div t-if="props.data.order_source" class="d-flex justify-content-between">
|
||||||
|
<span>Order Source:</span>
|
||||||
|
<span class="fw-bold text-uppercase" t-esc="props.data.order_source_label"/>
|
||||||
|
</div>
|
||||||
|
<div t-if="props.data.fulfilment_type" class="d-flex justify-content-between">
|
||||||
|
<span>Fulfilment:</span>
|
||||||
|
<span class="fw-bold text-uppercase" t-esc="props.data.fulfilment_type_label"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<t t-if="props.data.fulfilment_type === 'delivery'">
|
||||||
|
<div class="mt-2 pt-2 border-top">
|
||||||
|
<div class="fw-bold">DELIVERY ADDRESS:</div>
|
||||||
|
<div t-if="props.data.delivery_street" t-esc="props.data.delivery_street"/>
|
||||||
|
<div t-if="props.data.delivery_city || props.data.delivery_zip">
|
||||||
|
<t t-esc="props.data.delivery_city"/> <t t-esc="props.data.delivery_zip"/>
|
||||||
|
</div>
|
||||||
|
<div t-if="props.data.delivery_phone">Phone: <t t-esc="props.data.delivery_phone"/></div>
|
||||||
|
<div t-if="props.data.delivery_notes" class="mt-1 small italic">
|
||||||
|
Note: <t t-esc="props.data.delivery_notes"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<div t-if="props.data.social_ref" class="mt-1 small">
|
||||||
|
Ref: <t t-esc="props.data.social_ref"/>
|
||||||
|
</div>
|
||||||
|
<div t-if="props.data.whatsapp_number" class="mt-1 small">
|
||||||
|
WhatsApp: <t t-esc="props.data.whatsapp_number"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</xpath>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
</templates>
|
||||||
93
addons/dine360_order_channels/views/pos_order_views.xml
Normal file
93
addons/dine360_order_channels/views/pos_order_views.xml
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<!-- Inherit pos.order list view to show channel fields -->
|
||||||
|
<record id="view_pos_order_channels_list" model="ir.ui.view">
|
||||||
|
<field name="name">pos.order.channels.list</field>
|
||||||
|
<field name="model">pos.order</field>
|
||||||
|
<field name="inherit_id" ref="point_of_sale.view_pos_order_tree"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<field name="amount_total" position="before">
|
||||||
|
<field name="order_source" string="Source" widget="badge"
|
||||||
|
decoration-info="order_source == 'online'"
|
||||||
|
decoration-warning="order_source == 'phone' or order_source == 'whatsapp'"
|
||||||
|
decoration-primary="order_source == 'kiosk' or order_source == 'qr'"
|
||||||
|
decoration-muted="order_source == 'walk_in'"/>
|
||||||
|
<field name="fulfilment_type" string="Fulfilment" widget="badge"
|
||||||
|
decoration-success="fulfilment_type == 'pickup'"
|
||||||
|
decoration-info="fulfilment_type == 'delivery'"
|
||||||
|
decoration-muted="fulfilment_type == 'dine_in'"/>
|
||||||
|
</field>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Inherit pos.order form view -->
|
||||||
|
<record id="view_pos_order_channels_form" model="ir.ui.view">
|
||||||
|
<field name="name">pos.order.channels.form</field>
|
||||||
|
<field name="model">pos.order</field>
|
||||||
|
<field name="inherit_id" ref="point_of_sale.view_pos_pos_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//field[@name='state']" position="after">
|
||||||
|
<field name="order_source" string="Order Source" widget="badge"/>
|
||||||
|
<field name="fulfilment_type" string="Fulfilment" widget="badge"/>
|
||||||
|
</xpath>
|
||||||
|
<!-- Delivery section in the Notes tab area -->
|
||||||
|
<xpath expr="//field[@name='note']" position="before">
|
||||||
|
<group string="Delivery Details" invisible="fulfilment_type != 'delivery'">
|
||||||
|
<field name="delivery_partner_id" string="Saved Address"/>
|
||||||
|
<field name="delivery_street"/>
|
||||||
|
<field name="delivery_city"/>
|
||||||
|
<field name="delivery_zip"/>
|
||||||
|
<field name="delivery_phone"/>
|
||||||
|
<field name="delivery_notes"/>
|
||||||
|
</group>
|
||||||
|
<group string="WhatsApp" invisible="order_source != 'whatsapp'">
|
||||||
|
<field name="whatsapp_number"/>
|
||||||
|
</group>
|
||||||
|
<group string="Social Media" invisible="order_source != 'social_media'">
|
||||||
|
<field name="social_ref"/>
|
||||||
|
</group>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- POS Config settings - add channel settings to existing POS config form -->
|
||||||
|
<!-- Note: We skip config view inheritance to avoid xpath issues with pos_self_order -->
|
||||||
|
|
||||||
|
<!-- Action: All Orders by Channel -->
|
||||||
|
<record id="action_pos_orders_by_channel" model="ir.actions.act_window">
|
||||||
|
<field name="name">Orders by Channel</field>
|
||||||
|
<field name="res_model">pos.order</field>
|
||||||
|
<field name="view_mode">tree,form</field>
|
||||||
|
<field name="context">{'search_default_group_source': 1}</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Search view extension for group-by Source/Fulfilment -->
|
||||||
|
<record id="view_pos_order_channels_search" model="ir.ui.view">
|
||||||
|
<field name="name">pos.order.channels.search</field>
|
||||||
|
<field name="model">pos.order</field>
|
||||||
|
<field name="inherit_id" ref="point_of_sale.view_pos_order_filter"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<search>
|
||||||
|
<field name="order_source"/>
|
||||||
|
<field name="fulfilment_type"/>
|
||||||
|
<filter name="filter_phone" string="Phone Orders" domain="[('order_source','=','phone')]"/>
|
||||||
|
<filter name="filter_whatsapp" string="WhatsApp Orders" domain="[('order_source','=','whatsapp')]"/>
|
||||||
|
<filter name="filter_online" string="Online Orders" domain="[('order_source','=','online')]"/>
|
||||||
|
<filter name="filter_kiosk" string="Kiosk Orders" domain="[('order_source','=','kiosk')]"/>
|
||||||
|
<filter name="filter_delivery" string="Delivery" domain="[('fulfilment_type','=','delivery')]"/>
|
||||||
|
<filter name="filter_pickup" string="Pickup" domain="[('fulfilment_type','=','pickup')]"/>
|
||||||
|
<group expand="0" string="Group By">
|
||||||
|
<filter name="group_source" string="Order Source" context="{'group_by': 'order_source'}"/>
|
||||||
|
<filter name="group_fulfilment" string="Fulfilment Type" context="{'group_by': 'fulfilment_type'}"/>
|
||||||
|
</group>
|
||||||
|
</search>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Menu item in POS menu -->
|
||||||
|
<menuitem id="menu_pos_orders_by_channel"
|
||||||
|
name="Orders by Channel"
|
||||||
|
parent="point_of_sale.menu_point_of_sale"
|
||||||
|
action="action_pos_orders_by_channel"
|
||||||
|
sequence="25"/>
|
||||||
|
</odoo>
|
||||||
2
addons/dine360_self_order/__init__.py
Normal file
2
addons/dine360_self_order/__init__.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
from . import models
|
||||||
|
from . import controllers
|
||||||
29
addons/dine360_self_order/__manifest__.py
Normal file
29
addons/dine360_self_order/__manifest__.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
'name': 'Dine360 Self-Order',
|
||||||
|
'version': '17.0.1.0',
|
||||||
|
'category': 'Sales/Point of Sale',
|
||||||
|
'summary': 'QR Table Ordering and Kiosk Mode for Dine360',
|
||||||
|
'description': """
|
||||||
|
Custom Self-Order module for Dine360:
|
||||||
|
- QR Code generation for restaurant tables
|
||||||
|
- Public web interface for menu browsing and ordering
|
||||||
|
- JSON API for product data and order submission
|
||||||
|
- Integration with POS and KDS
|
||||||
|
""",
|
||||||
|
'author': 'Dine360',
|
||||||
|
'depends': ['point_of_sale', 'pos_restaurant', 'dine360_order_channels', 'website'],
|
||||||
|
'data': [
|
||||||
|
'views/restaurant_table_views.xml',
|
||||||
|
'views/self_order_templates.xml',
|
||||||
|
],
|
||||||
|
'assets': {
|
||||||
|
'web.assets_frontend': [
|
||||||
|
'dine360_self_order/static/src/css/self_order.css',
|
||||||
|
'dine360_self_order/static/src/js/self_order.js',
|
||||||
|
'dine360_self_order/static/src/xml/self_order.xml',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'installable': True,
|
||||||
|
'application': True,
|
||||||
|
'license': 'LGPL-3',
|
||||||
|
}
|
||||||
1
addons/dine360_self_order/controllers/__init__.py
Normal file
1
addons/dine360_self_order/controllers/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from . import main
|
||||||
114
addons/dine360_self_order/controllers/main.py
Normal file
114
addons/dine360_self_order/controllers/main.py
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
from odoo import http, _
|
||||||
|
from odoo.http import request
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Dine360SelfOrderController(http.Controller):
|
||||||
|
|
||||||
|
@http.route('/dine360/menu', type='http', auth='public', website=True)
|
||||||
|
def self_order_menu(self, **kwargs):
|
||||||
|
"""Displays the self-order menu for a specific table/kiosk"""
|
||||||
|
table_id = kwargs.get('table_id')
|
||||||
|
table = False
|
||||||
|
if table_id:
|
||||||
|
table = request.env['restaurant.table'].sudo().browse(int(table_id))
|
||||||
|
|
||||||
|
values = {
|
||||||
|
'table': table,
|
||||||
|
'floor': table.floor_id if table else False,
|
||||||
|
}
|
||||||
|
return request.render('dine360_self_order.self_order_menu_template', values)
|
||||||
|
|
||||||
|
@http.route('/dine360/self_order/products', type='json', auth='public', methods=['POST'])
|
||||||
|
def get_products(self):
|
||||||
|
"""API to fetch products for the self-order menu"""
|
||||||
|
# We only show items available in POS
|
||||||
|
products = request.env['product.product'].sudo().search([
|
||||||
|
('available_in_pos', '=', True),
|
||||||
|
('sale_ok', '=', True)
|
||||||
|
])
|
||||||
|
|
||||||
|
# Group by category if needed, but for now simple list
|
||||||
|
result = []
|
||||||
|
for p in products:
|
||||||
|
result.append({
|
||||||
|
'id': p.id,
|
||||||
|
'display_name': p.display_name,
|
||||||
|
'list_price': p.list_price,
|
||||||
|
'pos_categ_id': p.pos_categ_ids[0].id if p.pos_categ_ids else False,
|
||||||
|
'pos_categ_name': p.pos_categ_ids[0].name if p.pos_categ_ids else 'General',
|
||||||
|
'description': p.description_sale or '',
|
||||||
|
# Image URL helper
|
||||||
|
'image_url': f"/web/image/product.product/{p.id}/image_128",
|
||||||
|
'is_kitchen_item': p.is_kitchen_item,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
@http.route('/dine360/self_order/submit_order', type='json', auth='public', methods=['POST'])
|
||||||
|
def submit_self_order(self, **kwargs):
|
||||||
|
"""API to submit a self-order and create a pos.order"""
|
||||||
|
data = kwargs.get('order_data')
|
||||||
|
if not data:
|
||||||
|
return {'error': 'No order data received'}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# We need an active session to link the order
|
||||||
|
# For self-order, we might have a dedicated "Self-Order POS" config
|
||||||
|
# Here we'll find the first open POS session or one marked as 'self_order'
|
||||||
|
session = request.env['pos.session'].sudo().search([
|
||||||
|
('state', '=', 'opened'),
|
||||||
|
('config_id.module_pos_self_order', '=', True) # Or a custom flag
|
||||||
|
], limit=1)
|
||||||
|
|
||||||
|
if not session:
|
||||||
|
# Fallback to any open session
|
||||||
|
session = request.env['pos.session'].sudo().search([
|
||||||
|
('state', '=', 'opened')
|
||||||
|
], limit=1)
|
||||||
|
|
||||||
|
if not session:
|
||||||
|
return {'error': 'No open POS session found. Please wait for staff to open the store.'}
|
||||||
|
|
||||||
|
partner = request.env.user.partner_id if not request.env.user._is_public() else False
|
||||||
|
|
||||||
|
# Prepare order values
|
||||||
|
table_id = data.get('table_id')
|
||||||
|
lines = data.get('lines', [])
|
||||||
|
|
||||||
|
order_vals = {
|
||||||
|
'session_id': session.id,
|
||||||
|
'partner_id': partner.id if partner else False,
|
||||||
|
'table_id': int(table_id) if table_id else False,
|
||||||
|
'order_source': 'qr' if table_id else 'kiosk',
|
||||||
|
'fulfilment_type': data.get('fulfilment_type', 'dine_in'),
|
||||||
|
'lines': [],
|
||||||
|
}
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
order_vals['lines'].append((0, 0, {
|
||||||
|
'product_id': line['product_id'],
|
||||||
|
'qty': line['qty'],
|
||||||
|
'price_unit': line['price_unit'],
|
||||||
|
'customer_note': line.get('note', ''),
|
||||||
|
}))
|
||||||
|
|
||||||
|
# Use sudo to create the order since it's from public flow
|
||||||
|
order = request.env['pos.order'].sudo().create([order_vals])
|
||||||
|
|
||||||
|
# To trigger KDS, we might need to call specific methods or the confirmation flow
|
||||||
|
# For self-order, we often mark as "Pending Payment" or "Paid" depending on flow
|
||||||
|
# If kiosk, it might be unpaid until cashier handles it.
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'order_id': order.id,
|
||||||
|
'order_name': order.name,
|
||||||
|
'message': _("Your order has been sent to the kitchen!")
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error("Error submitting self-order: %s", str(e))
|
||||||
|
return {'error': str(e)}
|
||||||
1
addons/dine360_self_order/models/__init__.py
Normal file
1
addons/dine360_self_order/models/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from . import restaurant_table
|
||||||
38
addons/dine360_self_order/models/restaurant_table.py
Normal file
38
addons/dine360_self_order/models/restaurant_table.py
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
from odoo import models, fields, api, _
|
||||||
|
import werkzeug.urls
|
||||||
|
import logging
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class RestaurantTable(models.Model):
|
||||||
|
_inherit = 'restaurant.table'
|
||||||
|
|
||||||
|
self_order_url = fields.Char(
|
||||||
|
string='Self-Order URL',
|
||||||
|
compute='_compute_self_order_url',
|
||||||
|
help='The unique URL for this table to open the ordering menu'
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends('floor_id', 'name')
|
||||||
|
def _compute_self_order_url(self):
|
||||||
|
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
|
||||||
|
for table in self:
|
||||||
|
# We use a unique access token if we want more security, but for now ID is fine for the flow
|
||||||
|
params = {
|
||||||
|
'table_id': table.id,
|
||||||
|
'floor_id': table.floor_id.id,
|
||||||
|
}
|
||||||
|
# The public URL that the QR will point to
|
||||||
|
url = f"{base_url}/dine360/menu?{werkzeug.urls.url_encode(params)}"
|
||||||
|
table.self_order_url = url
|
||||||
|
|
||||||
|
def action_generate_qr_code(self):
|
||||||
|
"""Action to generate or print the QR code for this table"""
|
||||||
|
# In a full implementation, we'd use report logic to print a PDF with the QR
|
||||||
|
# For now, we'll expose the URL for testing
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_url',
|
||||||
|
'url': self.self_order_url,
|
||||||
|
'target': 'new',
|
||||||
|
}
|
||||||
89
addons/dine360_self_order/static/src/css/self_order.css
Normal file
89
addons/dine360_self_order/static/src/css/self_order.css
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
/* Self-Order UI Styling */
|
||||||
|
|
||||||
|
#self_order_app {
|
||||||
|
font-family: 'Outfit', 'Inter', -apple-system, sans-serif;
|
||||||
|
color: #171422;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transition-all {
|
||||||
|
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-scrollbar {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.08) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card .card-img-top {
|
||||||
|
border-top-left-radius: 14px;
|
||||||
|
border-top-right-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-warning {
|
||||||
|
background-color: #FECD4F;
|
||||||
|
border-color: #FECD4F;
|
||||||
|
color: #171422;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-warning:hover,
|
||||||
|
.btn-warning:active {
|
||||||
|
background-color: #fdbd1a;
|
||||||
|
border-color: #fdbd1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-warning-light {
|
||||||
|
background-color: rgba(254, 205, 79, 0.08) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-select.active {
|
||||||
|
border: 2px solid #FECD4F !important;
|
||||||
|
background-color: rgba(254, 205, 79, 0.05) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-item {
|
||||||
|
border-bottom: 1px dashed #eee;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#category_filter button {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-primary {
|
||||||
|
background-color: #171422;
|
||||||
|
}
|
||||||
|
|
||||||
|
#footer_cart {
|
||||||
|
border-top-left-radius: 20px;
|
||||||
|
border-top-right-radius: 20px;
|
||||||
|
z-index: 1050;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
border: none;
|
||||||
|
box-shadow: 0 -10px 40px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-border {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
}
|
||||||
259
addons/dine360_self_order/static/src/js/self_order.js
Normal file
259
addons/dine360_self_order/static/src/js/self_order.js
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import { jsonrpc } from "@web/core/network/rpc_service";
|
||||||
|
|
||||||
|
// We'll use a standard self-invoking function style since it's a public web module
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
const app = document.querySelector('#self_order_app');
|
||||||
|
if (!app) return;
|
||||||
|
|
||||||
|
const dataEl = document.querySelector('#self_order_data');
|
||||||
|
const config = {
|
||||||
|
tableId: dataEl.dataset.tableId,
|
||||||
|
tableName: dataEl.dataset.tableName,
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
products: [],
|
||||||
|
cart: [],
|
||||||
|
activeCategory: 'all',
|
||||||
|
searchTerm: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- UI Elements ---
|
||||||
|
const productList = document.querySelector('#product_list');
|
||||||
|
const categoryFilter = document.querySelector('#category_filter');
|
||||||
|
const cartCount = document.querySelector('#cart_count');
|
||||||
|
const cartTotal = document.querySelector('#cart_total');
|
||||||
|
const footerCart = document.querySelector('#footer_cart');
|
||||||
|
const loadingOverlay = document.querySelector('#loading_overlay');
|
||||||
|
const contentArea = document.querySelector('#self_order_content');
|
||||||
|
|
||||||
|
// --- Init ---
|
||||||
|
try {
|
||||||
|
const products = await jsonrpc('/dine360/self_order/products', {});
|
||||||
|
state.products = products;
|
||||||
|
renderCategories();
|
||||||
|
renderProducts();
|
||||||
|
loadingOverlay.classList.add('d-none');
|
||||||
|
contentArea.classList.remove('d-none');
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load products", e);
|
||||||
|
alert("Error connecting to server. Please try again later.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Functions ---
|
||||||
|
function renderCategories() {
|
||||||
|
const categories = ['all', ...new Set(state.products.map(p => p.pos_categ_name))];
|
||||||
|
categoryFilter.innerHTML = categories.map(cat => `
|
||||||
|
<button class="btn btn-sm ${state.activeCategory === cat ? 'btn-warning fw-bold shadow-sm' : 'btn-white border'} rounded-pill px-3 py-2" data-category="${cat}">
|
||||||
|
${cat === 'all' ? 'All Items' : cat}
|
||||||
|
</button>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
categoryFilter.querySelectorAll('button').forEach(btn => {
|
||||||
|
btn.onclick = () => {
|
||||||
|
state.activeCategory = btn.dataset.category;
|
||||||
|
renderCategories();
|
||||||
|
renderProducts();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProducts() {
|
||||||
|
let filtered = state.products;
|
||||||
|
if (state.activeCategory !== 'all') {
|
||||||
|
filtered = filtered.filter(p => p.pos_categ_name === state.activeCategory);
|
||||||
|
}
|
||||||
|
if (state.searchTerm) {
|
||||||
|
const term = state.searchTerm.toLowerCase();
|
||||||
|
filtered = filtered.filter(p => p.display_name.toLowerCase().includes(term));
|
||||||
|
}
|
||||||
|
|
||||||
|
productList.innerHTML = filtered.map(p => `
|
||||||
|
<div class="col-6 col-md-4 col-lg-3">
|
||||||
|
<div class="card h-100 border-0 shadow-sm product-card transition-all" data-id="${p.id}">
|
||||||
|
<div class="position-relative">
|
||||||
|
<img src="${p.image_url}" class="card-img-top rounded-top-4" alt="${p.display_name}" style="height: 140px; object-fit: cover; opacity: 1;"/>
|
||||||
|
<div class="position-absolute bottom-0 end-0 p-1">
|
||||||
|
<button class="btn btn-warning btn-sm rounded-circle add-to-cart-btn shadow" style="width: 32px; height: 32px; padding: 0;">
|
||||||
|
<i class="fa fa-plus"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-3">
|
||||||
|
<div class="fw-bold text-dark small mb-1 text-truncate">${p.display_name}</div>
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<span class="fw-bold text-primary">$${p.list_price.toFixed(2)}</span>
|
||||||
|
<t t-if="p.is_kitchen_item">
|
||||||
|
<i class="fa fa-fire text-danger small opacity-50"/>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
productList.querySelectorAll('.add-to-cart-btn').forEach(btn => {
|
||||||
|
btn.onclick = (ev) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
const card = btn.closest('.product-card');
|
||||||
|
const productId = parseInt(card.dataset.id);
|
||||||
|
addToCart(productId);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addToCart(productId) {
|
||||||
|
const product = state.products.find(p => p.id === productId);
|
||||||
|
const existing = state.cart.find(item => item.product_id === productId);
|
||||||
|
if (existing) {
|
||||||
|
existing.qty++;
|
||||||
|
} else {
|
||||||
|
state.cart.push({
|
||||||
|
product_id: product.id,
|
||||||
|
display_name: product.display_name,
|
||||||
|
price_unit: product.list_price,
|
||||||
|
qty: 1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
updateCartUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCartUI() {
|
||||||
|
const count = state.cart.reduce((acc, item) => acc + item.qty, 0);
|
||||||
|
const total = state.cart.reduce((acc, item) => acc + (item.qty * item.price_unit), 0);
|
||||||
|
|
||||||
|
cartCount.textContent = count;
|
||||||
|
cartTotal.textContent = `$${total.toFixed(2)}`;
|
||||||
|
|
||||||
|
if (count > 0) {
|
||||||
|
footerCart.classList.remove('d-none');
|
||||||
|
setTimeout(() => footerCart.style.transform = 'translateY(0)', 10);
|
||||||
|
} else {
|
||||||
|
footerCart.style.transform = 'translateY(100%)';
|
||||||
|
setTimeout(() => footerCart.classList.add('d-none'), 300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Search ---
|
||||||
|
document.querySelector('#product_search').oninput = (ev) => {
|
||||||
|
state.searchTerm = ev.target.value;
|
||||||
|
renderProducts();
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Cart Modal ---
|
||||||
|
const cartModal = new bootstrap.Modal(document.getElementById('cart_modal'));
|
||||||
|
document.querySelector('#view_cart_btn').onclick = () => {
|
||||||
|
renderCartModal();
|
||||||
|
cartModal.show();
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderCartModal() {
|
||||||
|
const list = document.querySelector('#cart_items_list');
|
||||||
|
const total = state.cart.reduce((acc, item) => acc + (item.qty * item.price_unit), 0);
|
||||||
|
|
||||||
|
list.innerHTML = state.cart.map((item, index) => `
|
||||||
|
<div class="d-flex align-items-center mb-4 cart-item">
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<div class="fw-bold mb-1">${item.display_name}</div>
|
||||||
|
<div class="text-primary fw-bold">$${(item.qty * item.price_unit).toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2 bg-light rounded-pill p-1 border">
|
||||||
|
<button class="btn btn-sm btn-white rounded-circle shadow-sm decrease-qty" data-index="${index}" style="width:28px; height:28px; padding:0;">-</button>
|
||||||
|
<span class="px-2 fw-bold" style="min-width: 20px; text-align:center;">${item.qty}</span>
|
||||||
|
<button class="btn btn-sm btn-white rounded-circle shadow-sm increase-qty" data-index="${index}" style="width:28px; height:28px; padding:0;">+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
document.querySelector('#checkout_subtotal').textContent = `$${total.toFixed(2)}`;
|
||||||
|
document.querySelector('#checkout_total').textContent = `$${total.toFixed(2)}`;
|
||||||
|
|
||||||
|
list.querySelectorAll('.increase-qty').forEach(btn => {
|
||||||
|
btn.onclick = () => {
|
||||||
|
state.cart[btn.dataset.index].qty++;
|
||||||
|
renderCartModal();
|
||||||
|
updateCartUI();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
list.querySelectorAll('.decrease-qty').forEach(btn => {
|
||||||
|
btn.onclick = () => {
|
||||||
|
const index = btn.dataset.index;
|
||||||
|
state.cart[index].qty--;
|
||||||
|
if (state.cart[index].qty <= 0) {
|
||||||
|
state.cart.splice(index, 1);
|
||||||
|
}
|
||||||
|
if (state.cart.length === 0) cartModal.hide();
|
||||||
|
renderCartModal();
|
||||||
|
updateCartUI();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Submit Order ---
|
||||||
|
document.querySelector('#submit_order_btn').onclick = async () => {
|
||||||
|
const btn = document.querySelector('#submit_order_btn');
|
||||||
|
const originalText = btn.innerHTML;
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"/>Sending...';
|
||||||
|
|
||||||
|
const fulfilmentType = document.querySelector('input[name="fulfilment"]:checked')?.value || 'dine_in';
|
||||||
|
|
||||||
|
const orderData = {
|
||||||
|
table_id: config.tableId,
|
||||||
|
fulfilment_type: fulfilmentType,
|
||||||
|
lines: state.cart
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await jsonrpc('/dine360/self_order/submit_order', { order_data: orderData });
|
||||||
|
if (response.success) {
|
||||||
|
cartModal.hide();
|
||||||
|
state.cart = [];
|
||||||
|
updateCartUI();
|
||||||
|
|
||||||
|
// Success View
|
||||||
|
document.querySelector('#self_order_content').innerHTML = `
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="bg-success text-white d-inline-flex align-items-center justify-content-center rounded-circle shadow-lg" style="width: 100px; height: 100px;">
|
||||||
|
<i class="fa fa-check fa-4x"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2 class="fw-bold mb-3">Order Received!</h2>
|
||||||
|
<p class="text-muted mb-4 px-4">${response.message}</p>
|
||||||
|
<div class="bg-white p-4 rounded-4 shadow-sm mb-4">
|
||||||
|
<div class="small text-muted mb-1">Order Number</div>
|
||||||
|
<div class="h4 fw-bold text-dark mb-0">${response.order_name}</div>
|
||||||
|
</div>
|
||||||
|
<button onclick="window.location.reload()" class="btn btn-dark btn-lg px-5 rounded-pill shadow">
|
||||||
|
Order More Items
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
alert(response.error || "Order submission failed");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert("Connection lost. Please check your internet.");
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Service Type Toggle
|
||||||
|
document.querySelectorAll('.service-select').forEach(label => {
|
||||||
|
label.onclick = () => {
|
||||||
|
document.querySelectorAll('.service-select').forEach(l => l.classList.remove('active', 'border-warning', 'bg-warning-light'));
|
||||||
|
label.classList.add('active', 'border-warning', 'bg-warning-light');
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
31
addons/dine360_self_order/views/restaurant_table_views.xml
Normal file
31
addons/dine360_self_order/views/restaurant_table_views.xml
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="view_restaurant_table_form_inherit_self_order" model="ir.ui.view">
|
||||||
|
<field name="name">restaurant.table.form.inherit.self.order</field>
|
||||||
|
<field name="model">restaurant.table</field>
|
||||||
|
<field name="inherit_id" ref="pos_restaurant.view_restaurant_table_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<field name="seats" position="after">
|
||||||
|
<field name="self_order_url" widget="url" readonly="1" string="Self-Order URL"/>
|
||||||
|
</field>
|
||||||
|
<xpath expr="//sheet" position="before">
|
||||||
|
<header>
|
||||||
|
<button name="action_generate_qr_code" string="Open Front-end" type="object" class="btn-primary"/>
|
||||||
|
</header>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_restaurant_table_tree_self_order" model="ir.ui.view">
|
||||||
|
<field name="name">restaurant.table.tree.self.order</field>
|
||||||
|
<field name="model">restaurant.table</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<tree string="Restaurant Tables">
|
||||||
|
<field name="floor_id"/>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="seats"/>
|
||||||
|
<field name="self_order_url" widget="url" readonly="1"/>
|
||||||
|
</tree>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
124
addons/dine360_self_order/views/self_order_templates.xml
Normal file
124
addons/dine360_self_order/views/self_order_templates.xml
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<template id="self_order_menu_template" name="Self-Order Menu">
|
||||||
|
<t t-call="website.layout">
|
||||||
|
<div id="self_order_app" class="bg-light min-vh-100 pb-5">
|
||||||
|
<!-- Data injection for JS -->
|
||||||
|
<div id="self_order_data" class="d-none"
|
||||||
|
t-att-data-table-id="table.id if table else ''"
|
||||||
|
t-att-data-floor-id="floor.id if floor else ''"
|
||||||
|
t-att-data-table-name="table.display_name if table else ''"/>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-dark bg-dark sticky-top shadow-sm py-2">
|
||||||
|
<div class="container px-3 d-flex justify-content-between">
|
||||||
|
<a class="navbar-brand fw-bold d-flex align-items-center" href="/dine360/menu">
|
||||||
|
<i class="fa fa-cutlery me-2 text-warning"/> Dine360
|
||||||
|
</a>
|
||||||
|
<div t-if="table" class="text-white-50 small">
|
||||||
|
<span class="badge bg-warning text-dark px-2 py-1 rounded-pill">
|
||||||
|
Table <t t-esc="table.name"/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div t-else="" class="text-white-50 small">
|
||||||
|
<span class="badge bg-info text-dark px-2 py-1 rounded-pill">
|
||||||
|
Kiosk Mode
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div id="loading_overlay" class="d-flex justify-content-center align-items-center py-5">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content (Populated by JS) -->
|
||||||
|
<div id="self_order_content" class="container py-3 d-none">
|
||||||
|
|
||||||
|
<!-- Search & Filter -->
|
||||||
|
<div class="mb-4 d-flex gap-2 sticky-top bg-light py-2" style="top: 56px; z-index: 100;">
|
||||||
|
<div class="input-group search-bar shadow-sm">
|
||||||
|
<span class="input-group-text bg-white border-0 ps-3">
|
||||||
|
<i class="fa fa-search text-muted"/>
|
||||||
|
</span>
|
||||||
|
<input type="text" id="product_search" class="form-control border-0 py-2" placeholder="Search for food..."/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Categories Scroll -->
|
||||||
|
<div id="category_filter" class="d-flex overflow-auto gap-2 pb-3 mb-3 no-scrollbar" style="white-space: nowrap;">
|
||||||
|
<!-- JS injects category buttons here -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Products List -->
|
||||||
|
<div id="product_list" class="row g-3">
|
||||||
|
<!-- JS injects product cards here -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer Cart Button -->
|
||||||
|
<div id="footer_cart" class="fixed-bottom p-3 d-none shadow-lg bg-white border-top transition-all" style="transform: translateY(100%);">
|
||||||
|
<div class="container d-flex justify-content-between align-items-center">
|
||||||
|
<div class="cart-info">
|
||||||
|
<span id="cart_count" class="badge bg-primary rounded-pill me-2">0</span>
|
||||||
|
<span id="cart_total" class="fw-bold fs-5 text-dark">$0.00</span>
|
||||||
|
</div>
|
||||||
|
<button id="view_cart_btn" class="btn btn-warning btn-lg px-4 fw-bold rounded-pill shadow-sm">
|
||||||
|
View Order <i class="fa fa-arrow-right ms-2"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cart Modal -->
|
||||||
|
<div class="modal fade" id="cart_modal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-dialog-centered modal-fullscreen-sm-down">
|
||||||
|
<div class="modal-content border-0 overflow-hidden" style="border-radius: 20px;">
|
||||||
|
<div class="modal-header border-0 bg-dark text-white p-4">
|
||||||
|
<h5 class="modal-title fw-bold">My Order</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"/>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body p-0">
|
||||||
|
<div id="cart_items_list" class="p-4" style="max-height: 400px; overflow-y: auto;">
|
||||||
|
<!-- JS injects checkout lines here -->
|
||||||
|
</div>
|
||||||
|
<div t-if="not table" class="p-4 bg-light border-top">
|
||||||
|
<h6 class="fw-bold mb-3">Service Type</h6>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<label class="flex-fill border p-3 rounded-3 text-center cursor-pointer service-select active" data-mode="pickup">
|
||||||
|
<input type="radio" name="fulfilment" value="pickup" checked="checked" class="d-none"/>
|
||||||
|
<i class="fa fa-shopping-basket mb-2 d-block"/> Pickup
|
||||||
|
</label>
|
||||||
|
<label class="flex-fill border p-3 rounded-3 text-center cursor-pointer service-select" data-mode="delivery">
|
||||||
|
<input type="radio" name="fulfilment" value="delivery" class="d-none"/>
|
||||||
|
<i class="fa fa-truck mb-2 d-block"/> Delivery
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="px-4 py-3 bg-white border-top">
|
||||||
|
<div class="d-flex justify-content-between mb-2">
|
||||||
|
<span class="text-muted">Subtotal</span>
|
||||||
|
<span id="checkout_subtotal">$0.00</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<span class="fw-bold fs-4">Total</span>
|
||||||
|
<span id="checkout_total" class="fw-bold fs-4 text-warning">$0.00</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer border-0 p-4 pt-0">
|
||||||
|
<button id="submit_order_btn" class="btn btn-warning w-100 btn-lg fw-bold rounded-pill shadow">
|
||||||
|
Send to Kitchen <i class="fa fa-paper-plane ms-2"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</template>
|
||||||
|
</odoo>
|
||||||
Loading…
x
Reference in New Issue
Block a user