Add Dine360 Dashboard Website module with custom controllers and views

- Created __init__.py and __manifest__.py for the new dine360_dashboard_website module.
- Implemented custom controllers to handle website routing and dashboard rendering.
- Added views for website logo and shop template to enhance the user interface.
- Updated dine360_reservation and dine360_restaurant modules to include dine360_table as a dependency.
- Enhanced restaurant table management with new views and actions for floors and tables.
- Updated docker-compose to change the exposed port for the Odoo service.
This commit is contained in:
Alaguraj0361 2026-06-23 20:38:41 +05:30
parent a03985749f
commit c82f392e15
22 changed files with 633 additions and 379 deletions

View File

@ -12,6 +12,7 @@
'dine360_theme_shivasakthi',
'dine360_kds',
'dine360_reservation',
'dine360_table',
'dine360_uber',
'dine360_recipe',
'dine360_self_order',

View File

@ -4,13 +4,11 @@
'license': 'LGPL-3',
'category': 'Website',
'summary': 'Redirect login to home and show icon grid',
'depends': ['base', 'web', 'auth_signup', 'website', 'website_sale'],
'depends': ['base', 'web', 'auth_signup'],
'data': [
'views/home_template.xml',
'views/login_templates.xml',
'views/web_title_template.xml',
'views/website_logo.xml',
'views/shop_template.xml',
'data/branding_data.xml',
],
'assets': {
@ -23,8 +21,6 @@
'web.assets_frontend': [
'dine360_dashboard/static/src/css/theme_variables.css',
'dine360_dashboard/static/src/css/login_style.css',
'dine360_dashboard/static/src/css/website_style.css',
'dine360_dashboard/static/src/css/shop_style.css',
],
'web.assets_common': [
'dine360_dashboard/static/src/css/theme_variables.css',

View File

@ -1,9 +1,10 @@
# -*- coding: utf-8 -*-
from odoo import http
from odoo.http import request
from odoo.addons.web.controllers.home import Home
class CustomHome(Home):
@http.route('/web/login', type='http', auth="public", website=True)
@http.route('/web/login', type='http', auth="public")
def web_login(self, *args, **kw):
response = super(CustomHome, self).web_login(*args, **kw)
if request.params.get('login_success') and request.session.uid:
@ -11,137 +12,109 @@ class CustomHome(Home):
return request.redirect('/')
return response
from odoo.addons.website.controllers.main import Website
def render_dashboard(request):
# 1. ROLE-BASED AUTO REDIRECTION (FOR STAFF)
# Skip the dashboard/website entirely for Chefs and Waiters
user = request.env.user.sudo()
is_admin = user.has_group('base.group_system') or \
user.has_group('dine360_restaurant.group_restaurant_admin')
class ImageHome(Website):
@http.route('/', type='http', auth='public', website=True, sitemap=True)
def index(self, **kwargs):
# -----------------------------------------------------------
# SUPER SAFE EDITOR & IFRAME DETECTION
# -----------------------------------------------------------
path = request.httprequest.path
params = request.params
headers = request.httprequest.headers
referer = headers.get('Referer', '')
fetch_dest = headers.get('Sec-Fetch-Dest', '')
if not is_admin:
# 1. WAITER / CASHIER -> Priority goes to POS
if user.has_group('dine360_restaurant.group_restaurant_waiter') or \
user.has_group('dine360_restaurant.group_restaurant_cashier'):
return request.redirect('/web#action=point_of_sale.action_client_pos_menu')
# 1. If not logged in, always show standard homepage
if not request.session.uid:
return super(ImageHome, self).index(**kwargs)
# 2. CHEF -> Directly to KDS
if user.has_group('dine360_restaurant.group_restaurant_kitchen'):
return request.redirect('/web#action=dine360_kds.action_kds_dashboard')
# 2. ROLE-BASED AUTO REDIRECTION (FOR STAFF)
# Skip the dashboard/website entirely for Chefs and Waiters
user = request.env.user.sudo()
is_admin = user.has_group('base.group_system') or \
user.has_group('dine360_restaurant.group_restaurant_admin')
# Remove sudo() to respect Odoo's standard menu group restrictions
menus = request.env['ir.ui.menu'].search([
('parent_id', '=', False)
], order='sequence')
if not is_admin:
# 1. WAITER / CASHIER -> Priority goes to POS
if user.has_group('dine360_restaurant.group_restaurant_waiter') or \
user.has_group('dine360_restaurant.group_restaurant_cashier'):
return request.redirect('/web#action=point_of_sale.action_client_pos_menu')
# User role checks
try:
is_admin = request.env.user.has_group('base.group_system') or \
request.env.user.has_group('dine360_restaurant.group_restaurant_admin')
except Exception:
is_admin = request.env.user.has_group('base.group_system')
# 2. CHEF -> Directly to KDS
if user.has_group('dine360_restaurant.group_restaurant_kitchen'):
return request.redirect('/web#action=dine360_kds.action_kds_dashboard')
# User requested to hide all standard apps and POS/KDS.
# Only allow specific menus based on user request + admin tools.
allowed_menus = ['Online Orders', 'Website', 'Table Reservations', 'Table Management', 'Uber Integration', 'Apps', 'Settings', 'Dine360 SaaS']
# 3. SUPER SAFE EDITOR & IFRAME DETECTION
path = request.httprequest.path
params = request.params
headers = request.httprequest.headers
referer = headers.get('Referer', '')
fetch_dest = headers.get('Sec-Fetch-Dest', '')
# Hide 'Website' icon from the parent level Dine360Restaurants database
if request.env.cr.dbname and 'Dine360Restaurants' in request.env.cr.dbname:
if 'Website' in allowed_menus:
allowed_menus.remove('Website')
# Check for ANY editor or backend signal
editor_params = ['enable_editor', 'edit', 'path', 'website_id', 'frontend_edit', 'model', 'id']
is_editor_request = any(p in params for p in editor_params)
is_from_backend = any(m in referer for m in ['/website/force', 'enable_editor'])
# Hide website/online orders if website module is not installed in current db
if 'website' not in request.env:
if 'Website' in allowed_menus:
allowed_menus.remove('Website')
if 'Online Orders' in allowed_menus:
allowed_menus.remove('Online Orders')
# if it looks like Odoo internal business, return the real website
if fetch_dest == 'iframe' or is_editor_request or is_from_backend:
return super(ImageHome, self).index(**kwargs)
filtered_menus = []
seen_names = set()
for menu in menus:
# Match strictly against allowed menus
if menu.name not in allowed_menus and menu.name != 'Table Reservation':
continue
if path != '/':
return super(ImageHome, self).index(**kwargs)
# Hide "Apps" and "Settings" for non-admins
if menu.name in ['Apps', 'Settings'] and not is_admin:
continue
# Remove sudo() to respect Odoo's standard menu group restrictions
menus = request.env['ir.ui.menu'].search([
('parent_id', '=', False)
], order='sequence')
# De-duplicate by name
if menu.name in seen_names:
continue
seen_names.add(menu.name)
# User role checks
try:
is_admin = request.env.user.has_group('base.group_system') or \
request.env.user.has_group('dine360_restaurant.group_restaurant_admin')
is_kitchen = request.env.user.has_group('dine360_restaurant.group_restaurant_kitchen')
except Exception:
is_admin = request.env.user.has_group('base.group_system')
is_kitchen = False
# Dynamic Icon Override (Dine360 Branding)
icon_mapping = {
'Apps': 'dine360_dashboard,static/src/img/icons/apps.svg',
'Settings': 'dine360_dashboard,static/src/img/icons/settings.svg',
'Table Reservation': 'dine360_dashboard,static/src/img/icons/table_reservation.svg',
'Table Reservations': 'dine360_dashboard,static/src/img/icons/table_reservation.svg',
'Uber Integration': 'dine360_dashboard,static/src/img/icons/uber_integration.svg',
'Online Orders': 'dine360_dashboard,static/src/img/icons/website.svg',
'Dine360 SaaS': 'dine360_dashboard,static/src/img/icons/saas.svg',
'SaaS': 'dine360_dashboard,static/src/img/icons/saas.svg',
}
# User requested to hide all standard apps and POS/KDS.
# Only allow specific menus based on user request + admin tools.
allowed_menus = ['Online Orders', 'Website', 'Table Reservations', 'Uber Integration', 'Apps', 'Settings', 'Dine360 SaaS']
# Find the best match in the mapping
current_name = menu.name
for key, icon_path in icon_mapping.items():
if key.lower() in current_name.lower():
menu.web_icon = icon_path
break
# Hide 'Website' icon from the parent level Dine360Restaurants database
if request.env.cr.dbname and 'Dine360Restaurants' in request.env.cr.dbname:
if 'Website' in allowed_menus:
allowed_menus.remove('Website')
filtered_menus.append(menu)
filtered_menus = []
seen_names = set()
for menu in menus:
# Match strictly against allowed menus
if menu.name not in allowed_menus and menu.name != 'Table Reservation':
continue
# Hide "Apps" and "Settings" for non-admins
if menu.name in ['Apps', 'Settings'] and not is_admin:
continue
# De-duplicate by name
if menu.name in seen_names:
continue
seen_names.add(menu.name)
# Dynamic Icon Override (Dine360 Branding)
icon_mapping = {
'Apps': 'dine360_dashboard,static/src/img/icons/apps.svg',
'Settings': 'dine360_dashboard,static/src/img/icons/settings.svg',
'Table Reservation': 'dine360_dashboard,static/src/img/icons/table_reservation.svg',
'Table Reservations': 'dine360_dashboard,static/src/img/icons/table_reservation.svg',
'Uber Integration': 'dine360_dashboard,static/src/img/icons/uber_integration.svg',
'Online Orders': 'dine360_dashboard,static/src/img/icons/website.svg',
'Dine360 SaaS': 'dine360_dashboard,static/src/img/icons/saas.svg',
'SaaS': 'dine360_dashboard,static/src/img/icons/saas.svg',
}
# Find the best match in the mapping
current_name = menu.name
for key, icon_path in icon_mapping.items():
if key.lower() in current_name.lower():
menu.web_icon = icon_path
break
filtered_menus.append(menu)
# Low Stock Alerts (Ingredients)
# Low Stock Alerts (Ingredients)
low_stock_products = []
try:
ProductTemplate = request.env['product.template'].sudo()
if hasattr(ProductTemplate, 'get_low_stock_products'):
low_stock_products = ProductTemplate.get_low_stock_products(limit=5)
except Exception:
low_stock_products = []
try:
ProductTemplate = request.env['product.template'].sudo()
if hasattr(ProductTemplate, 'get_low_stock_products'):
low_stock_products = ProductTemplate.get_low_stock_products(limit=5)
except Exception:
low_stock_products = []
return request.render('dine360_dashboard.image_home_template', {
'menus': filtered_menus,
'user_id': request.env.user,
'low_stock_products': low_stock_products
})
values = {
'menus': filtered_menus,
'user_id': request.env.user,
'low_stock_products': low_stock_products
}
if 'website' in request.env:
values['website'] = request.env['website'].get_current_website()
return request.render('dine360_dashboard.image_home_template', values)
@http.route('/home', type='http', auth="public", website=True, sitemap=True)
def website_home(self, **kw):
# Explicit route for standard Website Homepage
return request.render('website.homepage')
class ImageHomeBase(http.Controller):
@http.route('/', type='http', auth='user', website=True)
def index(self, **kwargs):
# Render the dashboard for logged in users when website module is not installed
return render_dashboard(request)

View File

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

View File

@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
{
'name': 'Dine360 Dashboard Website Glue',
'version': '1.0.0',
'category': 'Website',
'summary': 'Glue module for Dine360 Dashboard Website features',
'depends': ['dine360_dashboard', 'website', 'website_sale'],
'data': [
'views/website_logo.xml',
'views/shop_template.xml',
],
'assets': {
'web.assets_frontend': [
'dine360_dashboard/static/src/css/website_style.css',
'dine360_dashboard/static/src/css/shop_style.css',
],
},
'auto_install': True,
'installable': True,
'license': 'LGPL-3',
}

View File

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

View File

@ -0,0 +1,45 @@
# -*- coding: utf-8 -*-
from odoo import http
from odoo.http import request
from odoo.addons.web.controllers.home import Home
from odoo.addons.website.controllers.main import Website
from odoo.addons.dine360_dashboard.controllers.main import render_dashboard
class CustomHomeWebsite(Home):
@http.route('/web/login', type='http', auth="public", website=True)
def web_login(self, *args, **kw):
response = super(CustomHomeWebsite, self).web_login(*args, **kw)
if request.params.get('login_success') and request.session.uid:
return request.redirect('/')
return response
class ImageHomeWebsite(Website):
@http.route('/', type='http', auth='public', website=True, sitemap=True)
def index(self, **kwargs):
# 1. If not logged in, show website homepage
if not request.session.uid:
return super(ImageHomeWebsite, self).index(**kwargs)
# 2. If logged in, check for editor / iframe detection
params = request.params
headers = request.httprequest.headers
referer = headers.get('Referer', '')
fetch_dest = headers.get('Sec-Fetch-Dest', '')
editor_params = ['enable_editor', 'edit', 'path', 'website_id', 'frontend_edit', 'model', 'id']
is_editor_request = any(p in params for p in editor_params)
is_from_backend = any(m in referer for m in ['/website/force', 'enable_editor'])
if fetch_dest == 'iframe' or is_editor_request or is_from_backend:
return super(ImageHomeWebsite, self).index(**kwargs)
if request.httprequest.path != '/':
return super(ImageHomeWebsite, self).index(**kwargs)
# Render the dashboard
return render_dashboard(request)
@http.route('/home', type='http', auth="public", website=True, sitemap=True)
def website_home(self, **kw):
# Explicit route for standard Website Homepage
return request.render('website.homepage')

View File

@ -0,0 +1,10 @@
<odoo>
<!-- Ensure Shop Filter Sidebar is Visible -->
<template id="products_categories_inherit" inherit_id="website_sale.products_categories" name="Show Categories Sidebar">
<!-- Force display of categories sidebar -->
<!-- <xpath expr="//div[@id='products_grid_before']" position="attributes">
<attribute name="class">col-lg-3 d-block</attribute>
<attribute name="style">display: block !important; visibility: visible !important;</attribute>
</xpath> -->
</template>
</odoo>

View File

@ -0,0 +1,14 @@
<odoo>
<data>
<!--
Override the Shared Brand Placeholder
Using a generic selector '//span' because strict field matching failed.
This template typically contains a single span or img for the logo.
-->
<template id="custom_website_logo_placeholder" inherit_id="website.placeholder_header_brand" name="Custom Website Logo Placeholder">
<xpath expr="/*" position="replace">
<img t-att-src="'/web/image/res.company/%s/logo' % res_company.id" t-att-alt="website.name" class="img-fluid" style="max-height: 60px; width: auto; object-fit: contain;"/>
</xpath>
</template>
</data>
</odoo>

View File

@ -11,7 +11,7 @@
- WhatsApp/SMS notification hooks
""",
'author': 'Dine360',
'depends': ['base', 'website', 'pos_restaurant'],
'depends': ['base', 'website', 'pos_restaurant', 'dine360_table'],
'data': [
'security/ir.model.access.csv',
'data/reservation_sequence.xml',

View File

@ -48,4 +48,22 @@
</p>
</field>
</record>
<!-- Inherit Standalone Table Form View from dine360_table -->
<record id="view_restaurant_table_form_inherit_dine360_table" model="ir.ui.view">
<field name="name">restaurant.table.form.inherit.dine360.table</field>
<field name="model">restaurant.table</field>
<field name="inherit_id" ref="dine360_table.view_restaurant_table_form"/>
<field name="arch" type="xml">
<xpath expr="//group[@name='basic_config']" position="after">
<group string="Reservation Settings" name="reservation_settings">
<field name="is_reservation_enabled"/>
<field name="reservation_slot_duration"/>
<field name="min_party_size"/>
<field name="max_party_size"/>
<field name="zone"/>
</group>
</xpath>
</field>
</record>
</odoo>

View File

@ -13,7 +13,7 @@
- Store Keeper
""",
'author': 'Dine360',
'depends': ['point_of_sale', 'pos_restaurant', 'purchase', 'stock', 'website_sale'],
'depends': ['point_of_sale', 'pos_restaurant', 'purchase', 'stock'],
'data': [
'security/restaurant_security.xml',
'security/ir.model.access.csv',

View File

@ -12,13 +12,19 @@ class Dine360SelfOrderController(http.Controller):
def self_order_menu(self, **kwargs):
"""Displays the self-order menu for a specific table/kiosk"""
table_id = kwargs.get('table_id')
table = False
pos_table = False
if table_id:
table = request.env['restaurant.table'].sudo().browse(int(table_id))
try:
pos_table_id = int(table_id)
pos_table = request.env['restaurant.table'].sudo().browse(pos_table_id)
if not pos_table.exists():
pos_table = False
except (ValueError, TypeError):
pos_table = False
values = {
'table': table,
'floor': table.floor_id if table else False,
'pos_table': pos_table,
'pos_floor': pos_table.floor_id if pos_table else False,
}
return request.render('dine360_self_order.self_order_menu_template', values)

View File

@ -1,259 +1,252 @@
/** @odoo-module */
/** @odoo-module **/
import publicWidget from "@web/legacy/js/public/public_widget";
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';
publicWidget.registry.Dine360SelfOrder = publicWidget.Widget.extend({
selector: '#self_order_app',
events: {
'input #product_search': '_onSearchInput',
'click #view_cart_btn': '_onViewCartClick',
'click #submit_order_btn': '_onSubmitOrderClick',
'click [data-category]': '_onCategoryClick',
'click .add-to-cart-btn': '_onAddToCartClick',
'click .increase-qty': '_onIncreaseQtyClick',
'click .decrease-qty': '_onDecreaseQtyClick',
'click .service-select': '_onServiceSelectClick',
},
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 = {
init: function () {
this._super.apply(this, arguments);
this.config = {};
this.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');
start: async function () {
await this._super.apply(this, arguments);
const dataEl = this.el.querySelector('#self_order_data');
if (dataEl) {
this.config.tableId = dataEl.dataset.tableId;
this.config.tableName = dataEl.dataset.tableName;
}
// --- 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');
this.state.products = products;
this._renderCategories();
this._renderProducts();
this.$('#loading_overlay').addClass('d-none');
this.$('#self_order_content').removeClass('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('');
_renderCategories: function () {
const categories = ['all', ...new Set(this.state.products.map(p => p.pos_categ_name))];
const html = categories.map(cat => `
<button class="btn btn-sm ${this.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('');
this.$('#category_filter').html(html);
},
categoryFilter.querySelectorAll('button').forEach(btn => {
btn.onclick = () => {
state.activeCategory = btn.dataset.category;
renderCategories();
renderProducts();
};
});
_renderProducts: function () {
let filtered = this.state.products;
if (this.state.activeCategory !== 'all') {
filtered = filtered.filter(p => p.pos_categ_name === this.state.activeCategory);
}
if (this.state.searchTerm) {
const term = this.state.searchTerm.toLowerCase();
filtered = filtered.filter(p => p.display_name.toLowerCase().includes(term));
}
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
const html = 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>
`;
} 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;
}
</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>
${p.is_kitchen_item ? '<i class="fa fa-fire text-danger small opacity-50"/>' : ''}
</div>
</div>
</div>
</div>
`).join('');
this.$('#product_list').html(html);
},
_onCategoryClick: function (ev) {
this.state.activeCategory = $(ev.currentTarget).data('category');
this._renderCategories();
this._renderProducts();
},
_onAddToCartClick: function (ev) {
ev.stopPropagation();
const card = $(ev.currentTarget).closest('.product-card');
const productId = parseInt(card.data('id'));
const product = this.state.products.find(p => p.id === productId);
const existing = this.state.cart.find(item => item.product_id === productId);
if (existing) {
existing.qty++;
} else {
this.state.cart.push({
product_id: product.id,
display_name: product.display_name,
price_unit: product.list_price,
qty: 1
});
}
this._updateCartUI();
},
_updateCartUI: function () {
const count = this.state.cart.reduce((acc, item) => acc + item.qty, 0);
const total = this.state.cart.reduce((acc, item) => acc + (item.qty * item.price_unit), 0);
this.$('#cart_count').text(count);
this.$('#cart_total').text(`$${total.toFixed(2)}`);
const $footerCart = this.$('#footer_cart');
if (count > 0) {
$footerCart.removeClass('d-none');
setTimeout(() => $footerCart.css('transform', 'translateY(0)'), 10);
} else {
$footerCart.css('transform', 'translateY(100%)');
setTimeout(() => $footerCart.addClass('d-none'), 300);
}
},
_onSearchInput: function (ev) {
this.state.searchTerm = ev.target.value;
this._renderProducts();
},
_onViewCartClick: function () {
this._renderCartModal();
if (!this.cartModal) {
this.cartModal = new bootstrap.Modal(this.el.querySelector('#cart_modal'));
}
this.cartModal.show();
},
_renderCartModal: function () {
const total = this.state.cart.reduce((acc, item) => acc + (item.qty * item.price_unit), 0);
const html = this.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('');
this.$('#cart_items_list').html(html);
this.$('#checkout_subtotal').text(`$${total.toFixed(2)}`);
this.$('#checkout_total').text(`$${total.toFixed(2)}`);
},
_onIncreaseQtyClick: function (ev) {
const index = $(ev.currentTarget).data('index');
this.state.cart[index].qty++;
this._renderCartModal();
this._updateCartUI();
},
_onDecreaseQtyClick: function (ev) {
const index = $(ev.currentTarget).data('index');
this.state.cart[index].qty--;
if (this.state.cart[index].qty <= 0) {
this.state.cart.splice(index, 1);
}
if (this.state.cart.length === 0 && this.cartModal) {
this.cartModal.hide();
}
this._renderCartModal();
this._updateCartUI();
},
_onServiceSelectClick: function (ev) {
const $label = $(ev.currentTarget);
this.$('.service-select').removeClass('active border-warning bg-warning-light');
$label.addClass('active border-warning bg-warning-light');
},
_onSubmitOrderClick: async function () {
const $btn = this.$('#submit_order_btn');
const originalText = $btn.html();
$btn.prop('disabled', true);
$btn.html('<span class="spinner-border spinner-border-sm me-2"/>Sending...');
const fulfilmentType = this.$('input[name="fulfilment"]:checked').val() || 'dine_in';
const orderData = {
table_id: this.config.tableId,
fulfilment_type: fulfilmentType,
lines: this.state.cart
};
// 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');
};
});
});
})();
try {
const response = await jsonrpc('/dine360/self_order/submit_order', { order_data: orderData });
if (response.success) {
if (this.cartModal) this.cartModal.hide();
this.state.cart = [];
this._updateCartUI();
// Success View
this.$('#self_order_content').html(`
<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.prop('disabled', false);
$btn.html(originalText);
}
}
});
return publicWidget.registry.Dine360SelfOrder;

View File

@ -5,9 +5,9 @@
<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 ''"/>
t-att-data-table-id="pos_table.id if pos_table else ''"
t-att-data-floor-id="pos_floor.id if pos_floor else ''"
t-att-data-table-name="pos_table.display_name if pos_table else ''"/>
<!-- Header -->
<nav class="navbar navbar-expand-lg navbar-dark bg-dark sticky-top shadow-sm py-2">
@ -15,9 +15,9 @@
<a class="navbar-brand fw-bold d-flex align-items-center" href="/dine360/menu">
<i class="fa fa-cutlery me-2 text-danger"/> Dine360
</a>
<div t-if="table" class="text-white-50 small">
<div t-if="pos_table" class="text-white-50 small">
<span class="badge bg-danger text-white px-2 py-1 rounded-pill">
Table <t t-esc="table.name"/>
Table <t t-esc="pos_table.name"/>
</span>
</div>
<div t-else="" class="text-white-50 small">
@ -85,7 +85,7 @@
<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">
<div t-if="not pos_table" class="p-4 bg-light border-top">
<h6 class="fw-bold mb-3">Service Type</h6>
<div class="d-flex gap-2">
<label t-attf-class="flex-fill border p-3 rounded-3 text-center cursor-pointer service-select active {{'w-100' if not website.enable_delivery_option else ''}}" data-mode="pickup">

View File

@ -0,0 +1 @@
# -*- coding: utf-8 -*-

View File

@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
{
'name': 'Dine360 Table Management',
'version': '1.0',
'category': 'Sales/Restaurant',
'summary': 'Dedicated Floor and Table Management System',
'description': """
Provides backend menus and views to create, configure and manage floors and tables,
independent of POS configuration, and seamlessly integrated with Dine360 modules.
""",
'author': 'Dine360',
'depends': ['pos_restaurant'],
'data': [
'views/restaurant_floor_views.xml',
'views/restaurant_table_views.xml',
'views/menu_items.xml',
],
'installable': True,
'application': True,
'icon': '/dine360_table/static/description/icon.png',
'license': 'LGPL-3',
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Root Menu -->
<menuitem id="menu_dine360_table_root"
name="Table Management"
sequence="21"
web_icon="dine360_table,static/description/icon.png"/>
<!-- Submenus -->
<menuitem id="menu_dine360_floor_submenu"
name="Floors"
parent="menu_dine360_table_root"
action="action_restaurant_floor_form"
sequence="10"/>
<menuitem id="menu_dine360_table_submenu"
name="Tables"
parent="menu_dine360_table_root"
action="action_restaurant_table_form"
sequence="20"/>
</odoo>

View File

@ -0,0 +1,66 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Tree View -->
<record id="view_restaurant_floor_tree" model="ir.ui.view">
<field name="name">restaurant.floor.tree.dine360</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>
<!-- Form View -->
<record id="view_restaurant_floor_form" model="ir.ui.view">
<field name="name">restaurant.floor.form.dine360</field>
<field name="model">restaurant.floor</field>
<field name="arch" type="xml">
<form string="Restaurant Floor">
<sheet>
<div class="oe_title">
<label for="name" class="oe_edit_only"/>
<h1>
<field name="name" placeholder="e.g. Ground Floor, Garden, VIP..."/>
</h1>
</div>
<group>
<group string="Configurations">
<field name="pos_config_ids" widget="many2many_tags" placeholder="Link to Point of Sale Configs..."/>
<field name="background_color" widget="color" placeholder="e.g. rgb(220, 220, 220)"/>
</group>
</group>
<notebook>
<page string="Tables on Floor" name="tables">
<field name="table_ids" context="{'default_floor_id': active_id}">
<tree string="Tables" editable="bottom">
<field name="name"/>
<field name="seats"/>
<field name="shape"/>
</tree>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- Floor Window Action -->
<record id="action_restaurant_floor_form" model="ir.actions.act_window">
<field name="name">Floors</field>
<field name="res_model">restaurant.floor</field>
<field name="view_mode">tree,form</field>
<field name="view_id" ref="view_restaurant_floor_tree"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create a new floor plan!
</p>
<p>
Floors represent different physical sections of your restaurant (e.g., Main Hall, Terrace, Bar).
</p>
</field>
</record>
</odoo>

View File

@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Tree View -->
<record id="view_restaurant_table_tree" model="ir.ui.view">
<field name="name">restaurant.table.tree.dine360</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="shape"/>
</tree>
</field>
</record>
<!-- Form View -->
<record id="view_restaurant_table_form" model="ir.ui.view">
<field name="name">restaurant.table.form.dine360</field>
<field name="model">restaurant.table</field>
<field name="arch" type="xml">
<form string="Restaurant Table">
<sheet>
<div class="oe_title">
<label for="name" class="oe_edit_only"/>
<h1>
<field name="name" placeholder="e.g. Table 1, T2..."/>
</h1>
</div>
<group>
<group string="Basic Configuration" name="basic_config">
<field name="floor_id" options="{'no_create': True}"/>
<field name="seats"/>
<field name="shape"/>
</group>
<group string="POS Position &amp; Size" name="pos_layout">
<field name="position_h"/>
<field name="position_v"/>
<field name="width"/>
<field name="height"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<!-- Table Window Action -->
<record id="action_restaurant_table_form" model="ir.actions.act_window">
<field name="name">Tables</field>
<field name="res_model">restaurant.table</field>
<field name="view_mode">tree,form</field>
<field name="view_id" ref="view_restaurant_table_tree"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create a new table!
</p>
<p>
Tables are placed on floor plans. You can configure their shape, seat capacity, and custom positioning for the Point of Sale system.
</p>
</field>
</record>
</odoo>

View File

@ -16,7 +16,7 @@ services:
depends_on:
- db
ports:
- "10002:8069"
- "10006:8069"
environment:
HOST: db
USER: odoo