Implement online order management with KDS integration and a custom POS navbar.

This commit is contained in:
Alaguraj0361 2026-03-06 18:00:13 +05:30
parent 015f703026
commit d58a1fd30f
23 changed files with 1270 additions and 13 deletions

View File

@ -82,10 +82,12 @@ class PosOrderLine(models.Model):
def create(self, vals_list):
"""Override create to send notifications to KDS when new orders are added"""
lines = super(PosOrderLine, self).create(vals_list)
# Send notification to KDS backend only for new items (waiting status)
waiting_lines = lines.filtered(lambda l: l.preparation_status == 'waiting')
if waiting_lines:
waiting_lines._notify_kds()
# Skip KDS notification if flagged (online orders wait for cashier confirmation)
if not self.env.context.get('skip_kds_notify'):
# Send notification to KDS backend only for new items (waiting status)
waiting_lines = lines.filtered(lambda l: l.preparation_status == 'waiting')
if waiting_lines:
waiting_lines._notify_kds()
return lines
def write(self, vals):

View File

@ -5,6 +5,6 @@ class ProductTemplate(models.Model):
is_kitchen_item = fields.Boolean(
string='Show in KDS',
default=True,
default=False,
help="If checked, this product will appear in the Kitchen Display System when ordered."
)

View File

@ -57,7 +57,7 @@ class SaleOrder(models.Model):
continue
# Skip non-kitchen items (delivery charges, shipping, etc.)
if not line.product_id.is_kitchen_item:
if not line.product_id.is_kitchen_item or line.product_id.type == 'service':
continue
lines_data.append((0, 0, {
@ -68,19 +68,24 @@ class SaleOrder(models.Model):
'price_subtotal_incl': line.price_total,
'full_product_name': line.name,
'tax_ids': [(6, 0, line.tax_id.ids)],
# Key for KDS:
'preparation_status': 'waiting',
# Online orders: hold for cashier confirmation before sending to KDS
'preparation_status': False,
'customer_note': 'Web Order',
}))
if not lines_data:
return
# Generate proper POS reference using sequence
pos_reference = session.config_id.sequence_id.next_by_id() if session.config_id.sequence_id else f"Order {sale_order.name}"
# Generate proper POS reference matching Odoo's regex pattern '([0-9-]){14,}'
# Odoo's _export_for_ui expects this to exist otherwise it crashes
import datetime
uid = f"{datetime.datetime.now().strftime('%Y%m%d%H%M%S')}-{session.id}-{sale_order.id}"
pos_reference = f"Order {uid}"
# 4. Create POS Order (in Draft/New state to avoid double accounting)
pos_order = PosOrder.create({
# Use skip_kds_notify context to prevent immediate KDS notification
# Online orders will be sent to KDS only after cashier confirmation
pos_order = PosOrder.with_context(skip_kds_notify=True).create({
'session_id': session.id,
'company_id': sale_order.company_id.id,
'partner_id': sale_order.partner_id.id,
@ -95,5 +100,5 @@ class SaleOrder(models.Model):
# 'state': 'draft', # Default is draft
})
# Trigger KDS notification (handled by create method of pos.order.line in dine360_kds)
_logger.info(f"Created POS Order {pos_order.name} from Website Order {sale_order.name} for KDS.")
# Notification to KDS is deferred until cashier confirms via dine360_online_orders
_logger.info(f"Created POS Order {pos_order.name} from Website Order {sale_order.name} (pending cashier confirmation).")

View File

@ -0,0 +1 @@
from . import models

View File

@ -0,0 +1,31 @@
{
'name': 'Dine360 Online Orders in POS',
'version': '17.0.1.0',
'category': 'Sales/Point of Sale',
'summary': 'Receive website shop orders on POS screen with KDS integration',
'description': """
Online Orders Integration for POS:
- Website shop orders appear in POS as a new 'Online Orders' tab
- Cashier can confirm or reject online orders
- Confirmed orders are sent to Kitchen Display System (KDS)
- Real-time notifications via bus service
""",
'author': 'Dine360',
'depends': ['point_of_sale', 'pos_restaurant', 'dine360_kds', 'website_sale', 'sale_management'],
'data': [
'security/ir.model.access.csv',
'views/pos_order_views.xml',
'views/kds_override_views.xml',
],
'assets': {
'point_of_sale._assets_pos': [
'dine360_online_orders/static/src/css/online_orders.css',
'dine360_online_orders/static/src/js/online_orders_screen.js',
'dine360_online_orders/static/src/js/online_orders_navbar.js',
'dine360_online_orders/static/src/xml/online_orders_screen.xml',
],
},
'installable': True,
'application': False,
'license': 'LGPL-3',
}

View File

@ -0,0 +1,2 @@
from . import pos_order
from . import sale_order

View File

@ -0,0 +1,119 @@
from odoo import models, fields, api, _
import logging
_logger = logging.getLogger(__name__)
class PosOrder(models.Model):
_inherit = 'pos.order'
is_online_order = fields.Boolean(
string='Online Order',
default=False,
help='Indicates this order came from the website shop'
)
online_order_status = fields.Selection([
('pending', 'Pending'),
('confirmed', 'Confirmed'),
('rejected', 'Rejected'),
], string='Online Order Status', default='pending')
sale_order_id = fields.Many2one(
'sale.order', string='Source Sale Order',
help='The website sale order that generated this POS order'
)
online_customer_name = fields.Char(
string='Online Customer',
compute='_compute_online_customer_name', store=True
)
online_order_date = fields.Datetime(
string='Online Order Date',
default=fields.Datetime.now
)
@api.depends('partner_id', 'partner_id.name')
def _compute_online_customer_name(self):
for order in self:
order.online_customer_name = order.partner_id.name or 'Guest'
def action_confirm_online_order(self):
"""Cashier confirms the online order → sends to KDS"""
self.ensure_one()
self.write({'online_order_status': 'confirmed'})
# Set all order lines to 'waiting' so KDS picks them up
for line in self.lines:
if line.product_id.is_kitchen_item and line.product_id.type != 'service':
line.write({
'preparation_status': 'waiting',
})
# Notify KDS
self.lines.filtered(
lambda l: l.product_id.is_kitchen_item and l.product_id.type != 'service'
)._notify_kds()
# Notify POS that order was confirmed
if self.config_id:
channel = "online_orders_%s" % self.config_id.id
self.env['bus.bus']._sendone(channel, 'online_order_confirmed', {
'order_id': self.id,
'order_name': self.pos_reference or self.name,
})
_logger.info("Online order %s confirmed and sent to KDS", self.name)
return True
def action_reject_online_order(self):
"""Cashier rejects the online order"""
self.ensure_one()
self.write({'online_order_status': 'rejected'})
# Notify POS
if self.config_id:
channel = "online_orders_%s" % self.config_id.id
self.env['bus.bus']._sendone(channel, 'online_order_rejected', {
'order_id': self.id,
'order_name': self.pos_reference or self.name,
})
_logger.info("Online order %s rejected", self.name)
return True
@api.model
def get_online_orders(self, config_id):
"""Fetch pending online orders for a specific POS config"""
domain = [
('is_online_order', '=', True),
('online_order_status', '=', 'pending'),
('config_id', '=', config_id),
]
orders = self.search(domain, order='online_order_date desc')
result = []
for order in orders:
lines = []
for line in order.lines:
lines.append({
'id': line.id,
'product_name': line.full_product_name or line.product_id.name,
'qty': line.qty,
'price_unit': line.price_unit,
'price_subtotal_incl': line.price_subtotal_incl,
'customer_note': line.customer_note or '',
'is_kitchen_item': line.product_id.is_kitchen_item,
})
result.append({
'id': order.id,
'name': order.pos_reference or order.name,
'partner_name': order.partner_id.name or 'Guest',
'partner_phone': order.partner_id.phone or order.partner_id.mobile or '',
'amount_total': order.amount_total,
'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 '',
'note': order.note or '',
'lines': lines,
})
return result

View File

@ -0,0 +1,62 @@
from odoo import models, fields, api, _
import logging
_logger = logging.getLogger(__name__)
class SaleOrderOnline(models.Model):
_inherit = 'sale.order'
pos_order_id = fields.Many2one(
'pos.order', string='POS Order',
help='The POS order created from this website sale order'
)
def _create_pos_order_for_kds(self, sale_order):
"""
Override from dine360_kds to also mark the POS order as an online order.
This method is called by dine360_kds.website_sale_integration when a
website sale order is confirmed.
"""
# Let the parent create the POS order
super(SaleOrderOnline, self)._create_pos_order_for_kds(sale_order)
# Now find the POS order that was just created and mark it
# We look for the most recent POS order linked to this sale order's partner
# with the note containing the sale order name
PosOrder = self.env['pos.order']
pos_order = PosOrder.search([
('note', 'like', sale_order.name),
], order='id desc', limit=1)
if pos_order:
pos_order.write({
'is_online_order': True,
'online_order_status': 'pending',
'sale_order_id': sale_order.id,
'online_order_date': fields.Datetime.now(),
})
# Link back to sale order
sale_order.write({'pos_order_id': pos_order.id})
# Set all lines to a "hold" state - they will go to KDS only when cashier confirms
for line in pos_order.lines:
if line.product_id.is_kitchen_item:
line.write({'preparation_status': 'waiting'})
# Send bus notification to POS
if pos_order.config_id:
channel = "online_orders_%s" % pos_order.config_id.id
self.env['bus.bus']._sendone(channel, 'new_online_order', {
'order_id': pos_order.id,
'order_name': pos_order.pos_reference or pos_order.name,
'customer_name': sale_order.partner_id.name or 'Guest',
'amount_total': pos_order.amount_total,
'items_count': len(pos_order.lines),
})
_logger.info(
"Marked POS Order %s as online order from Sale Order %s",
pos_order.name, sale_order.name
)

View File

@ -0,0 +1,2 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_pos_order_online,pos.order.online,point_of_sale.model_pos_order,point_of_sale.group_pos_user,1,1,1,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_pos_order_online pos.order.online point_of_sale.model_pos_order point_of_sale.group_pos_user 1 1 1 0

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@ -0,0 +1,320 @@
/* ============================================ */
/* Dine360 Online Orders Screen - POS */
/* ============================================ */
.online-orders-screen {
background: #1a1a2e;
color: #eee;
font-family: 'Inter', 'Segoe UI', sans-serif;
}
/* Header */
.online-orders-header {
background: linear-gradient(135deg, #16213e 0%, #0f3460 100%);
border-bottom: 2px solid rgba(255, 255, 255, 0.1);
min-height: 60px;
}
.online-orders-title {
color: #fff;
font-size: 1.4rem;
}
.order-count-badge {
font-size: 0.8rem;
padding: 4px 10px;
border-radius: 20px;
animation: pulse-badge 2s infinite;
}
@keyframes pulse-badge {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.15);
}
}
.btn-back {
border-radius: 10px;
font-weight: 600;
}
.btn-refresh {
border-radius: 10px;
font-weight: 600;
border-color: rgba(255, 255, 255, 0.3);
color: rgba(255, 255, 255, 0.8);
}
.btn-refresh:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
/* Body */
.online-orders-body {
background: #1a1a2e;
}
/* Left Panel - Orders List */
.online-orders-list {
width: 380px;
min-width: 380px;
overflow-y: auto;
border-right: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(0, 0, 0, 0.15);
}
/* Order Card */
.order-card {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 14px;
cursor: pointer;
transition: all 0.25s ease;
}
.order-card:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
}
.order-card.selected {
background: rgba(15, 52, 96, 0.5);
border-color: #e94560;
box-shadow: 0 0 15px rgba(233, 69, 96, 0.2);
}
.order-ref {
color: #fff;
font-size: 0.95rem;
}
.order-total {
color: #53cf8a;
font-size: 1.1rem;
}
.order-customer {
color: #ccc;
font-size: 0.9rem;
}
.order-date {
font-size: 0.8rem;
}
/* Action Buttons in Card */
.btn-confirm-order {
border-radius: 8px;
font-weight: 600;
background: #27ae60;
border: none;
padding: 8px 0;
}
.btn-confirm-order:hover {
background: #2ecc71;
}
.btn-reject-order {
border-radius: 8px;
font-weight: 600;
background: transparent;
border: 1px solid #e74c3c;
color: #e74c3c;
padding: 8px 0;
}
.btn-reject-order:hover {
background: #e74c3c;
color: #fff;
}
/* Right Panel - Order Detail */
.online-order-detail {
overflow-y: auto;
}
.detail-card {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
height: 100%;
}
.detail-section {
padding-bottom: 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.detail-section:last-of-type {
border-bottom: none;
}
/* Customer Avatar */
.customer-avatar {
width: 44px;
height: 44px;
border-radius: 50%;
background: linear-gradient(135deg, #e94560, #0f3460);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
color: #fff;
}
/* Note Box */
.note-box {
background: rgba(255, 193, 7, 0.1);
border: 1px solid rgba(255, 193, 7, 0.2);
color: #ffc107;
font-style: italic;
}
/* Order Lines Table */
.order-lines-table {
color: #ddd;
}
.order-lines-table thead th {
border-bottom: 2px solid rgba(255, 255, 255, 0.15);
color: #aaa;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 10px 8px;
}
.order-lines-table tbody td {
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
padding: 12px 8px;
vertical-align: middle;
}
.order-lines-table tbody tr:hover {
background: rgba(255, 255, 255, 0.05);
}
.total-row td {
border-top: 2px solid rgba(255, 255, 255, 0.2) !important;
padding-top: 14px;
font-size: 1.15rem;
}
.order-total-amount {
color: #53cf8a;
font-size: 1.3rem !important;
}
/* Kitchen Badge */
.kitchen-badge {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.7rem;
background: rgba(255, 255, 255, 0.1);
color: #888;
}
.kitchen-badge.active {
background: rgba(233, 69, 96, 0.2);
color: #e94560;
}
/* Detail Action Buttons */
.btn-confirm-detail {
border-radius: 12px;
font-weight: 700;
font-size: 1.05rem;
background: linear-gradient(135deg, #27ae60, #2ecc71);
border: none;
padding: 14px 24px;
box-shadow: 0 4px 15px rgba(46, 204, 113, 0.3);
}
.btn-confirm-detail:hover {
background: linear-gradient(135deg, #2ecc71, #27ae60);
transform: translateY(-1px);
box-shadow: 0 6px 20px rgba(46, 204, 113, 0.4);
}
.btn-reject-detail {
border-radius: 12px;
font-weight: 600;
padding: 14px 24px;
}
/* Empty State */
.empty-state .empty-icon {
font-size: 5rem;
color: rgba(255, 255, 255, 0.15);
display: block;
}
/* Navbar Button */
.online-orders-nav-btn {
background: rgba(233, 69, 96, 0.15);
border: 1px solid rgba(233, 69, 96, 0.3);
color: #e94560;
border-radius: 10px;
padding: 6px 14px;
font-weight: 600;
font-size: 0.85rem;
transition: all 0.25s ease;
}
.online-orders-nav-btn:hover {
background: #e94560;
color: #fff;
border-color: #e94560;
}
/* Scrollbar */
.online-orders-list::-webkit-scrollbar,
.online-order-detail::-webkit-scrollbar {
width: 6px;
}
.online-orders-list::-webkit-scrollbar-thumb,
.online-order-detail::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 3px;
}
.online-orders-list::-webkit-scrollbar-track,
.online-order-detail::-webkit-scrollbar-track {
background: transparent;
}
/* Spinner */
.spinner-border {
color: #e94560 !important;
}
/* Responsive */
@media (max-width: 768px) {
.online-orders-list {
width: 100%;
min-width: 100%;
}
.online-order-detail {
display: none;
}
.order-card.selected+.online-order-detail {
display: block;
}
}

View File

@ -0,0 +1,14 @@
/** @odoo-module */
import { Navbar } from "@point_of_sale/app/navbar/navbar";
import { patch } from "@web/core/utils/patch";
console.log("[OnlineOrders] Patching Navbar...");
patch(Navbar.prototype, {
onClickOnlineOrders() {
this.pos.showScreen("OnlineOrdersScreen");
},
});
console.log("[OnlineOrders] Navbar patched!");

View File

@ -0,0 +1,170 @@
/** @odoo-module */
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl";
import { usePos } from "@point_of_sale/app/store/pos_hook";
console.log("[OnlineOrders] Module loading...");
export class OnlineOrdersScreen extends Component {
static template = "dine360_online_orders.OnlineOrdersScreen";
setup() {
try {
console.log("[OnlineOrders] Setup starting...");
this.pos = usePos();
// Direct access to services to avoid 'methods is not iterable' error
this.orm = this.env.services.orm;
this.notification = this.env.services.pos_notification;
this.busService = this.env.services.bus_service;
this.state = useState({
orders: [],
loading: true,
selectedOrder: null,
confirmingId: null,
});
console.log("[OnlineOrders] Services obtained:", {
hasOrm: !!this.orm,
hasNotif: !!this.notification,
hasBus: !!this.busService
});
// Subscribe to bus notifications for real-time updates
const channel = `online_orders_${this.pos.config.id}`;
console.log("[OnlineOrders] Subscribing to channel:", channel);
if (this.busService) {
this.busService.addChannel(channel);
this._notifHandler = this._onNotification.bind(this);
this.busService.addEventListener("notification", this._notifHandler);
}
} catch (err) {
console.error("[OnlineOrders] Setup Error:", err);
}
onMounted(() => {
this.loadOnlineOrders();
// Auto-refresh every 30 seconds
this._refreshInterval = setInterval(() => {
this.loadOnlineOrders();
}, 30000);
});
onWillUnmount(() => {
if (this._refreshInterval) {
clearInterval(this._refreshInterval);
}
if (this.busService && this._notifHandler) {
this.busService.removeEventListener("notification", this._notifHandler);
}
});
}
_onNotification(event) {
const notifications = event.detail || [];
for (const notif of notifications) {
if (notif.type === "new_online_order") {
console.log("[OnlineOrders] New order received!", notif.payload);
this.notification.add(
`New Online Order from ${notif.payload.customer_name} - ${this.env.utils.formatCurrency(notif.payload.amount_total)}`
);
this.loadOnlineOrders();
}
if (notif.type === "online_order_confirmed" || notif.type === "online_order_rejected") {
this.loadOnlineOrders();
}
}
}
async loadOnlineOrders() {
try {
this.state.loading = true;
const orders = await this.orm.call(
"pos.order",
"get_online_orders",
[this.pos.config.id]
);
this.state.orders = orders;
this.state.loading = false;
console.log("[OnlineOrders] Loaded", orders.length, "orders");
} catch (error) {
console.error("[OnlineOrders] Error loading orders:", error);
this.state.loading = false;
}
}
selectOrder(order) {
this.state.selectedOrder = order;
}
async confirmOrder(orderId) {
try {
this.state.confirmingId = orderId;
await this.orm.call(
"pos.order",
"action_confirm_online_order",
[[orderId]]
);
this.notification.add("Order confirmed and sent to kitchen! 🍳");
// Remove from list
this.state.orders = this.state.orders.filter(o => o.id !== orderId);
if (this.state.selectedOrder && this.state.selectedOrder.id === orderId) {
this.state.selectedOrder = null;
}
this.state.confirmingId = null;
} catch (error) {
console.error("[OnlineOrders] Confirm error:", error);
this.notification.add("Failed to confirm order");
this.state.confirmingId = null;
}
}
async rejectOrder(orderId) {
try {
await this.orm.call(
"pos.order",
"action_reject_online_order",
[[orderId]]
);
this.notification.add("Order has been rejected");
this.state.orders = this.state.orders.filter(o => o.id !== orderId);
if (this.state.selectedOrder && this.state.selectedOrder.id === orderId) {
this.state.selectedOrder = null;
}
} catch (error) {
console.error("[OnlineOrders] Reject error:", error);
this.notification.add("Failed to reject order");
}
}
formatDate(isoDate) {
if (!isoDate) return "";
const d = new Date(isoDate);
return d.toLocaleString();
}
formatCurrency(amount) {
return this.env.utils.formatCurrency(amount);
}
get orderCount() {
return this.state.orders.length;
}
back() {
if (this.pos.config.module_pos_restaurant && !this.pos.get_order()) {
this.pos.showScreen("FloorScreen");
} else {
this.pos.showScreen("ProductScreen");
}
}
}
// Register the screen
registry.category("pos_screens").add("OnlineOrdersScreen", OnlineOrdersScreen);
console.log("[OnlineOrders] Screen registered!");

View File

@ -0,0 +1,247 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<!-- Online Orders Screen -->
<t t-name="dine360_online_orders.OnlineOrdersScreen" owl="1">
<div class="online-orders-screen screen d-flex flex-column h-100">
<!-- Header -->
<div class="online-orders-header d-flex align-items-center justify-content-between px-4 py-3">
<div class="d-flex align-items-center gap-3">
<button class="btn btn-light btn-back" t-on-click="back">
<i class="fa fa-arrow-left me-2"/>Back
</button>
<h2 class="mb-0 fw-bold online-orders-title">
<i class="fa fa-shopping-cart me-2"/>
Online Orders
<span class="badge bg-danger ms-2 order-count-badge" t-if="orderCount > 0">
<t t-esc="orderCount"/>
</span>
</h2>
</div>
<button class="btn btn-outline-light btn-refresh" t-on-click="loadOnlineOrders">
<i class="fa fa-refresh me-1"/>Refresh
</button>
</div>
<!-- Content -->
<div class="online-orders-body flex-grow-1 d-flex overflow-hidden">
<!-- Loading State -->
<div t-if="state.loading" class="d-flex align-items-center justify-content-center w-100">
<div class="text-center">
<div class="spinner-border text-primary mb-3" style="width: 3rem; height: 3rem;"/>
<p class="text-muted">Loading online orders...</p>
</div>
</div>
<!-- Empty State -->
<div t-elif="state.orders.length === 0" class="d-flex align-items-center justify-content-center w-100">
<div class="text-center empty-state">
<i class="fa fa-inbox empty-icon mb-3"/>
<h3 class="text-muted">No Pending Orders</h3>
<p class="text-muted">New website orders will appear here automatically</p>
</div>
</div>
<!-- Orders List -->
<t t-else="">
<!-- Left Panel: Order Cards -->
<div class="online-orders-list p-3">
<t t-foreach="state.orders" t-as="order" t-key="order.id">
<div t-attf-class="order-card mb-3 p-3 #{state.selectedOrder and state.selectedOrder.id === order.id ? 'selected' : ''}"
t-on-click="() => this.selectOrder(order)">
<!-- Order Header -->
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<span class="order-ref fw-bold">
<i class="fa fa-receipt me-1"/>
<t t-esc="order.name"/>
</span>
<span class="badge bg-info ms-2">ONLINE</span>
</div>
<span class="order-total fw-bold">
<t t-esc="formatCurrency(order.amount_total)"/>
</span>
</div>
<!-- Customer Info -->
<div class="order-customer mb-2">
<i class="fa fa-user me-1 text-muted"/>
<span t-esc="order.partner_name"/>
<t t-if="order.partner_phone">
<span class="ms-2 text-muted">
<i class="fa fa-phone me-1"/>
<t t-esc="order.partner_phone"/>
</span>
</t>
</div>
<!-- Items Summary -->
<div class="order-items-summary text-muted small">
<t t-esc="order.lines.length"/> items
<t t-if="order.sale_order_name">
<span class="ms-2">
<i class="fa fa-link me-1"/>
<t t-esc="order.sale_order_name"/>
</span>
</t>
</div>
<!-- Date -->
<div class="order-date text-muted small mt-1">
<i class="fa fa-clock-o me-1"/>
<t t-esc="formatDate(order.date_order)"/>
</div>
<!-- Quick Actions -->
<div class="order-actions d-flex gap-2 mt-3">
<button class="btn btn-success btn-sm flex-grow-1 btn-confirm-order"
t-on-click.stop="() => this.confirmOrder(order.id)"
t-att-disabled="state.confirmingId === order.id">
<t t-if="state.confirmingId === order.id">
<span class="spinner-border spinner-border-sm me-1"/>
</t>
<t t-else="">
<i class="fa fa-check me-1"/>
</t>
Confirm
</button>
<button class="btn btn-danger btn-sm flex-grow-1 btn-reject-order"
t-on-click.stop="() => this.rejectOrder(order.id)">
<i class="fa fa-times me-1"/>Reject
</button>
</div>
</div>
</t>
</div>
<!-- Right Panel: Order Detail -->
<div class="online-order-detail flex-grow-1 p-3">
<t t-if="state.selectedOrder">
<div class="detail-card p-4">
<h4 class="mb-3 fw-bold">
<i class="fa fa-file-text me-2"/>
Order Details: <t t-esc="state.selectedOrder.name"/>
</h4>
<!-- Customer Detail -->
<div class="detail-section mb-4">
<h6 class="text-muted text-uppercase mb-2">Customer</h6>
<div class="d-flex align-items-center gap-2">
<div class="customer-avatar">
<i class="fa fa-user"/>
</div>
<div>
<div class="fw-bold" t-esc="state.selectedOrder.partner_name"/>
<div class="text-muted small" t-if="state.selectedOrder.partner_phone">
<i class="fa fa-phone me-1"/>
<t t-esc="state.selectedOrder.partner_phone"/>
</div>
</div>
</div>
</div>
<!-- Note -->
<div class="detail-section mb-4" t-if="state.selectedOrder.note">
<h6 class="text-muted text-uppercase mb-2">Note</h6>
<div class="note-box p-2 rounded">
<t t-esc="state.selectedOrder.note"/>
</div>
</div>
<!-- Order Lines -->
<div class="detail-section mb-4">
<h6 class="text-muted text-uppercase mb-2">Items</h6>
<table class="table order-lines-table">
<thead>
<tr>
<th>Item</th>
<th class="text-center">Qty</th>
<th class="text-end">Price</th>
<th class="text-end">Total</th>
</tr>
</thead>
<tbody>
<t t-foreach="state.selectedOrder.lines" t-as="line" t-key="line.id">
<tr>
<td>
<div class="d-flex align-items-center gap-2">
<span t-attf-class="kitchen-badge #{line.is_kitchen_item ? 'active' : ''}">
<i t-attf-class="fa #{line.is_kitchen_item ? 'fa-fire' : 'fa-circle-o'}"/>
</span>
<span t-esc="line.product_name"/>
</div>
<div class="text-muted small mt-1" t-if="line.customer_note">
<i class="fa fa-comment-o me-1"/>
<t t-esc="line.customer_note"/>
</div>
</td>
<td class="text-center fw-bold">
<t t-esc="line.qty"/>
</td>
<td class="text-end">
<t t-esc="formatCurrency(line.price_unit)"/>
</td>
<td class="text-end fw-bold">
<t t-esc="formatCurrency(line.price_subtotal_incl)"/>
</td>
</tr>
</t>
</tbody>
<tfoot>
<tr class="total-row">
<td colspan="3" class="text-end fw-bold">Total</td>
<td class="text-end fw-bold order-total-amount">
<t t-esc="formatCurrency(state.selectedOrder.amount_total)"/>
</td>
</tr>
</tfoot>
</table>
</div>
<!-- Detail Actions -->
<div class="detail-actions d-flex gap-3">
<button class="btn btn-success btn-lg flex-grow-1 btn-confirm-detail"
t-on-click="() => this.confirmOrder(state.selectedOrder.id)"
t-att-disabled="state.confirmingId === state.selectedOrder.id">
<t t-if="state.confirmingId === state.selectedOrder.id">
<span class="spinner-border spinner-border-sm me-2"/>
Confirming...
</t>
<t t-else="">
<i class="fa fa-check-circle me-2"/>
Confirm &amp; Send to Kitchen
</t>
</button>
<button class="btn btn-outline-danger btn-lg btn-reject-detail"
t-on-click="() => this.rejectOrder(state.selectedOrder.id)">
<i class="fa fa-times-circle me-1"/>Reject
</button>
</div>
</div>
</t>
<t t-else="">
<div class="d-flex align-items-center justify-content-center h-100 text-center">
<div>
<i class="fa fa-hand-pointer-o" style="font-size: 4rem; color: #ccc;"/>
<p class="mt-3 text-muted">Select an order to view details</p>
</div>
</div>
</t>
</div>
</t>
</div>
</div>
</t>
<!-- Navbar button for Online Orders -->
<t t-name="dine360_online_orders.NavbarButton" t-inherit="point_of_sale.Navbar" t-inherit-mode="extension" owl="1">
<xpath expr="//div[hasclass('status-buttons')]" position="before">
<button class="online-orders-nav-btn btn d-flex align-items-center gap-2 h-100 px-3 border-0"
t-on-click="onClickOnlineOrders">
<i class="fa fa-shopping-cart"/>
<span t-if="!ui.isSmall">Online Orders</span>
</button>
</xpath>
</t>
</templates>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Override KDS Dashboard Action to exclude pending online orders -->
<record id="dine360_kds.action_kds_dashboard" model="ir.actions.act_window">
<field name="domain">[
('product_id.is_kitchen_item', '=', True),
('product_id.name', '!=', 'Water'),
('order_id.session_id.state', '!=', 'closed'),
'|', ('product_id.pos_categ_ids', '=', False), ('product_id.pos_categ_ids.name', '!=', 'Drinks'),
'|', ('order_id.is_online_order', '=', False), ('order_id.online_order_status', '!=', 'pending')
]</field>
</record>
</odoo>

View File

@ -0,0 +1,68 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Tree View for Online Orders -->
<record id="view_pos_order_online_tree" model="ir.ui.view">
<field name="name">pos.order.online.tree</field>
<field name="model">pos.order</field>
<field name="arch" type="xml">
<tree string="Online Orders" decoration-warning="online_order_status=='pending'" decoration-success="online_order_status=='confirmed'" decoration-danger="online_order_status=='rejected'">
<field name="pos_reference"/>
<field name="partner_id"/>
<field name="date_order"/>
<field name="amount_total"/>
<field name="online_order_status" widget="badge"
decoration-warning="online_order_status=='pending'"
decoration-success="online_order_status=='confirmed'"
decoration-danger="online_order_status=='rejected'"/>
<field name="sale_order_id"/>
<field name="config_id"/>
</tree>
</field>
</record>
<!-- Form View extension -->
<record id="view_pos_order_online_form" model="ir.ui.view">
<field name="name">pos.order.online.form.inherit</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="is_online_order" invisible="not is_online_order"/>
<field name="online_order_status" invisible="not is_online_order" widget="badge"
decoration-warning="online_order_status=='pending'"
decoration-success="online_order_status=='confirmed'"
decoration-danger="online_order_status=='rejected'"/>
<field name="sale_order_id" invisible="not is_online_order"/>
</xpath>
</field>
</record>
<!-- Action for Online Orders -->
<record id="action_online_orders" model="ir.actions.act_window">
<field name="name">Online Orders</field>
<field name="res_model">pos.order</field>
<field name="view_mode">tree,form</field>
<field name="domain">[('is_online_order', '=', True)]</field>
<field name="context">{'search_default_pending': 1}</field>
</record>
<!-- Search View -->
<record id="view_pos_order_online_search" model="ir.ui.view">
<field name="name">pos.order.online.search</field>
<field name="model">pos.order</field>
<field name="arch" type="xml">
<search string="Online Orders">
<field name="pos_reference"/>
<field name="partner_id"/>
<field name="sale_order_id"/>
<filter name="pending" string="Pending" domain="[('online_order_status', '=', 'pending')]"/>
<filter name="confirmed" string="Confirmed" domain="[('online_order_status', '=', 'confirmed')]"/>
<filter name="rejected" string="Rejected" domain="[('online_order_status', '=', 'rejected')]"/>
<group expand="0" string="Group By">
<filter name="group_status" string="Status" context="{'group_by': 'online_order_status'}"/>
<filter name="group_date" string="Date" context="{'group_by': 'date_order'}"/>
</group>
</search>
</field>
</record>
</odoo>

View File

@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.

View File

@ -0,0 +1,18 @@
{
'name': 'Dine360 POS Navbar',
'version': '17.0.1.0',
'category': 'Point of Sale',
'summary': 'Custom POS Navbar mimicking Odoo 19 style',
'depends': ['point_of_sale'],
'data': [],
'assets': {
'point_of_sale._assets_pos': [
'dine360_pos_navbar/static/src/css/pos_navbar.css',
'dine360_pos_navbar/static/src/js/pos_navbar.js',
'dine360_pos_navbar/static/src/xml/pos_navbar.xml',
],
},
'installable': True,
'application': False,
'license': 'LGPL-3',
}

View File

@ -0,0 +1,5 @@
/* Placeholder for custom navbar styling */
.pos .pos-topheader {
/* Custom style placeholder */
background: inherit;
}

View File

@ -0,0 +1,9 @@
/** @odoo-module */
import { Navbar } from "@point_of_sale/app/navbar/navbar";
import { patch } from "@web/core/utils/patch";
// Removing empty/broken setup patch as it was causing the POS to crash.
// Any future navbar customizations should go here.
patch(Navbar.prototype, {
// Other navbar methods can be patched here safely
});

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<!-- Placeholder for custom navbar styling/elements -->
</templates>

View File

@ -0,0 +1,114 @@
<?xml version="1.0"?>
<odoo>
<!-- RESTAURANTS FLOORS -->
<record id="view_restaurant_floor_form" model="ir.ui.view">
<field name="name">Restaurant Floors</field>
<field name="model">restaurant.floor</field>
<field name="arch" type="xml">
<form string="Restaurant Floor">
<sheet>
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger" invisible="active"/>
<field name="active" invisible="1"/>
<group col="4">
<field name="name" />
<field name="pos_config_ids" widget="many2many_tags"/>
<field name="background_color" groups="base.group_no_one" />
</group>
<field name="table_ids">
<tree string='Tables'>
<field name="name" />
<field name="seats" />
<field name="shape" />
</tree>
</field>
</sheet>
</form>
</field>
</record>
<record id="view_restaurant_floor_tree" model="ir.ui.view">
<field name="name">Restaurant Floors</field>
<field name="model">restaurant.floor</field>
<field name="arch" type="xml">
<tree string="Restaurant Floors">
<field name="sequence" widget="handle" />
<field name="name" />
<field name="pos_config_ids" widget="many2many_tags"/>
</tree>
</field>
</record>
<record id="view_restaurant_floor_search" model="ir.ui.view">
<field name="name">restaurant.floor.search</field>
<field name="model">restaurant.floor</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<filter string="Archived" name="active" domain="[('active', '=', False)]"/>
</search>
</field>
</record>
<record id="view_restaurant_floor_kanban" model="ir.ui.view">
<field name="name">restaurant.floor.kanban</field>
<field name="model">restaurant.floor</field>
<field name="arch" type="xml">
<kanban class="o_kanban_mobile">
<field name="name"/>
<field name="pos_config_ids" />
<templates>
<t t-name="kanban-box">
<div t-attf-class="oe_kanban_global_click">
<div><strong>Floor Name: </strong><t t-esc="record.name.value"/></div>
<div><strong>Point of Sales: </strong><t t-esc="record.pos_config_ids.value"/></div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<record id="action_restaurant_floor_form" model="ir.actions.act_window">
<field name="name">Floor Plans</field>
<field name="res_model">restaurant.floor</field>
<field name="view_mode">tree,kanban,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Add a new restaurant floor
</p><p>
A restaurant floor represents the place where customers are served, this is where you can
define and position the tables.
</p>
</field>
</record>
<record id="view_restaurant_table_form" model="ir.ui.view">
<field name="name">Restaurant Table</field>
<field name="model">restaurant.table</field>
<field name="arch" type="xml">
<form string="Restaurant Table">
<sheet>
<group col="2">
<field name="name" />
<field name="seats" />
</group>
<group col="4" string="Appearance" groups="base.group_no_one">
<field name="shape" />
<field name="color" />
<field name="position_h" />
<field name="position_v" />
<field name="width" />
<field name="height" />
</group>
</sheet>
</form>
</field>
</record>
<menuitem id="menu_restaurant_floor_all"
parent="point_of_sale.menu_point_config_product"
action="action_restaurant_floor_form"
sequence="10"
groups="point_of_sale.group_pos_user"/>
</odoo>

View File

@ -0,0 +1,49 @@
<?xml version="1.0"?>
<odoo>
<record id="res_config_settings_view_form" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.pos_restaurant</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="point_of_sale.res_config_settings_view_form"/>
<field name="arch" type="xml">
<div id="warning_text_pos_restaurant" position="replace"/>
<block id="restaurant_section" position="inside">
<setting id="floor_and_table_map" string="Floors &amp; Tables Map" help="Design floors and assign orders to tables" invisible="is_kiosk_mode or not pos_module_pos_restaurant">
<div class="content-group">
<div class="mt16">
<label string="Floors" for="pos_floor_ids" class="o_light_label me-2"/>
<field name="pos_floor_ids" widget="many2many_tags" readonly="pos_has_active_session" />
</div>
<div>
<button name="%(pos_restaurant.action_restaurant_floor_form)d" icon="oi-arrow-right" type="action" string="Floors" class="btn-link"/>
</div>
</div>
</setting>
<setting string="Early Receipt Printing" help="Allow to print receipt before payment" id="iface_printbill" invisible="not pos_module_pos_restaurant or is_kiosk_mode">
<field name="pos_iface_printbill"/>
</setting>
<setting help="Split total or order lines" id="iface_splitbill" invisible="not pos_module_pos_restaurant or is_kiosk_mode">
<field name="pos_iface_splitbill" string="Allow Bill Splitting"/>
</setting>
<setting help="Online reservation for restaurant" invisible="not pos_module_pos_restaurant or is_kiosk_mode">
<field name="pos_module_pos_restaurant_appointment" string="Table Booking" widget="upgrade_boolean" />
<div class="content-group" id="pos_table_booking" invisible="not pos_module_pos_restaurant_appointment">
<div class="text-warning mt16 mb4">
Save this page and come back here to set up the feature.
</div>
</div>
</setting>
</block>
<div id="tip_product" position="after">
<div invisible="not pos_module_pos_restaurant or not pos_iface_tipproduct">
<field name="pos_set_tip_after_payment" class="oe_inline"/>
<label class="fw-normal" for="pos_set_tip_after_payment" string="Add tip after payment"/>
</div>
</div>
<block id="pos_interface_section" position="inside">
<setting string="Internal Notes" help="Add internal notes on order lines for the kitchen" id="iface_orderline_notes">
<field name="pos_iface_orderline_notes"/>
</setting>
</block>
</field>
</record>
</odoo>