first commit

This commit is contained in:
Alaguraj0361 2026-06-19 10:39:27 +05:30
commit fc319c1f61
415 changed files with 21532 additions and 0 deletions

View 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.

96
.gitignore vendored Normal file
View File

@ -0,0 +1,96 @@
# Python artifacts
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
.venv/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
.pytest_cache/
.tox/
.nox/
.coverage
htmlcov/
nosetests.xml
coverage.xml
*.cover
*.log
# Odoo artifacts
*.log
odoo.conf
/data/
/filestore/
/sessions/
/logs/
/dump/
# OS artifacts
.DS_Store
Thumbs.db
ehthumbs.db
Desktop.ini
*.tmp
*.bak
*.swp
*.swo
*~
# IDEs
.idea/
.vscode/
.history/
*.sublime-project
*.sublime-workspace
# Debug/Temp scripts found in project
inspect_*.py
dump_*.py
debug_*.py
read_arch.py
resolve_homepage.py
fix_homepage.py
force_inherit.py
txt.py
update_error.txt
update_log.txt
addons/*.png
blog_posts_*.json
# Local config/environment
.env
docker-compose.override.yml
# Log / debug dump files
*.txt
*.log
*_logs.txt
shop_output.html
upgrade_log.txt
# Database dumps
*.sql
*.sqlite
*.dump
# One-off scripts (keep .ps1 compose scripts but ignore others)
cleanup_*.py
fix_*.py
test_*.py
temp_*.py

113
README.md Normal file
View File

@ -0,0 +1,113 @@
# Dine360 Odoo Addons_New by mohan1
This repository contains custom Odoo 17 addons for the Dine360 Restaurant Suite. It includes a website theme, a custom login/dashboard experience, and a restaurant role-based access module, all bundled by a meta module for one-click install.
## Stack
- Odoo 17 (Docker)
- Postgres 15 (Docker)
- Addons mounted from `./addons`
## Services (Docker)
- Odoo: `http://localhost:10001`
- DB: Postgres 15 (`odoo` / `odoo`)
## Repository Layout
- `addons/` Odoo addons
- `docker-compose.yml` Odoo + Postgres stack
- `backup_db.ps1`, `export_odoo.ps1` Windows helpers
- `inspect_views.py`, `resolve_homepage.py`, etc. view debugging helpers
## Addons
### 1) `dine360_dashboard`
Custom login layout and app-grid dashboard landing page.
Key features:
- Redirect `/web/login` to `/` after successful login
- Override `/` for authenticated users to show a custom app dashboard
- Custom login page layout and styling
Key files:
- `addons/dine360_dashboard/controllers/main.py`
- `addons/dine360_dashboard/views/home_template.xml`
- `addons/dine360_dashboard/views/login_templates.xml`
- `addons/dine360_dashboard/views/web_title_template.xml`
- `addons/dine360_dashboard/views/website_logo.xml`
- `addons/dine360_dashboard/static/src/css/*`
### 2) `dine360_restaurant`
Role-based access control for restaurant staff.
Roles:
- Admin/Owner, Manager, Cashier, Waiter/Captain, Kitchen (KDS), Store Keeper
Key files:
- `addons/dine360_restaurant/models/res_users.py`
- `addons/dine360_restaurant/security/*`
- `addons/dine360_restaurant/views/*`
### 3) `dine360_theme_shivasakthi`
Custom website theme and page content (homepage + contact us).
Key files:
- `addons/dine360_theme_shivasakthi/views/layout.xml`
- `addons/dine360_theme_shivasakthi/views/pages.xml`
- `addons/dine360_theme_shivasakthi/static/src/scss/*`
- `addons/dine360_theme_shivasakthi/static/src/img/*`
### 4) `Dine360_Shivasakthi` (meta module)
Install this single module to pull in all required addons.
Depends on:
- `dine360_dashboard`
- `dine360_restaurant`
- `dine360_theme_shivasakthi`
## Standard Install / Upgrade
### Start the stack
```bash
docker-compose up -d
```
### Update Apps list
- Apps -> Update Apps List
### Install the suite (recommended)
- Apps -> search `Dine360 Restaurant Suite` -> Install
### Upgrade the suite (after code changes)
```bash
docker exec odoo_client1 odoo -u Dine360_Shivasakthi -d shivasakthi_db --db_host db --db_user odoo --db_password odoo --stop-after-init
```
### Upgrade a single addon
```bash
docker exec odoo_client1 odoo -u dine360_dashboard -d shivasakthi_db --db_host db --db_user odoo --db_password odoo --stop-after-init
```
## Logos (Apps icons)
Place PNGs here (128x128 or 256x256 recommended):
- `addons/Dine360_Shivasakthi/static/description/icon.png`
- `addons/dine360_dashboard/static/description/icon.png`
- `addons/dine360_restaurant/static/description/icon.png`
- `addons/dine360_theme_shivasakthi/static/description/icon.png`
## Troubleshooting
### 500 error after view edits
- Upgrade the affected module
- Restart Odoo: `docker-compose restart odoo`
- Hard refresh browser (Ctrl + F5)
### Old modules still present (home_dashboard, restaurant_management, theme_shivasakthi)
If you renamed modules, uninstall the old ones in Apps to avoid conflicts.
## Utilities
Helper scripts for view diagnostics and homepage issues:
- `inspect_views.py`, `inspect_views_v2.py`
- `resolve_homepage.py`, `fix_homepage.py`, `force_inherit.py`
## Notes
- Homepage content is fully overridden in `addons/dine360_theme_shivasakthi/views/pages.xml`.
- If theme changes dont appear, check for COW (customized) views masking the theme.

View File

@ -0,0 +1,2 @@
# Meta module for Dine360 Shivasakthi
from .hooks import uninstall_hook

View File

@ -0,0 +1,44 @@
{
'name': 'Dine360 Shivasakthi Restaurant Suite',
'version': '1.0.0',
'license': 'LGPL-3',
'category': 'Website',
'summary': 'Installs all Dine360 Shivasakthi Restaurant modules',
'author': 'Dine360',
'depends': [
'dine360_restaurant',
'dine360_order_channels',
'dine360_dashboard',
'dine360_theme_shivasakthi',
'dine360_kds',
'dine360_reservation',
'dine360_uber',
'dine360_recipe',
'dine360_self_order',
'dine360_online_orders',
'dine360_pos_navbar',
'mail',
'calendar',
'contacts',
'crm',
'sale_management',
'board',
'point_of_sale',
'account',
'website',
'purchase',
'stock',
'hr',
],
'uninstall_hook': 'uninstall_hook',
'data': [
'views/apps_kanban_menu.xml',
],
'assets': {
'web.assets_backend': [
'Dine360_Shivasakthi/static/src/css/apps_kanban_fix.css',
],
},
'installable': True,
'application': True,
}

View File

@ -0,0 +1,24 @@
from odoo import api, SUPERUSER_ID
def uninstall_hook(env):
"""
Synchronized uninstallation: When Dine360 Shivasakthi Restaurant Suite is uninstalled,
automatically trigger uninstallation for all its core sub-modules.
"""
modules_to_uninstall = [
'dine360_dashboard',
'dine360_restaurant',
'dine360_theme_shivasakthi',
'dine360_kds',
'dine360_reservation'
]
# Search for these modules if they are installed
modules = env['ir.module.module'].search([
('name', 'in', modules_to_uninstall),
('state', '=', 'installed')
])
if modules:
# Mark modules for uninstallation
modules.button_uninstall()

View File

@ -0,0 +1,31 @@
/* Fix Apps kanban icon sizing and dropdown overlap */
.o_modules_kanban .oe_module_vignette,
.o_modules_kanban .o_kanban_record {
overflow: visible;
}
.o_modules_kanban .o_kanban_record {
position: relative;
z-index: 1;
}
.o_modules_kanban .oe_module_icon {
width: 64px;
height: 64px;
display: flex;
align-items: center;
justify-content: center;
}
.o_modules_kanban .oe_module_icon img {
width: 64px;
height: 64px;
object-fit: contain;
}
.o_modules_kanban .oe_module_vignette .dropdown-menu {
right: 0;
left: auto;
min-width: 160px;
z-index: 1060;
}

View File

@ -0,0 +1,15 @@
<odoo>
<data>
<template id="dine360_apps_kanban_menu_shivasakthi" inherit_id="base.module_view_kanban" name="Dine360 Apps Kanban Menu Shivasakthi">
<xpath expr="//t[@t-name='kanban-menu']" position="replace">
<t t-name="kanban-menu">
<t t-set="installed" t-value="record.state.raw_value == 'installed'"/>
<a type="edit" class="dropdown-item">Module Info</a>
<a t-if="record.website.raw_value" role="menuitem" class="dropdown-item o-hidden-ios" t-att-href="record.website.raw_value" target="_blank">Learn More</a>
<a t-if="installed" name="button_immediate_upgrade" type="object" role="menuitem" class="dropdown-item" groups="base.group_system">Upgrade</a>
<a t-if="installed" name="button_uninstall_wizard" type="object" role="menuitem" class="dropdown-item" groups="base.group_system">Uninstall</a>
</t>
</xpath>
</template>
</data>
</odoo>

View File

@ -0,0 +1,4 @@
# dine360_dashboard/__init__.py
from . import controllers
from . import models

View File

@ -0,0 +1,38 @@
{
'name': 'Dine360 Dashboard',
'version': '1.0.2',
'license': 'LGPL-3',
'category': 'Website',
'summary': 'Redirect login to home and show icon grid',
'depends': ['base', 'web', 'auth_signup', 'website', 'website_sale'],
'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': {
'web.assets_backend': [
'dine360_dashboard/static/src/css/theme_variables.css',
'dine360_dashboard/static/src/css/home_menu.css',
'dine360_dashboard/static/src/js/chennora_title.js',
'dine360_dashboard/static/src/xml/navbar_extension.xml',
],
'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',
],
'point_of_sale._assets_pos': [
'dine360_dashboard/static/src/css/pos_style.css',
],
},
'installable': True,
'application': True,
}

View File

@ -0,0 +1,4 @@
# dine360_dashboard/controllers/__init__.py
from . import main
from . import cors

View File

@ -0,0 +1,15 @@
from odoo import http
from odoo.http import request
import json
class CorsHandler(http.Controller):
# This specifically targets the authentication endpoint for CORS
@http.route('/web/session/authenticate', type='json', auth="none", cors="*")
def authenticate_cors(self, db, login, password, base_location=None):
request.session.authenticate(db, login, password)
return request.env['ir.http'].session_info()
# Generic search_read for the dashboard apps
@http.route('/web/dataset/call_kw', type='json', auth="user", cors="*")
def call_kw_cors(self, model, method, args, kwargs, path=None):
return request.env[model].with_user(request.uid).call_kw(method, args, kwargs)

View File

@ -0,0 +1,140 @@
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)
def web_login(self, *args, **kw):
response = super(CustomHome, self).web_login(*args, **kw)
if request.params.get('login_success') and request.session.uid:
# Use relative redirect to maintain HTTPS/HTTP protocol
return request.redirect('/')
return response
from odoo.addons.website.controllers.main import Website
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', '')
# 1. If not logged in, always show standard homepage
if not request.session.uid:
return super(ImageHome, self).index(**kwargs)
# 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')
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')
# 2. CHEF -> Directly to KDS
if user.has_group('dine360_restaurant.group_restaurant_kitchen'):
return request.redirect('/web#action=dine360_kds.action_kds_dashboard')
# 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', '')
# 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'])
# 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)
if path != '/':
return super(ImageHome, self).index(**kwargs)
# Remove sudo() to respect Odoo's standard menu group restrictions
menus = request.env['ir.ui.menu'].search([
('parent_id', '=', False)
], order='sequence')
# 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
# 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']
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',
}
# 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_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
})
@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,17 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="base_title_param" model="ir.config_parameter">
<field name="key">web.base_title</field>
<field name="value">Shivasakthi</field>
</record>
</data>
<data>
<record id="base.main_company" model="res.company">
<field name="phone">+1(647)856-2878</field>
</record>
<!-- Note: 'phone' field is not available on the website model in Odoo 17.
Phone/contact info should be set on res.company (line above) instead. -->
</data>
</odoo>

View File

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

View File

@ -0,0 +1,34 @@
from odoo import models, api
class IrUiMenu(models.Model):
_inherit = 'ir.ui.menu'
@api.model
def load_menus(self, debug):
"""
Override standard menu loading to hide unwanted root menus.
"""
menus = super(IrUiMenu, self).load_menus(debug)
user = self.env.user
is_admin = user.has_group('base.group_system')
allowed_menus = ['Online Orders', 'Website', 'Table Reservations', 'Uber Integration', 'Apps', 'Settings']
if 'root' in menus and 'children' in menus['root']:
new_children = []
for child_id in menus['root']['children']:
child_menu = menus.get(child_id)
if not child_menu:
continue
name = child_menu.get('name')
# Allow matching menus (with fallback for Table Reservation naming variations)
if name in allowed_menus or name == 'Table Reservation':
# Hide Apps and Settings for non-admins
if name in ['Apps', 'Settings'] and not is_admin:
continue
new_children.append(child_id)
menus['root']['children'] = new_children
return menus

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

@ -0,0 +1,176 @@
/* Main background */
body.o_home_dashboard,
#wrapwrap.o_home_dashboard,
.o_home_menu_background {
background: linear-gradient(rgb(0 0 0 / 83%), rgb(0 0 0 / 83%)),
url('/dine360_dashboard/static/src/img/dashboard_bg.png') no-repeat center center !important;
background-size: cover !important;
background-attachment: fixed !important;
min-height: 100vh !important;
padding: 0 !important;
margin: 0 !important;
font-family: 'Inter', 'Segoe UI', Roboto, sans-serif !important;
position: relative !important;
}
#wrapwrap.o_home_dashboard #wrap {
background: transparent !important;
}
.o_apps {
display: flex !important;
flex-wrap: wrap !important;
justify-content: center !important;
gap: 40px 50px !important;
max-width: 1000px !important;
margin: 0 auto !important;
padding: 20px !important;
}
.o_app {
display: flex !important;
flex-direction: column !important;
align-items: center !important;
text-decoration: none !important;
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) !important;
width: 120px !important;
}
.o_app_icon_container {
background: #ffffff !important;
width: 90px !important;
height: 90px !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
border-radius: 24px !important;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.05),
0 2px 4px -1px rgba(0, 0, 0, 0.03) !important;
margin-bottom: 12px !important;
position: relative !important;
overflow: hidden;
border: 1px solid rgba(0, 0, 0, 0.03) !important;
transition: all 0.4s ease !important;
}
.o_app:hover .o_app_icon_container {
transform: translateY(-10px) rotate(2deg) !important;
background: #ffffff !important;
border-bottom: 2px solid #d6111e !important;
box-shadow:
0 20px 25px -5px rgba(214, 17, 30, 0.2),
0 10px 10px -5px rgba(23, 20, 34, 0.1) !important;
}
.o_app_icon_container::before {
content: "";
--pos-accent-red: #d6111e;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(135deg, rgb(175 36 36 / 62%) 0%, rgb(181 84 84 / 20%) 50%);
z-index: 1;
}
.o_app_icon {
width: 56px !important;
height: 56px !important;
object-fit: contain !important;
z-index: 2;
transition: transform 0.3s ease !important;
}
.o_app:hover .o_app_icon {
transform: scale(1.1) !important;
}
.o_app_name {
color: #ffffff !important;
font-size: 14px !important;
font-weight: 500 !important;
text-align: center !important;
letter-spacing: -0.2px !important;
transition: color 0.3s ease !important;
}
.o_app:hover .o_app_name {
color: #d6111e !important;
font-weight: 700 !important;
}
.o_home_top_bar {
position: fixed !important;
top: 25px !important;
left: 0;
right: 0;
display: flex !important;
justify-content: center !important;
z-index: 1000 !important;
}
.o_top_bar_island {
display: flex !important;
align-items: center !important;
gap: 12px !important;
padding: 10px 25px !important;
background: rgba(0, 0, 0, 0.9) !important;
backdrop-filter: blur(15px) !important;
-webkit-backdrop-filter: blur(15px);
border-radius: 50px !important;
border-color: #d6111e !important;
box-shadow: 0 0 0 3px rgba(214, 17, 30, 0.15) !important;
box-shadow: 0 12px 25px rgba(214, 17, 30, 0.3) !important;
}
.o_top_item {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff !important;
transition: all 0.3s ease;
cursor: pointer;
position: relative;
text-decoration: none !important;
}
.o_top_item:hover {
color: #d6111e !important;
transform: translateY(-3px);
}
.o_bar_divider {
width: 1px;
height: 24px;
background: rgba(255, 255, 255, 0.2);
margin: 0 10px;
}
.o_ai_icon {
font-weight: 800;
color: #d6111e !important;
}
.badge_dot {
position: absolute !important;
top: 8px;
right: 8px;
width: 8px;
height: 8px;
background: #d6111e !important;
border-radius: 50% !important;
border: 2px solid white !important;
}
.o_user_avatar {
width: 35px !important;
height: 35px !important;
border-radius: 12px !important;
background: #d6111e !important;
color: white !important;
font-weight: bold !important;
}

View File

@ -0,0 +1,279 @@
/* Container: Full Screen Split */
.o_login_main_wrapper {
display: flex !important;
height: 100vh !important;
width: 100vw !important;
overflow: hidden;
background: linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0.6)), url('/dine360_theme_shivasakthi/static/src/img/chen-banner-2.webp') !important;
background-repeat: no-repeat !important;
background-position: center center !important;
background-size: cover !important;
}
/* Left Side: Background Image and Branding */
.o_login_left_side {
flex: 1.5 !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
padding: 60px !important;
position: relative;
/* Dark overlay on image for text readability */
/* background: linear-gradient(rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5)),
url('/dine360_dashboard/static/src/img/login_bg.png') no-repeat center center !important;
background-size: cover !important; */
color: white !important;
}
.o_login_content {
text-align: center;
max-width: 80%;
}
.o_login_content h1 {
font-size: 3.8rem !important;
font-weight: 800 !important;
margin-bottom: 20px;
text-shadow: 2px 4px 15px rgba(0, 0, 0, 0.6) !important;
}
.o_login_content p {
font-size: 1.4rem !important;
opacity: 0.9;
}
/* Right Side: Form Section */
.o_login_right_side {
flex: 1 !important;
/* background: #ffffff !important; */
display: flex !important;
justify-content: center !important;
align-items: center !important;
padding: 40px !important;
position: relative;
}
.o_login_card_wrapper {
width: 60%
}
/* Glassmorphism Card UI */
.o_login_card {
width: 100% !important;
max-width: 450px !important;
padding: 40px !important;
background: rgba(255, 255, 255, 0.05) !important;
backdrop-filter: blur(15px) !important;
-webkit-backdrop-filter: blur(15px) !important;
border-radius: 20px !important;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5) !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
z-index: 2;
}
/* Header Text Gradient */
.o_login_header_text h3 {
font-size: 2rem !important;
font-weight: 800 !important;
margin-bottom: 10px;
color: #d6111e !important;
}
/* Logo */
.o_login_logo_container {
text-align: center;
margin-bottom: 30px;
}
.o_login_logo_container img {
max-height: 120px;
/* Larger logo */
width: auto;
filter: drop-shadow(0 0 10px rgba(255, 255, 255, 0.2));
}
/* Form Inputs */
.form-control:focus {
border-color: #FECD4F !important;
box-shadow: 0 0 0 3px rgba(254, 205, 79, 0.2) !important;
background: #ffffff !important;
}
.o_login_form_container label {
color: #ffffff !important;
margin-bottom: 8px;
font-weight: 500;
}
.o_login_form_container a,
.o_login_form_container .btn-link {
color: #ffffff !important;
}
.form-control {
background: rgba(255, 255, 255, 0.1) !important;
border: 1px solid rgba(255, 255, 255, 0.3) !important;
border-radius: 12px !important;
padding: 12px 15px !important;
height: auto !important;
color: white !important;
}
.form-control::placeholder {
color: rgba(255, 255, 255, 0.6) !important;
}
/* Accessibility contrast for error messages */
.alert-danger {
background-color: rgba(220, 53, 69, 0.9) !important;
color: white !important;
border: none !important;
}
/* Login Button with Gradient */
.oe_login_buttons .btn-primary {
background: #FECD4F !important;
border: none !important;
border-radius: 12px !important;
padding: 14px !important;
font-weight: 700 !important;
font-size: 1rem !important;
color: #04121D !important;
width: 100% !important;
transition: all 0.3s ease !important;
box-shadow: 0 4px 15px rgba(254, 205, 79, 0.3) !important;
}
.oe_login_buttons .btn-primary:hover {
background: #04121D !important;
color: #FECD4F !important;
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(254, 205, 79, 0.4) !important;
}
/* Responsive Handling */
@media (max-width: 992px) {
.o_login_main_wrapper {
flex-direction: column;
}
.o_login_left_side {
display: none !important;
}
.o_login_right_side {
background: #0f172a !important;
}
.o_login_card_wrapper {
width: 90% !important;
}
.oe_website_login_container .oe_login_form,
.oe_website_login_container .oe_signup_form,
.oe_website_login_container .oe_reset_password_form {
width: 90% !important;
}
}
/* Hide Website Header/Footer ONLY on Login, Signup, Reset Password */
body.o_custom_login_body header,
body.o_custom_login_body footer,
body.o_custom_login_body .o_footer_copyright,
body.o_custom_login_body #o_main_nav,
body.o_custom_login_body .o_header_standard,
body.o_custom_login_body #wrapwrap>header,
body.o_custom_login_body #wrapwrap>footer {
display: none !important;
height: 0 !important;
width: 0 !important;
visibility: hidden !important;
opacity: 0 !important;
position: absolute !important;
pointer-events: none !important;
}
/* Ensure wrapwrap doesn't have padding/margin from header on these pages */
body.o_custom_login_body #wrapwrap {
padding-top: 0 !important;
margin-top: 0 !important;
}
/* Ensure the wrapper covers the entire screen, ignoring website container constraints */
body.o_custom_login_body .o_login_main_wrapper {
position: fixed !important;
top: 0;
left: 0;
width: 100vw !important;
height: 100vh !important;
z-index: 9999;
/* Ensure it stays on top */
margin: 0 !important;
padding: 0 !important;
}
/* Custom Footer */
.o_login_footer_custom {
position: absolute;
bottom: 0;
right: 0;
width: 50%;
/* Only on the right side which is 50% usually, or just right side */
text-align: center;
padding: 15px;
background: transparent;
color: #6c757d;
z-index: 10;
font-size: 0.9rem;
}
@media (max-width: 992px) {
.o_login_footer_custom {
width: 100%;
color: white;
/* Visible on gradient background */
}
}
.o_login_footer_custom a {
color: #d6111e;
text-decoration: none;
font-weight: 600;
}
.o_login_footer_custom a:hover {
color: #171422;
}
/* website login screen */
.oe_website_login_container {
display: flex !important;
justify-content: center !important;
align-items: center !important;
height: 100vh !important;
/* width: 100vw !important; */
overflow: hidden !important;
background: linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0.6)), url('/dine360_theme_shivasakthi/static/src/img/chen-banner-2.webp') no-repeat center center !important;
background-size: cover !important;
}
.oe_website_login_container .oe_login_form,
.oe_website_login_container .oe_signup_form,
.oe_website_login_container .oe_reset_password_form {
width: 60% !important;
padding: 40px !important;
background: rgba(255, 255, 255, 0.05) !important;
backdrop-filter: blur(15px) !important;
-webkit-backdrop-filter: blur(15px) !important;
border-radius: 20px !important;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5) !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
z-index: 2;
max-width: 400px;
}
.oe_website_login_container label,
.oe_website_login_container a {
color: #ffffff !important;
}

View File

@ -0,0 +1,446 @@
/* ========================================
SHIVASAKTHI RUSH MODE - POS THEME (ULTRA PREMIUM)
======================================== */
:root {
--pos-primary: #171422;
/* Secondary Dark */
--pos-secondary: #d6111e;
/* Main Teal */
--pos-accent-orange: #e66421;
/* Biryani Orange */
--pos-accent-gold: #cc9900;
/* Tandoor Gold */
--pos-accent-red: #d6111e;
/* Main Teal */
--pos-bg-main: #f8fafc;
/* Ultra light gray */
--pos-card-dark: #1a1d23;
/* Dark Card Background */
--pos-text-light: #ffffff;
--pos-text-dark: #111827;
--border-radius-xl: 24px;
--border-radius-lg: 16px;
}
/* 1. Main POS Layout Swapping */
/* Standard Odoo: Leftpane = Cart, Rightpane = Products */
/* Goal: Food List on Left, Orders/Calculations on Right */
/* .pos .pos-content,
.pos .product-screen {
display: flex !important;
flex-direction: row-reverse !important; */
/* SWAP Panes */
/* height: calc(100vh - 85px) !important;
} */
.pos .leftpane {
/* The Cart Section - Now on Right */
width: 480px !important;
border-left: 2px solid #f1f5f9 !important;
border-right: none !important;
background: #ffffff !important;
display: flex !important;
flex-direction: column !important;
flex: none !important;
height: calc(100vh - 85px) !important;
overflow: hidden !important;
z-index: 100 !important;
}
.pos .order-container {
flex: 1 1 auto !important;
overflow-y: auto !important;
background: #ffffff !important;
min-height: 0 !important;
}
.product-screen {
display: flex !important;
flex-direction: row-reverse !important;
height: calc(100vh - 85px) !important;
overflow: hidden !important;
}
.pos .rightpane {
/* The Product Grid Section - Now on Left */
flex: 1 1 auto !important;
background: #f8fafc !important;
height: 100% !important;
display: flex !important;
flex-direction: column !important;
overflow: hidden !important;
}
.pos .product-list-container {
flex: 1 1 auto !important;
overflow-y: auto !important;
background: #f8fafc !important;
}
/* 2. Header & Branding */
.pos .pos-topheader {
background: #171422 !important;
border-bottom: 2px solid #d6111e !important;
color: #fff !important;
height: 85px !important;
display: flex !important;
align-items: center !important;
padding: 0 30px !important;
}
/* Logo from Odoo Settings */
.pos .pos-logo {
display: flex !important;
align-items: center !important;
gap: 15px !important;
margin-right: 50px !important;
width: auto !important;
}
.pos .pos-logo img,
.pos .pos-logo {
content: url('/web/binary/company_logo') !important;
height: 55px !important;
width: auto !important;
object-fit: contain !important;
}
/* "Rush Mode" Label removed for production */
/* 3. Search Bar - Teal Theme */
.pos .search-bar {
background: #e0f2f1 !important;
border-radius: var(--border-radius-lg) !important;
margin: 0 !important;
width: 380px !important;
height: 50px !important;
display: flex !important;
align-items: center !important;
padding: 0 20px !important;
}
.pos .search-bar input {
background: transparent !important;
border: none !important;
font-weight: 800 !important;
color: #00695c !important;
font-size: 16px !important;
text-transform: uppercase !important;
}
/* 4. Category Bar - Fix Truncation */
.pos .category-list {
display: flex !important;
gap: 12px !important;
padding: 15px 30px !important;
background: #ffffff !important;
border-bottom: 2px solid #f1f5f9 !important;
}
.pos .category-button {
border-radius: var(--border-radius-lg) !important;
font-weight: 900 !important;
text-transform: uppercase !important;
padding: 12px 30px !important;
font-size: 14px !important;
border: none !important;
min-width: 140px !important;
/* STOP MAI... truncation */
white-space: nowrap !important;
background: #f1f5f9 !important;
color: #475569 !important;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05) !important;
}
.pos .category-button.active {
background: #d6111e !important;
color: #ffffff !important;
}
/* Dynamic Colors for specific indexes */
.pos .category-button:nth-child(2) {
background: var(--pos-accent-orange) !important;
color: white !important;
}
.pos .category-button:nth-child(3) {
background: var(--pos-accent-gold) !important;
color: white !important;
}
/* 5. Product Cards - Dark Premium */
.pos .product-list-container {
background: #f8fafc !important;
}
.pos .product-list {
display: flex !important;
flex-wrap: wrap !important;
padding: 25px !important;
gap: 25px !important;
}
.pos .product {
background: #171422 !important;
border-radius: var(--border-radius-lg) !important;
width: 210px !important;
height: 230px !important;
flex: 0 0 auto !important;
flex-shrink: 0 !important;
border: none !important;
box-shadow: 0 2px 4px rgba(42, 106, 126, 0.05) !important;
transition: all 0.3s ease !important;
position: relative !important;
overflow: hidden !important;
}
.pos .product:hover {
transform: translateY(-8px) scale(1.02) !important;
}
.pos .product .product-img {
width: 100% !important;
height: 150px !important;
object-fit: cover !important;
}
.product-img img {
width: 100% !important;
height: 100% !important;
/* object-fit: cover !important; */
}
.pos .product .product-name {
color: #ffffff !important;
font-size: 16px !important;
font-weight: 700 !important;
padding: 12px 15px 5px 15px !important;
background: transparent !important;
}
.pos .product .price-tag {
background: transparent !important;
color: #ffffff !important;
font-size: 15px !important;
font-weight: 600 !important;
position: relative !important;
bottom: 5px !important;
left: 15px !important;
top: auto !important;
right: auto !important;
padding: 0 !important;
}
/* 6. Order (Cart) Styling - Now on Right */
.pos .order-container {
background: #ffffff !important;
padding: 10px 0 !important;
}
.pos .orderline {
border-radius: var(--border-radius-lg) !important;
border: 2px solid #f1f5f9 !important;
margin: 8px 20px !important;
padding: 15px !important;
background: white !important;
transition: all 0.2s ease !important;
}
.pos .orderline.selected {
border: 2px solid var(--pos-secondary) !important;
background: #f0fdf4 !important;
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.1) !important;
}
.pos .orderline .product-name {
font-weight: 900 !important;
font-size: 17px !important;
color: #111827 !important;
}
/* 7. Summary & Pay Button */
.pos .order-summary {
background: #ffffff !important;
padding: 25px 30px !important;
border-top: 2px dashed #e2e8f0 !important;
}
.pos .summary .total {
color: var(--pos-secondary) !important;
font-size: 40px !important;
font-weight: 900 !important;
letter-spacing: -1px;
}
.pos .action-pad .button.pay {
background: var(--pos-secondary) !important;
color: white !important;
border-radius: var(--border-radius-lg) !important;
margin: 20px !important;
height: 75px !important;
font-size: 24px !important;
font-weight: 900 !important;
text-transform: uppercase !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
box-shadow: 0 12px 25px rgba(214, 17, 30, 0.3) !important;
border: none !important;
}
.pos .action-pad .button.pay::after {
content: "\2794";
margin-left: 15px;
font-size: 30px;
}
/* 8. Numpad styling */
.pos .numpad {
background: #f8fafc !important;
padding: 20px !important;
}
.pos .numpad button {
border-radius: 12px !important;
border: 1px solid #e2e8f0 !important;
background: white !important;
height: 55px !important;
font-weight: 700 !important;
font-size: 14px !important;
}
/* 9. Receipt Screen & New Order Button - Ultra Aggressive Theme Match */
.pos .button.next,
.pos .button.validation,
.pos .receipt-screen .button.next,
.pos .receipt-screen .validation.button,
.pos .receipt-screen .button {
background: #d61112 !important;
color: white !important;
border-radius: var(--border-radius-lg) !important;
height: 70px !important;
font-size: 22px !important;
font-weight: 900 !important;
text-transform: uppercase !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
box-shadow: 0 10px 20px rgba(214, 17, 18, 0.2) !important;
border: none !important;
}
.pos .receipt-screen .button.next:active {
transform: scale(0.98);
}
/* 10. Premium Bill UI (Right Side) */
.pos-receipt {
font-family: 'Inter', sans-serif !important;
color: #1a1d23 !important;
padding: 30px !important;
background: #fff !important;
}
.pos-receipt .pos-receipt-contact {
font-size: 13px !important;
color: #64748b !important;
margin-bottom: 20px !important;
}
.pos-receipt .pos-receipt-center-align {
text-align: center !important;
font-weight: 700 !important;
}
.pos-receipt .pos-receipt-order-data {
color: #94a3b8 !important;
font-size: 12px !important;
margin-top: 10px !important;
}
.pos-receipt .receipt-orderlines {
border-top: 2px solid #f1f5f9 !important;
padding-top: 15px !important;
}
.pos-receipt .orderline {
border: none !important;
margin: 0 !important;
padding: 8px 0 !important;
border-bottom: 1px dashed #e2e8f0 !important;
display: flex !important;
flex-wrap: wrap !important;
justify-content: space-between !important;
align-items: baseline !important;
}
.pos-receipt .orderline .product-name {
flex: 1 1 65% !important;
font-weight: 700 !important;
white-space: normal !important;
word-break: break-word !important;
padding-right: 5px !important;
}
.pos-receipt .orderline .pos-receipt-right-align {
flex: 0 0 auto !important;
text-align: right !important;
font-weight: 800 !important;
}
.pos-receipt .pos-receipt-total {
font-size: 24px !important;
font-weight: 900 !important;
color: var(--pos-secondary) !important;
border-top: 2px solid #1a1d23 !important;
padding-top: 15px !important;
}
.pos-receipt-amount {
font-weight: 800 !important;
}
/* ========================================
RESPONSIVE MEDIA QUERIES
======================================== */
@media (max-width: 1024px) {
.pos .leftpane {
width: 380px !important;
}
.pos .product {
width: 160px !important;
height: 180px !important;
}
.pos .product .product-img {
height: 110px !important;
}
.pos .search-bar {
width: 280px !important;
}
}
@media (max-width: 768px) {
.product-screen {
flex-direction: column !important;
height: 100vh !important;
}
.pos .leftpane {
width: 100% !important;
height: 50vh !important;
border-left: none !important;
border-top: 2px solid #f1f5f9 !important;
}
.pos .rightpane {
height: 50vh !important;
}
.pos .pos-topheader {
height: auto !important;
flex-wrap: wrap !important;
padding: 10px !important;
}
.pos .search-bar {
width: 100% !important;
margin-top: 10px !important;
}
}

View File

@ -0,0 +1,312 @@
/* ========================================
SHOP PAGE - LEFT SIDEBAR FILTER STYLING
======================================== */
/* Ensure Shop Page Layout with Sidebar */
#wrapwrap.oe_website_sale {
background: #ffffff !important;
}
/* Main Shop Container - Two Column Layout */
.oe_website_sale #products_grid_before {
display: block !important;
visibility: visible !important;
opacity: 1 !important;
width: 100% !important;
}
/* Left Sidebar Filter Section */
.oe_website_sale #o_shop_collapse_category,
.oe_website_sale .o_wsale_products_searchbar_form,
.oe_website_sale #wsale_products_categories_collapse {
display: block !important;
visibility: visible !important;
opacity: 1 !important;
}
/* Shop Layout Container */
.oe_website_sale .container,
.oe_website_sale .container-fluid {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
/* Row with Sidebar and Products */
.oe_website_sale .row {
display: flex !important;
flex-wrap: wrap;
}
/* Left Sidebar Column (Categories/Filters) */
.oe_website_sale #products_grid_before,
.oe_website_sale .col-lg-3,
.oe_website_sale aside {
flex: 0 0 280px !important;
max-width: 280px !important;
display: block !important;
visibility: visible !important;
padding-right: 20px;
}
/* Right Products Column */
.oe_website_sale #products_grid,
.oe_website_sale .col-lg-9 {
flex: 1 !important;
max-width: calc(100% - 280px) !important;
}
/* Filter Sidebar Styling */
.oe_website_sale #products_grid_before {
background: rgba(255, 255, 255, 0.95) !important;
border-radius: 16px !important;
padding: 20px !important;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08) !important;
backdrop-filter: blur(10px);
border: 1px solid rgba(0, 0, 0, 0.05);
}
/* Category Filter Section */
.oe_website_sale .o_wsale_products_searchbar_form,
.oe_website_sale #wsale_products_categories_collapse {
margin-bottom: 25px;
}
/* Category Filter Title */
.oe_website_sale #products_grid_before h5,
.oe_website_sale #products_grid_before .h5,
.oe_website_sale #products_grid_before h6 {
color: var(--secondary-color, #2BB1A5) !important;
font-weight: 700 !important;
font-size: 1.1rem !important;
margin-bottom: 15px !important;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Category List Items */
.oe_website_sale #products_grid_before ul,
.oe_website_sale #products_grid_before .list-group {
list-style: none;
padding: 0;
margin: 0;
}
.oe_website_sale #products_grid_before li,
.oe_website_sale #products_grid_before .list-group-item {
background: transparent !important;
border: none !important;
padding: 10px 15px !important;
margin-bottom: 5px !important;
border-radius: 10px !important;
transition: all 0.2s ease !important;
}
.oe_website_sale #products_grid_before li a,
.oe_website_sale #products_grid_before .list-group-item a {
color: #4a5568 !important;
text-decoration: none !important;
font-weight: 500 !important;
display: block;
width: 100%;
}
.oe_website_sale #products_grid_before li:hover,
.oe_website_sale #products_grid_before .list-group-item:hover {
background: #fecd4f !important;
transform: translateX(5px);
}
.oe_website_sale #products_grid_before li:hover a,
.oe_website_sale #products_grid_before .list-group-item:hover a {
color: #ffffff !important;
}
/* Active Category */
.oe_website_sale #products_grid_before li.active,
.oe_website_sale #products_grid_before .list-group-item.active {
background: transparent !important;
box-shadow: none !important;
}
.oe_website_sale #products_grid_before li.active a,
.oe_website_sale #products_grid_before .list-group-item.active a {
color: #000000 !important;
font-weight: 700 !important;
}
/* Search Bar in Sidebar */
.oe_website_sale #products_grid_before .form-control,
.oe_website_sale #products_grid_before input[type="text"],
.oe_website_sale #products_grid_before input[type="search"] {
border-radius: 10px !important;
border: 1px solid #e2e8f0 !important;
padding: 10px 15px !important;
background: #f8f9fa !important;
transition: all 0.2s ease;
}
.oe_website_sale #products_grid_before .form-control:focus,
.oe_website_sale #products_grid_before input:focus {
border-color: var(--secondary-color, #2BB1A5) !important;
background: #ffffff !important;
box-shadow: 0 0 0 3px rgba(43, 177, 165, 0.15) !important;
outline: none;
}
/* Filter Buttons */
.oe_website_sale #products_grid_before .btn,
.oe_website_sale #products_grid_before button {
border-radius: 10px !important;
padding: 8px 20px !important;
font-weight: 600 !important;
transition: all 0.2s ease;
}
.oe_website_sale #products_grid_before .btn-primary {
background: #2BB1A5 !important;
border: none !important;
color: #ffffff !important;
box-shadow: 0 4px 12px rgba(43, 177, 165, 0.3) !important;
}
.oe_website_sale #products_grid_before .btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 8px 15px rgba(43, 177, 165, 0.4) !important;
}
/* Attribute Filters (Color, Size, etc.) */
.oe_website_sale #products_grid_before .css_attribute_color {
display: inline-block;
width: 30px;
height: 30px;
border-radius: 50%;
margin: 5px;
border: 2px solid #e2e8f0;
cursor: pointer;
transition: all 0.2s ease;
}
.oe_website_sale #products_grid_before .css_attribute_color:hover,
.oe_website_sale #products_grid_before .css_attribute_color.active {
border-color: var(--secondary-color, #2BB1A5);
transform: scale(1.1);
box-shadow: 0 2px 8px rgba(43, 177, 165, 0.3);
}
/* Price Range Filter */
.oe_website_sale #products_grid_before .o_wsale_price_filter {
margin-top: 20px;
}
.oe_website_sale #products_grid_before .o_wsale_price_filter input[type="range"] {
width: 100%;
accent-color: var(--secondary-color, #2BB1A5);
}
/* Collapse/Expand Buttons */
.oe_website_sale #products_grid_before .btn-link {
color: var(--secondary-color, #2BB1A5) !important;
text-decoration: none !important;
font-weight: 600 !important;
}
.oe_website_sale #products_grid_before .btn-link:hover {
color: var(--primary-color, #171422) !important;
}
/* Mobile Responsive */
@media (max-width: 991px) {
.oe_website_sale #products_grid_before,
.oe_website_sale .col-lg-3,
.oe_website_sale aside {
flex: 0 0 100% !important;
max-width: 100% !important;
margin-bottom: 20px;
padding-right: 0;
}
.oe_website_sale #products_grid,
.oe_website_sale .col-lg-9 {
flex: 0 0 100% !important;
max-width: 100% !important;
}
}
/* Ensure visibility of all filter elements */
.oe_website_sale .o_wsale_products_searchbar_form,
.oe_website_sale .o_wsale_products_categories,
.oe_website_sale #wsale_products_categories_collapse,
.oe_website_sale .o_wsale_products_attributes,
.oe_website_sale .o_wsale_products_price {
display: block !important;
visibility: visible !important;
opacity: 1 !important;
}
/* Category Navigation */
.oe_website_sale .o_wsale_products_categories ul.o_wsale_categories_list {
padding-left: 0 !important;
}
.oe_website_sale .o_wsale_products_categories ul.o_wsale_categories_list li {
list-style: none;
}
/* Attribute Filters Section */
.oe_website_sale .o_wsale_products_attributes {
margin-top: 20px;
}
.oe_website_sale .o_wsale_products_attributes .o_wsale_attribute {
margin-bottom: 20px;
}
.oe_website_sale .o_wsale_products_attributes .o_wsale_attribute label {
display: block;
margin-bottom: 10px;
color: #4a5568;
font-weight: 500;
}
/* Clear Filters Button */
.oe_website_sale .o_wsale_clear_filters {
margin-top: 20px;
width: 100%;
}
.oe_website_sale .o_wsale_clear_filters .btn {
width: 100%;
background: #f8f9fa !important;
border: 1px solid #e2e8f0 !important;
color: #4a5568 !important;
}
.oe_website_sale .o_wsale_clear_filters .btn:hover {
background: #ffffff !important;
border-color: var(--secondary-color, #2BB1A5) !important;
color: var(--secondary-color, #2BB1A5) !important;
}
.css_quantity input {
max-width: 20px !important;
}
/* Product Details Quantity Input Fix */
.css_quantity input.quantity {
color: #000000 !important;
background-color: #ffffff !important;
opacity: 1 !important;
width: 60px !important;
max-width: 60px !important;
height: 45px !important;
line-height: 45px !important;
font-weight: 600 !important;
font-size: 18px !important;
padding: 0 !important;
text-align: center !important;
border: 1px solid #000000 !important;
display: inline-block !important;
visibility: visible !important;
}

View File

@ -0,0 +1,599 @@
/* ========================================
ODOO THEME COLOR VARIABLES
Primary Color: #ec0000 (Dine360 Red)
Secondary Color: #000000 (Black)
======================================== */
:root {
/* Primary Brand Colors */
--primary-color: #d6111e;
--primary-dark: #8B0A0E;
--primary-light: #E57373;
--primary-lighter: #FFCDD2;
/* Secondary/Accent Color */
--secondary-color: #171422;
--secondary-dark: #000000;
--secondary-light: #2d293d;
/* Gradient Combinations Removed - Using Solid Colors */
--gradient-primary: #d6111e;
--gradient-primary-hover: #8B0A0E;
/* Home Page Background */
--bg-gradient-main: #ffffff;
/* UI Element Colors */
--btn-primary-bg: #d6111e;
--link-color: #171422;
--link-hover: #d6111e;
/* Status Colors */
--success-color: #171422;
--warning-color: #FECD4F;
--danger-color: #d6111e;
--info-color: #2d293d;
/* Shadows */
--shadow-primary: 0 4px 15px rgba(214, 17, 30, 0.2);
--shadow-primary-hover: 0 8px 20px rgba(214, 17, 30, 0.3);
--shadow-card: 0 10px 30px rgba(23, 20, 34, 0.08);
}
/* ::selection - Text Selection Color */
::selection {
background: var(--primary-color) !important;
color: #fff !important;
}
/* Scrollbar Styling (Webkit) */
::-webkit-scrollbar-thumb {
background: var(--secondary-color) !important;
border-radius: 10px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
/* ---------------------------------------------------------
GLOBAL UI OVERRIDES (HOME PAGE STYLE)
--------------------------------------------------------- */
/* Global Background & Font */
body,
.o_web_client {
background: var(--bg-gradient-main) !important;
font-family: 'Inter', 'Segoe UI', Roboto, sans-serif !important;
font-size: 15px !important;
/* Increased Font Size for Readability */
background-attachment: fixed !important;
background-size: cover !important;
}
/*
REMOVED AGGRESSIVE LAYOUT OVERRIDES
(o_action_manager, o_view_controller overrides deleted to restore Odoo default structural behavior)
*/
/* ---------------------------------------------------------
NAVBAR & SIDEBAR (CONTROL PANEL)
--------------------------------------------------------- */
/* Top Navbar - Glassmorphism */
.o_main_navbar {
background: #ffffff !important;
border-bottom: 2px solid var(--primary-color) !important;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.03) !important;
color: #2c3e50 !important;
}
.o_main_navbar .o_menu_brand {
color: var(--secondary-color) !important;
font-weight: 800 !important;
font-size: 1.4rem !important;
letter-spacing: -0.5px;
}
.o_main_navbar .o_menu_sections .o_nav_entry,
.o_main_navbar .o_menu_systray .o_nav_entry {
color: #111111 !important;
font-size: 14px !important;
font-weight: 600 !important;
}
.o_main_navbar .o_menu_sections .o_nav_entry:hover,
.o_main_navbar .o_menu_systray .o_nav_entry:hover {
background: var(--primary-color) !important;
}
/* Control Panel (Search, Filter Bar) */
.o_control_panel {
background: #fdfdfd !important;
border-bottom: 1px solid rgba(0, 0, 0, 0.05) !important;
}
/* Breadcrumbs */
.breadcrumb-item a {
color: #FECD4F !important;
font-weight: 700 !important;
font-size: 18px !important;
/* Larger headers */
}
.breadcrumb-item.active {
color: #718096 !important;
font-weight: 600 !important;
font-size: 18px !important;
}
/* Search Bar Input */
.o_searchview {
background: #f8f9fa !important;
border: 1px solid #e2e8f0 !important;
border-radius: 10px !important;
padding: 6px 15px !important;
}
.o_searchview .o_searchview_input {
background: transparent !important;
font-size: 14px !important;
}
/* ---------------------------------------------------------
TABLES (LIST VIEW)
--------------------------------------------------------- */
.o_list_view .o_list_table {
background: transparent !important;
}
/* Table Header */
.o_list_view .o_list_table thead {
background: rgba(248, 249, 250, 0.8) !important;
color: #4a5568 !important;
border-bottom: 2px solid var(--secondary-color) !important;
}
.o_list_view .o_list_table thead th {
border: none !important;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.05em;
font-weight: 700 !important;
padding: 12px 10px !important;
}
/* Table Rows */
.o_list_view .o_list_table tbody tr.o_data_row {
transition: all 0.2s ease !important;
border-bottom: 1px solid rgba(0, 0, 0, 0.03) !important;
}
.o_list_view .o_list_table tbody tr.o_data_row:hover {
background-color: rgba(214, 17, 30, 0.05) !important;
/* Light Red Hover */
transform: scale(1.002);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.02);
}
/* ---------------------------------------------------------
KANBAN CARDS
--------------------------------------------------------- */
.o_kanban_view .o_kanban_record {
background: #fff !important;
border: 1px solid rgba(0, 0, 0, 0.04) !important;
border-radius: 16px !important;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.02) !important;
margin-bottom: 10px !important;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
overflow: hidden;
}
/* Hover Effect for Cards */
.o_kanban_view .o_kanban_record:hover {
transform: translateY(-5px) !important;
box-shadow: 0 15px 30px rgba(0, 0, 0, 0.08) !important;
border-color: var(--primary-color) !important;
}
/* Card Header/Title */
.o_kanban_record .o_kanban_record_title,
.o_kanban_record strong {
color: #2d3748 !important;
font-weight: 700 !important;
font-size: 1.05rem !important;
}
/* Kanban Group Headers */
.o_kanban_view .o_kanban_group_header {
background: transparent !important;
}
/* ---------------------------------------------------------
BUTTONS & FORM ELEMENTS
--------------------------------------------------------- */
/* Gradient Buttons */
.btn-primary,
.o_form_button_save,
.o_form_button_create {
background: var(--gradient-primary) !important;
border: none !important;
border-radius: 10px !important;
padding: 8px 20px !important;
font-weight: 600 !important;
letter-spacing: 0.3px;
box-shadow: 0 4px 12px rgba(214, 17, 30, 0.3) !important;
color: #fff !important;
}
.btn-primary:hover,
.o_form_button_save:hover,
.o_form_button_create:hover {
background: var(--gradient-primary-hover) !important;
transform: translateY(-2px);
box-shadow: 0 8px 15px rgba(214, 17, 30, 0.4) !important;
}
/* Secondary Buttons */
.btn-secondary {
background: #fff !important;
border: 1px solid #e2e8f0 !important;
color: #4a5568 !important;
border-radius: 10px !important;
}
.btn-secondary:hover {
background: #f7fafc !important;
color: var(--secondary-color) !important;
border-color: var(--secondary-color) !important;
}
/* Form Inputs */
.o_form_view .o_input {
border-radius: 8px !important;
border: 1px solid #e2e8f0 !important;
padding: 8px 12px !important;
background-color: #fcfcfc !important;
}
.o_form_view .o_input:focus {
border-color: var(--secondary-color) !important;
background-color: #fff !important;
box-shadow: 0 0 0 3px rgba(214, 17, 30, 0.15) !important;
}
/* ---------------------------------------------------------
COMPONENT OVERRIDES
--------------------------------------------------------- */
/* Primary Buttons */
.btn-primary,
.o_form_button_save,
.o_form_button_create,
.o_button_import {
background: var(--gradient-primary) !important;
border: none !important;
border-radius: 10px !important;
padding: 8px 20px !important;
font-weight: 600 !important;
letter-spacing: 0.3px;
box-shadow: 0 4px 12px rgba(214, 17, 30, 0.3) !important;
}
.btn-primary:hover,
.o_form_button_save:hover,
.o_form_button_create:hover,
.o_button_import:hover {
background: var(--gradient-primary-hover) !important;
transform: translateY(-2px);
box-shadow: 0 8px 15px rgba(214, 17, 30, 0.4) !important;
}
/* ---------------------------------------------------------
DASHBOARD SPECIFIC UI UPDATES (But safer for global)
--------------------------------------------------------- */
/* Top Menu Tabs */
.o_main_navbar .o_menu_sections .dropdown-item,
.o_main_navbar .o_menu_sections .o_nav_entry {
background: transparent !important;
color: var(--primary-color) !important;
font-weight: 600 !important;
border-radius: 8px !important;
margin: 0 4px !important;
}
/* Active Tab / Hover State */
.o_main_navbar .o_menu_sections .o_nav_entry:hover,
.o_main_navbar .o_menu_sections .show .dropdown-toggle {
background-color: #ffffff !important;
/* Yellow Active Background */
color: #d6111e !important;
border-bottom: 2px solid #d6111e !important;
}
/* Dashboard Sidebar (Finance/Logistics) */
.o_notebook .nav.nav-tabs,
.o_dashboard_view .o_group_selector {
background: rgba(255, 255, 255, 0.6) !important;
backdrop-filter: blur(10px);
border-right: 1px solid rgba(0, 0, 0, 0.05);
padding: 15px !important;
}
.o_notebook .nav-link,
.o_dashboard_view .o_group_selector li {
color: var(--secondary-color) !important;
/* Teal Text */
border: none !important;
border-radius: 10px !important;
margin-bottom: 8px !important;
padding: 10px 15px !important;
font-weight: 500 !important;
transition: all 0.2s ease;
}
.o_notebook .nav-link.active,
.o_dashboard_view .o_group_selector li.selected {
background: var(--gradient-primary) !important;
color: #fff !important;
box-shadow: 0 4px 10px rgba(214, 17, 30, 0.3) !important;
}
/* Dashboard KPI Cards (Invoiced, Average Invoice, DSO) */
.o_dashboard_view .o_group .o_aggregate,
.o_group .o_group_col_6 {
background: #fff !important;
border-radius: 15px !important;
padding: 20px !important;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.05) !important;
border: 1px solid rgba(0, 0, 0, 0.02) !important;
transition: transform 0.3s ease !important;
margin-bottom: 20px !important;
}
.o_dashboard_view .o_group .o_aggregate:hover {
transform: translateY(-5px) !important;
box-shadow: 0 10px 25px rgba(214, 17, 30, 0.15) !important;
border-color: var(--secondary-color) !important;
}
/* Value Text in Cards */
.o_dashboard_view .o_group .o_aggregate .o_value {
color: var(--secondary-color) !important;
font-weight: 800 !important;
font-size: 2rem !important;
}
.o_dashboard_view .o_group .o_aggregate label {
color: #718096 !important;
font-size: 0.9rem !important;
text-transform: uppercase;
letter-spacing: 1px;
}
/* Charts Background */
.o_graph_view {
background: transparent !important;
}
.o_graph_canvas_container canvas {
background: rgba(255, 255, 255, 0.5) !important;
border-radius: 15px;
padding: 10px;
}
/* Links */
a,
.o_form_uri,
.oe_link,
.btn-link {
color: var(--secondary-color) !important;
/* Switched to teal for better readibility on white */
}
a:hover,
.o_form_uri:hover,
.oe_link:hover,
.btn-link:hover {
color: var(--primary-dark) !important;
}
/* Nav & Menu Active States */
.nav-link.active,
.o_menu_sections .dropdown-item.active,
.o_menu_brand:hover {
color: var(--secondary-color) !important;
border-bottom-color: var(--secondary-color) !important;
}
/* Badges */
.badge-primary,
.o_tag {
background-color: var(--primary-color) !important;
color: #fff !important;
}
/* Progress bars */
.progress-bar {
background-color: var(--secondary-color) !important;
}
/* Checkboxes and Radios */
.custom-control-input:checked~.custom-control-label::before,
.o_checkbox input:checked+label::before {
background-color: var(--secondary-color) !important;
border-color: var(--secondary-color) !important;
}
/* Form Controls - Focus using Secondary (Teal) like Login Page */
.form-control:focus,
.o_input:focus,
.o_searchview_input:focus {
border-color: var(--secondary-color) !important;
box-shadow: 0 0 0 0.2rem rgba(214, 17, 30, 0.25) !important;
}
/* Loading Bar */
.o_loading {
background-color: var(--primary-color) !important;
}
/* Backend View Headers */
.o_list_view .o_list_table thead,
.o_kanban_view .o_kanban_group_header {
background-color: #f8f9fa;
border-bottom: 2px solid var(--primary-color) !important;
color: #4a5568 !important;
font-weight: 600 !important;
padding: 10px !important;
}
/* Kanban Records Selected */
.o_kanban_record.o_kanban_record_selected {
box-shadow: 0 0 0 2px var(--primary-color) !important;
}
/* Pagination */
.page-item.active .page-link {
background-color: var(--secondary-color) !important;
border-color: var(--secondary-color) !important;
}
/* Switcher/Toggle */
.o_switch.custom-switch .custom-control-input:checked~.custom-control-label::before {
background-color: var(--secondary-color) !important;
}
/* Calendar Today */
.fc-day-today {
background-color: rgba(254, 205, 79, 0.1) !important;
}
/* ---------------------------------------------------------
HEADER SUBMENU DROPDOWN NAVIGATION
--------------------------------------------------------- */
/* Dropdown Container */
.dropdown-menu {
background: #ffffff !important;
border: 1px solid #e2e8f0 !important;
border-radius: 12px !important;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.15) !important;
padding: 8px !important;
margin-top: 5px !important;
z-index: 10000 !important;
/* Raised to be above most backend elements */
}
/* Fix for Dropdowns being hidden in List Views (Editable rows) */
.o_list_view .o_list_table {
overflow: visible !important;
}
.o_list_view .o_list_table tbody tr,
.o_list_view .o_list_table tbody td {
overflow: visible !important;
}
/* Ensure Autocomplete stays in front */
.ui-autocomplete,
.o_autocomplete_dropdown,
.dropdown-menu.show {
z-index: 10001 !important;
}
.o_main_navbar .o_menu_sections .o_nav_entry.active,
.o_main_navbar .o_menu_sections .dropdown-toggle {
background-color: none !important;
}
.o_main_navbar .o_menu_sections .o_nav_entry.active,
.o_main_navbar .o_menu_sections .dropdown-toggle.active {
background-color: var(--primary-color) !important;
}
.o_main_navbar .o_menu_brand,
.o_main_navbar .o_navbar_apps_menu .dropdown-toggle,
.o_main_navbar .o_nav_entry,
.o_main_navbar .dropdown-toggle {
color: var(--secondary-color) !important;
font-size: 1.1rem !important;
transition: all 0.2s ease !important;
}
/* Navbar Icons */
.o_main_navbar .o_menu_systray .o_nav_entry i,
.o_main_navbar .o_menu_apps .o_app_drawer i,
.o_main_navbar .o_menu_sections .fa {
color: var(--secondary-color) !important;
font-size: 1.1rem !important;
transition: all 0.2s ease !important;
}
.o_main_navbar .o_menu_systray .o_nav_entry:hover i,
.o_main_navbar .o_menu_apps .o_app_drawer:hover i {
color: var(--primary-dark) !important;
transform: scale(1.1);
text-shadow: 0 2px 10px rgba(214, 17, 30, 0.5);
}
/* Dropdown Items */
.dropdown-item {
color: #4a5568 !important;
padding: 10px 15px !important;
border-radius: 8px !important;
font-weight: 500 !important;
transition: all 0.2s ease !important;
margin-bottom: 2px !important;
background: transparent;
}
/* Dropdown Item Hover */
.dropdown-item:hover,
.dropdown-item:focus {
background: var(--gradient-primary) !important;
color: #fff !important;
box-shadow: 0 4px 10px rgba(214, 17, 30, 0.2);
}
/* Dropdown Header/Divider */
.dropdown-divider {
border-top: 1px solid rgba(0, 0, 0, 0.06) !important;
margin: 8px 0 !important;
}
.dropdown-header {
color: var(--secondary-color) !important;
font-weight: 700 !important;
text-transform: uppercase;
font-size: 0.75rem !important;
letter-spacing: 0.05em;
padding: 8px 15px !important;
background: transparent !important;
}
/* Active Dropdown Item */
.dropdown-item.active,
.dropdown-item:active {
background: var(--gradient-primary) !important;
color: #fff !important;
}
.o_main_navbar .o_menu_sections .o_nav_entry,
.o_main_navbar .o_menu_sections .dropdown-toggle {
background: #ffffff;
border-bottom: 2px solid #d6111e;
}
.list-group-item.active {
z-index: 2;
color: #fff;
background-color: #d6111e;
border-color: #d6111e;
}

View File

@ -0,0 +1,138 @@
/* Custom Website Header Styling */
/* Force Header Background to Black */
header#top,
body .o_header_standard,
body header.o_header_standard,
body .navbar,
body #o_main_nav {
background-color: #111 !important;
/* background: #171422 !important; */
border-bottom: none;
}
/* Navbar Links / Menu Items */
header#top .navbar-nav .nav-link,
header#top .nav-link,
body .o_header_standard .nav-link,
body .navbar .nav-link,
body #o_main_nav .nav-link {
color: #ffffff !important;
font-weight: 500;
}
/* Navbar Icons (Search, Cart, User, etc.) */
header#top i,
header#top .fa,
body .o_header_standard i,
body .o_header_standard .fa,
body .navbar i,
body .navbar .fa {
color: #ffffff !important;
}
/* Brand / Logo Text (if text logo) */
header#top .navbar-brand,
body .o_header_standard .navbar-brand {
color: #ffffff !important;
}
/* Hover States - Using Theme Teal */
header#top .nav-link:hover,
body .o_header_standard .nav-link:hover,
header#top i:hover,
body .o_header_standard i:hover {
color: #2BB1A5 !important;
}
/* Active States - Using Theme Gold */
header#top .nav-link.active,
body .o_header_standard .nav-link.active {
color: #2BB1A5 !important;
}
/* Dropdown Menu overrides (ensure visibility) */
header#top .dropdown-menu,
body .o_header_standard .dropdown-menu {
background-color: #171422 !important;
border: 1px solid rgba(43, 177, 165, 0.2);
}
header#top .dropdown-item,
body .o_header_standard .dropdown-item {
color: #ffffff !important;
}
header#top .dropdown-item:hover,
body .o_header_standard .dropdown-item:hover {
background-color: #2BB1A5 !important;
color: #ffffff !important;
}
/* Header Cart & Search Icon Adjustments */
.o_wsale_my_cart,
.o_nav_item_search,
.o_nav_item_cart,
header .navbar-nav .nav-item a[href="/shop/cart"],
header .navbar-nav .nav-item a.o_navlink_background_hover {
transition: all 0.3s ease !important;
}
/* Hover background for Search and Cart icons */
.o_wsale_my_cart:hover,
.o_nav_item_search:hover,
.o_nav_item_cart:hover,
header .navbar-nav .nav-item a[href="/shop/cart"]:hover,
header .o_search_button:hover {
background-color: #FECD4F !important;
border-radius: 50px !important;
}
/* Ensure icons turn dark on yellow background hover */
.o_wsale_my_cart:hover i,
.o_nav_item_search:hover i,
.o_nav_item_cart:hover i,
header .navbar-nav .nav-item a[href="/shop/cart"]:hover i,
header .o_search_button:hover i {
color: #111 !important;
}
/* Cart Count Badge Color */
.o_wsale_my_cart .badge,
.o_wsale_my_cart .o_wsale_cart_quantity,
header .navbar-nav .nav-item .badge-pill,
header .navbar-nav .nav-item .badge {
background-color: #2BB1A5 !important;
color: #ffffff !important;
border: none !important;
}
/* Header Action Buttons */
header .btn,
header .btn-primary,
header .o_header_cta_btn,
header .btn-contact,
header a.btn[href="/contactus"],
header a.btn[href*="contact"] {
background-color: #FECD4F !important;
color: #04121D !important;
border: none !important;
border-radius: 8px !important;
padding: 10px 25px !important;
font-weight: 700 !important;
text-transform: uppercase !important;
transition: all 0.3s ease !important;
box-shadow: 0 4px 10px rgba(254, 205, 79, 0.3) !important;
text-decoration: none !important;
}
header .btn:hover,
header .btn-primary:hover,
header .o_header_cta_btn:hover,
header .btn-contact:hover,
header a.btn[href="/contactus"]:hover,
header a.btn[href*="contact"]:hover {
background-color: #FECD4F !important;
color: #FECD4F !important;
transform: translateY(-2px) !important;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1014 KiB

View File

@ -0,0 +1,28 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<defs>
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#d6111e;stop-opacity:1" />
<stop offset="100%" style="stop-color:#8b0d16;stop-opacity:1" />
</linearGradient>
<linearGradient id="grad2" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#ff6b7a;stop-opacity:1" />
<stop offset="100%" style="stop-color:#d6111e;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="256" height="256" rx="56" fill="white"/>
<!-- 3x3 App grid -->
<rect x="44" y="44" width="56" height="56" rx="14" fill="url(#grad1)"/>
<rect x="114" y="44" width="56" height="56" rx="14" fill="url(#grad2)"/>
<rect x="156" y="44" width="56" height="56" rx="14" fill="#ffb347" opacity="0.85"/>
<rect x="44" y="114" width="56" height="56" rx="14" fill="#ffb347" opacity="0.85"/>
<rect x="114" y="114" width="56" height="56" rx="14" fill="url(#grad1)"/>
<rect x="184" y="114" width="56" height="56" rx="14" fill="url(#grad2)"/>
<rect x="44" y="184" width="56" height="28" rx="14" fill="url(#grad2)"/>
<rect x="114" y="184" width="56" height="28" rx="14" fill="#ffb347" opacity="0.85"/>
<rect x="184" y="184" width="28" height="28" rx="14" fill="url(#grad1)"/>
<!-- Plus icon in center of middle grid -->
<text x="142" y="149" font-family="Arial" font-size="32" font-weight="bold" fill="white" text-anchor="middle">+</text>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,32 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
<stop offset="100%" style="stop-color:#fff5f5;stop-opacity:1" />
</linearGradient>
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#d6111e;stop-opacity:1" />
<stop offset="100%" style="stop-color:#8b0d16;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="256" height="256" rx="56" fill="url(#bg)"/>
<!-- Calendar body -->
<rect x="44" y="76" width="168" height="144" rx="16" fill="white" stroke="#e8e8e8" stroke-width="3"/>
<!-- Header -->
<rect x="44" y="76" width="168" height="52" rx="16" fill="url(#grad1)"/>
<rect x="44" y="104" width="168" height="24" fill="#d6111e"/>
<!-- Calendar pins -->
<rect x="86" y="58" width="14" height="36" rx="7" fill="#555"/>
<rect x="156" y="58" width="14" height="36" rx="7" fill="#555"/>
<!-- Day number -->
<text x="128" y="116" font-family="Arial" font-size="28" font-weight="bold" fill="white" text-anchor="middle">31</text>
<!-- Grid dots -->
<circle cx="80" cy="158" r="7" fill="#d6111e"/>
<circle cx="112" cy="158" r="7" fill="#ccc"/>
<circle cx="144" cy="158" r="7" fill="#ccc"/>
<circle cx="176" cy="158" r="7" fill="#ccc"/>
<circle cx="80" cy="186" r="7" fill="#ccc"/>
<circle cx="112" cy="186" r="7" fill="#d6111e"/>
<circle cx="144" cy="186" r="7" fill="#ccc"/>
<circle cx="176" cy="186" r="7" fill="#ccc"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<defs>
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#d6111e;stop-opacity:1" />
<stop offset="100%" style="stop-color:#8b0d16;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="256" height="256" rx="56" fill="white"/>
<!-- Person head -->
<circle cx="128" cy="96" r="40" fill="url(#grad1)"/>
<!-- Person body -->
<path d="M60 210 Q60 158 128 158 Q196 158 196 210 Z" fill="url(#grad1)"/>
<!-- Book lines -->
<rect x="36" y="100" width="16" height="8" rx="3" fill="#d6111e" opacity="0.5"/>
<rect x="36" y="120" width="16" height="8" rx="3" fill="#d6111e" opacity="0.5"/>
<rect x="36" y="140" width="16" height="8" rx="3" fill="#d6111e" opacity="0.5"/>
</svg>

After

Width:  |  Height:  |  Size: 823 B

View File

@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<defs>
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#d6111e;stop-opacity:1" />
<stop offset="100%" style="stop-color:#8b0d16;stop-opacity:1" />
</linearGradient>
<linearGradient id="grad2" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#ff6b7a;stop-opacity:1" />
<stop offset="100%" style="stop-color:#d6111e;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="256" height="256" rx="56" fill="white"/>
<!-- Funnel/Pipeline -->
<polygon points="48,60 208,60 168,120 88,120" fill="url(#grad1)"/>
<polygon points="88,128 168,128 148,178 108,178" fill="url(#grad2)"/>
<polygon points="108,185 148,185 135,220 121,220" fill="#8b0d16"/>
</svg>

After

Width:  |  Height:  |  Size: 842 B

View File

@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<defs>
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#d6111e;stop-opacity:1" />
<stop offset="100%" style="stop-color:#8b0d16;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="256" height="256" rx="56" fill="white"/>
<!-- Dashboard grid panels -->
<rect x="40" y="40" width="78" height="78" rx="14" fill="url(#grad1)"/>
<rect x="138" y="40" width="78" height="78" rx="14" fill="#ff6b7a" opacity="0.8"/>
<rect x="40" y="138" width="78" height="78" rx="14" fill="#ff6b7a" opacity="0.6"/>
<rect x="138" y="138" width="78" height="78" rx="14" fill="url(#grad1)"/>
<!-- Bar chart inside top-left -->
<rect x="54" y="82" width="10" height="22" rx="3" fill="white"/>
<rect x="70" y="70" width="10" height="34" rx="3" fill="white" opacity="0.8"/>
<rect x="86" y="75" width="10" height="29" rx="3" fill="white" opacity="0.6"/>
<!-- Pie slice inside bottom-right -->
<path d="M177 177 L177 157 A20 20 0 0 1 197 177 Z" fill="white"/>
<path d="M177 177 L197 177 A20 20 0 0 1 167 195 Z" fill="white" opacity="0.7"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
<stop offset="100%" style="stop-color:#fff5f5;stop-opacity:1" />
</linearGradient>
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#d6111e;stop-opacity:1" />
<stop offset="100%" style="stop-color:#8b0d16;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="256" height="256" rx="56" fill="url(#bg)"/>
<!-- Main chat bubble -->
<rect x="50" y="70" width="140" height="100" rx="20" fill="url(#grad1)"/>
<polygon points="70,170 70,200 100,170" fill="#d6111e"/>
<!-- Dots inside bubble -->
<circle cx="98" cy="120" r="10" fill="white"/>
<circle cx="128" cy="120" r="10" fill="white" opacity="0.8"/>
<circle cx="158" cy="120" r="10" fill="white" opacity="0.6"/>
<!-- Small secondary bubble -->
<rect x="140" y="155" width="76" height="52" rx="14" fill="#ff6b7a" opacity="0.3"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<defs>
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#d6111e;stop-opacity:1" />
<stop offset="100%" style="stop-color:#8b0d16;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="256" height="256" rx="56" fill="white"/>
<!-- Left person head -->
<circle cx="88" cy="92" r="30" fill="url(#grad1)" opacity="0.75"/>
<!-- Right person head -->
<circle cx="168" cy="92" r="30" fill="url(#grad1)"/>
<!-- Left person body -->
<path d="M34 210 Q34 160 88 160 Q120 160 126 175 Q114 158 88 158 Q38 158 38 210 Z" fill="#d6111e" opacity="0.6"/>
<path d="M34 215 Q34 160 88 160 Q118 160 126 178" fill="none" stroke="#d6111e" stroke-width="0"/>
<path d="M28 215 Q30 155 88 155 Q122 155 130 180" fill="#d6111e" opacity="0.55"/>
<!-- Right/main person body -->
<path d="M100 215 Q100 158 168 158 Q236 158 236 215 Z" fill="url(#grad1)"/>
</svg>

After

Width:  |  Height:  |  Size: 996 B

View File

@ -0,0 +1,23 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<defs>
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#d6111e;stop-opacity:1" />
<stop offset="100%" style="stop-color:#8b0d16;stop-opacity:1" />
</linearGradient>
<linearGradient id="grad2" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#ff6b7a;stop-opacity:1" />
<stop offset="100%" style="stop-color:#d6111e;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="256" height="256" rx="56" fill="white"/>
<!-- Bottom large box -->
<rect x="40" y="162" width="176" height="62" rx="12" fill="url(#grad1)"/>
<!-- Middle box -->
<rect x="60" y="108" width="136" height="62" rx="12" fill="url(#grad2)"/>
<!-- Top small box -->
<rect x="84" y="60" width="88" height="54" rx="12" fill="url(#grad1)" opacity="0.8"/>
<!-- Box lines/lids -->
<line x1="40" y1="180" x2="216" y2="180" stroke="white" stroke-width="3" opacity="0.4"/>
<line x1="60" y1="126" x2="196" y2="126" stroke="white" stroke-width="3" opacity="0.4"/>
<line x1="84" y1="78" x2="172" y2="78" stroke="white" stroke-width="3" opacity="0.4"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<defs>
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#d6111e;stop-opacity:1" />
<stop offset="100%" style="stop-color:#8b0d16;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="256" height="256" rx="56" fill="white"/>
<!-- Invoice paper -->
<rect x="56" y="44" width="144" height="180" rx="14" fill="white" stroke="#eee" stroke-width="3"/>
<!-- Invoice fold corner -->
<polygon points="168,44 200,76 168,76" fill="url(#grad1)"/>
<!-- Header bar -->
<rect x="56" y="44" width="112" height="32" rx="14" fill="url(#grad1)"/>
<rect x="56" y="62" width="112" height="14" fill="#d6111e"/>
<!-- Invoice lines -->
<rect x="76" y="96" width="104" height="8" rx="4" fill="#eee"/>
<rect x="76" y="114" width="80" height="8" rx="4" fill="#eee"/>
<rect x="76" y="132" width="92" height="8" rx="4" fill="#eee"/>
<!-- Dollar amount highlight -->
<rect x="76" y="158" width="104" height="24" rx="6" fill="#fff0f0"/>
<text x="128" y="175" font-family="Arial" font-size="16" font-weight="bold" fill="#d6111e" text-anchor="middle">$ 128.00</text>
<!-- Footer tag -->
<rect x="76" y="196" width="104" height="12" rx="4" fill="url(#grad1)" opacity="0.3"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,23 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
<stop offset="100%" style="stop-color:#fff5f5;stop-opacity:1" />
</linearGradient>
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#d6111e;stop-opacity:1" />
<stop offset="100%" style="stop-color:#8b0d16;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="256" height="256" rx="56" fill="url(#bg)"/>
<!-- Chef hat brim -->
<rect x="58" y="158" width="140" height="28" rx="8" fill="url(#grad1)"/>
<!-- Chef hat body -->
<ellipse cx="128" cy="118" rx="62" ry="56" fill="white" stroke="#e0e0e0" stroke-width="3"/>
<!-- Chef hat top bump -->
<ellipse cx="128" cy="82" rx="30" ry="28" fill="white" stroke="#e0e0e0" stroke-width="3"/>
<!-- D360 text on hat -->
<text x="128" y="145" font-family="Arial" font-size="18" font-weight="bold" fill="#d6111e" text-anchor="middle">D360</text>
<!-- Fork and spoon cross below -->
<line x1="100" y1="195" x2="156" y2="195" stroke="white" stroke-width="4" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<defs>
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#d6111e;stop-opacity:1" />
<stop offset="100%" style="stop-color:#8b0d16;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="256" height="256" rx="56" fill="white"/>
<!-- POS terminal screen -->
<rect x="62" y="50" width="132" height="100" rx="14" fill="url(#grad1)"/>
<!-- Screen glare -->
<rect x="74" y="62" width="108" height="76" rx="8" fill="white" opacity="0.15"/>
<!-- Screen content lines -->
<rect x="82" y="74" width="60" height="10" rx="4" fill="white" opacity="0.8"/>
<rect x="82" y="92" width="40" height="8" rx="4" fill="white" opacity="0.6"/>
<rect x="150" y="88" width="24" height="18" rx="4" fill="white"/>
<!-- POS stand/neck -->
<rect x="114" y="150" width="28" height="18" rx="4" fill="#8b0d16"/>
<!-- POS base -->
<ellipse cx="128" cy="178" rx="52" ry="14" fill="url(#grad1)"/>
<!-- Keypad dots -->
<circle cx="88" cy="200" r="6" fill="#d6111e"/>
<circle cx="110" cy="200" r="6" fill="#d6111e"/>
<circle cx="132" cy="200" r="6" fill="#d6111e"/>
<circle cx="154" cy="200" r="6" fill="#d6111e"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<defs>
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#d6111e;stop-opacity:1" />
<stop offset="100%" style="stop-color:#8b0d16;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="256" height="256" rx="56" fill="white"/>
<!-- Shopping bag body -->
<path d="M60 110 L70 210 Q70 220 80 220 L176 220 Q186 220 186 210 L196 110 Z" fill="url(#grad1)"/>
<!-- Bag handle left -->
<path d="M96 110 Q96 62 128 62 Q160 62 160 110" fill="none" stroke="#8b0d16" stroke-width="14" stroke-linecap="round"/>
<!-- Bag top fold highlight -->
<rect x="60" y="100" width="136" height="18" rx="6" fill="#8b0d16" opacity="0.5"/>
<!-- PO badge -->
<rect x="86" y="148" width="84" height="36" rx="10" fill="white" opacity="0.25"/>
<text x="128" y="172" font-family="Arial" font-size="18" font-weight="bold" fill="white" text-anchor="middle">PO</text>
</svg>

After

Width:  |  Height:  |  Size: 999 B

View File

@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<defs>
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#d6111e;stop-opacity:1" />
<stop offset="100%" style="stop-color:#8b0d16;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="256" height="256" rx="56" fill="white"/>
<!-- Trending arrow up -->
<polyline points="44,180 96,124 136,152 212,72" fill="none" stroke="url(#grad1)" stroke-width="18" stroke-linecap="round" stroke-linejoin="round"/>
<!-- Arrow head -->
<polygon points="212,72 212,110 180,82" fill="#d6111e"/>
<!-- Dots on line -->
<circle cx="44" cy="180" r="10" fill="#d6111e"/>
<circle cx="96" cy="124" r="10" fill="#d6111e"/>
<circle cx="136" cy="152" r="10" fill="#d6111e"/>
<circle cx="212" cy="72" r="10" fill="#d6111e"/>
</svg>

After

Width:  |  Height:  |  Size: 864 B

View File

@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<defs>
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#d6111e;stop-opacity:1" />
<stop offset="100%" style="stop-color:#8b0d16;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="256" height="256" rx="56" fill="white"/>
<!-- Outer gear ring -->
<circle cx="128" cy="128" r="76" fill="none" stroke="url(#grad1)" stroke-width="20"/>
<!-- Gear teeth (8 teeth) -->
<rect x="116" y="36" width="24" height="30" rx="8" fill="url(#grad1)"/>
<rect x="116" y="190" width="24" height="30" rx="8" fill="url(#grad1)"/>
<rect x="36" y="116" width="30" height="24" rx="8" fill="url(#grad1)"/>
<rect x="190" y="116" width="30" height="24" rx="8" fill="url(#grad1)"/>
<!-- Diagonal teeth -->
<rect x="60" y="60" width="24" height="30" rx="8" fill="url(#grad1)" transform="rotate(45 72 75)"/>
<rect x="172" y="60" width="24" height="30" rx="8" fill="url(#grad1)" transform="rotate(-45 184 75)"/>
<rect x="60" y="166" width="24" height="30" rx="8" fill="url(#grad1)" transform="rotate(-45 72 181)"/>
<rect x="172" y="166" width="24" height="30" rx="8" fill="url(#grad1)" transform="rotate(45 184 181)"/>
<!-- Inner circle -->
<circle cx="128" cy="128" r="42" fill="url(#grad1)"/>
<!-- Center dot -->
<circle cx="128" cy="128" r="18" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<defs>
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#d6111e;stop-opacity:1" />
<stop offset="100%" style="stop-color:#8b0d16;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="256" height="256" rx="56" fill="white"/>
<!-- Table top -->
<ellipse cx="128" cy="118" rx="88" ry="22" fill="url(#grad1)"/>
<!-- Table leg -->
<rect x="118" y="138" width="20" height="62" rx="6" fill="#8b0d16"/>
<!-- Table base -->
<ellipse cx="128" cy="200" rx="50" ry="12" fill="#d6111e" opacity="0.5"/>
<!-- Reserved tag -->
<rect x="90" y="75" width="76" height="36" rx="8" fill="white" stroke="#d6111e" stroke-width="3"/>
<text x="128" y="99" font-family="Arial" font-size="14" font-weight="bold" fill="#d6111e" text-anchor="middle">RESERVED</text>
<!-- Chairs -->
<ellipse cx="54" cy="118" rx="20" ry="10" fill="#ff6b7a" opacity="0.7"/>
<ellipse cx="202" cy="118" rx="20" ry="10" fill="#ff6b7a" opacity="0.7"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<defs>
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#d6111e;stop-opacity:1" />
<stop offset="100%" style="stop-color:#8b0d16;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="256" height="256" rx="56" fill="white"/>
<!-- Car body -->
<rect x="30" y="140" width="196" height="56" rx="20" fill="url(#grad1)"/>
<!-- Car roof -->
<path d="M72 140 Q90 96 140 96 L186 96 Q210 96 220 140 Z" fill="url(#grad1)"/>
<!-- Windows -->
<path d="M90 140 Q100 108 128 108 L160 108 Q178 108 186 140 Z" fill="white" opacity="0.3"/>
<!-- Delivery bag on top -->
<rect x="100" y="68" width="56" height="42" rx="10" fill="#ff6b7a"/>
<rect x="112" y="58" width="32" height="18" rx="8" fill="#ff6b7a" stroke="white" stroke-width="3" fill-opacity="0"/>
<!-- U letter (Uber) -->
<text x="128" y="98" font-family="Arial" font-size="22" font-weight="bold" fill="white" text-anchor="middle">U</text>
<!-- Wheels -->
<circle cx="80" cy="196" r="22" fill="#8b0d16"/>
<circle cx="80" cy="196" r="12" fill="white"/>
<circle cx="176" cy="196" r="22" fill="#8b0d16"/>
<circle cx="176" cy="196" r="12" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<defs>
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#d6111e;stop-opacity:1" />
<stop offset="100%" style="stop-color:#8b0d16;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="256" height="256" rx="56" fill="white"/>
<!-- Globe/earth circle -->
<circle cx="128" cy="128" r="90" fill="none" stroke="#eee" stroke-width="3"/>
<circle cx="128" cy="128" r="90" fill="none" stroke="url(#grad1)" stroke-width="14" stroke-dasharray="60 510" stroke-dashoffset="-10"/>
<!-- Latitude lines -->
<ellipse cx="128" cy="128" rx="90" ry="38" fill="none" stroke="#d6111e" stroke-width="3" opacity="0.4"/>
<!-- Meridian (vertical oval) -->
<ellipse cx="128" cy="128" rx="44" ry="90" fill="none" stroke="#d6111e" stroke-width="3" opacity="0.4"/>
<!-- Center vertical line -->
<line x1="128" y1="38" x2="128" y2="218" stroke="#d6111e" stroke-width="3" opacity="0.4"/>
<!-- Horizontal center -->
<line x1="38" y1="128" x2="218" y2="128" stroke="#d6111e" stroke-width="3" opacity="0.4"/>
<!-- Outer circle -->
<circle cx="128" cy="128" r="90" fill="none" stroke="url(#grad1)" stroke-width="6"/>
<!-- Dine360 center badge -->
<circle cx="128" cy="128" r="28" fill="url(#grad1)"/>
<text x="128" y="134" font-family="Arial" font-size="13" font-weight="bold" fill="white" text-anchor="middle">D360</text>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 760 KiB

View File

@ -0,0 +1,31 @@
/** @odoo-module **/
import { WebClient } from "@web/webclient/webclient";
import { patch } from "@web/core/utils/patch";
import { onMounted } from "@odoo/owl";
patch(WebClient.prototype, {
setup() {
super.setup();
try {
this.title.setParts({ brand: "Shivasakthi" });
} catch (e) {
// title service not ready yet, ignore
}
onMounted(() => {
try {
// Only redirect to home dashboard if the URL has no meaningful hash
// (i.e., user just typed /web or /web# directly)
const hash = window.location.hash;
const path = window.location.pathname;
if (path === "/web" && (!hash || hash === "#" || hash === "#home")) {
window.location.href = "/";
}
} catch (e) {
// ignore redirect errors
}
});
}
});

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-inherit="web.NavBar" t-inherit-mode="extension" owl="1">
<xpath expr="//t[@t-call='web.NavBar.AppsMenu']" position="after">
<a t-if="!env.services.user.isPublic" href="/" class="o_navbar_dashboard_btn d-none d-md-flex align-items-center px-4"
style="background-color: #d61112; color: white !important; font-weight: bold; border-left: 1px solid rgba(255,255,255,0.2); text-decoration: none; height: 100%; -webkit-app-region: no-drag;"
title="Back to Main Dashboard">
<i class="fa fa-th-large me-2"></i>
<span>Dashboard</span>
</a>
</xpath>
</t>
</templates>

View File

@ -0,0 +1,102 @@
<odoo>
<template id="image_home_template" name="Home Page Icons">
<t t-call="web.frontend_layout">
<t t-set="no_footer" t-value="True"/>
<t t-set="no_header" t-value="True"/>
<t t-set="pageName" t-value="'home_dashboard'"/>
<link rel="stylesheet" type="text/css" href="/dine360_dashboard/static/src/css/home_menu.css?v=1.1"/>
<div class="o_home_menu_background">
<!-- Top Bar -->
<div class="o_home_top_bar">
<div class="o_top_bar_island">
<a href="/" class="o_top_item" title="Shivasakthi Home" style="width: auto; padding: 0 10px;">
<img t-attf-src="/web/binary/company_logo" style="height: 24px; width: auto; object-fit: contain;"/>
</a>
<div class="o_bar_divider" style="margin: 0 5px;"></div>
<a href="/" class="o_top_item" title="View Website">
<i class="fa fa-globe"/>
</a>
<a href="/web#action=mail.action_discuss" class="o_top_item" title="Messages">
<i class="fa fa-comments-o"/>
<span class="badge_dot"/>
</a>
<!-- Only show Settings for Admins -->
<t t-set="is_admin" t-value="user_id.has_group('base.group_system') or user_id.has_group('dine360_restaurant.group_restaurant_admin')"/>
<t t-if="is_admin">
<a href="/web#action=base.action_res_config_general_settings" class="o_top_item" title="Settings">
<i class="fa fa-sliders"/>
</a>
</t>
<div class="o_bar_divider"></div>
<div class="o_user_avatar_container">
<a href="/web#action=base.action_res_users_my" class="o_user_avatar" style="display:flex; align-items:center; justify-content:center; text-decoration:none;">
<t t-esc="user_id.name[0] if user_id else 'U'"/>
</a>
</div>
</div>
</div>
<div class="container" style="padding-top: 100px;">
<!-- Low Stock Alert Section -->
<t t-if="low_stock_products">
<div class="o_low_stock_alert mb-5" style="background: rgba(220, 53, 69, 0.1); border: 1px solid rgba(220, 53, 69, 0.2); border-radius: 12px; padding: 15px 25px;">
<div class="d-flex align-items-center mb-2">
<i class="fa fa-exclamation-triangle me-2" style="color: #dc3545; font-size: 1.2rem;"></i>
<h5 class="mb-0 fw-bold" style="color: #dc3545;">Low Stock Alert!</h5>
<a href="/web#action=stock.product_template_action_product" class="ms-auto text-decoration-none small fw-bold" style="color: #dc3545;">
Manage Inventory <i class="fa fa-arrow-right ms-1"></i>
</a>
</div>
<div class="d-flex flex-wrap gap-2">
<t t-foreach="low_stock_products" t-as="product">
<div class="badge rounded-pill bg-white border px-3 py-2 shadow-sm" style="color: #333;">
<span class="fw-bold" t-esc="product.name"/>:
<span class="text-danger fw-bold" t-esc="product.qty_available"/>
<span class="text-muted" t-esc="product.uom_id.name"/>
</div>
</t>
</div>
</div>
</t>
<div class="o_apps">
<t t-foreach="menus" t-as="menu">
<t t-set="app_url" t-value="'/web#menu_id=' + str(menu.id)"/>
<!-- Check if it is the Website app by checking the icon module or name -->
<t t-if="(menu.web_icon and menu.web_icon.startswith('website,')) or menu.name == 'Website'">
<t t-set="app_url" t-value="'/home'"/>
</t>
<a t-att-href="app_url" class="o_app">
<div class="o_app_icon_container">
<t t-if="menu.web_icon">
<t t-set="icon_data" t-value="menu.web_icon.split(',')"/>
<img t-attf-src="/{{icon_data[0]}}/{{icon_data[1]}}"
class="o_app_icon" loading="lazy"
onerror="this.style.display='none'; this.nextElementSibling.style.display='block';"/>
<div class="o_app_icon fa fa-cube" style="font-size: 40px; color: #7c7bad; display: none;"/>
</t>
<t t-else="">
<div class="o_app_icon fa fa-cube" style="font-size: 40px; color: #7c7bad;"/>
</t>
</div>
<div class="o_app_name">
<t t-esc="menu.name"/>
</div>
</a>
</t>
</div>
</div>
</div>
</t>
</template>
</odoo>

View File

@ -0,0 +1,45 @@
<odoo>
<template id="custom_login_layout" inherit_id="web.login_layout" name="Custom Login Layout" priority="10">
<xpath expr="//t[@t-call='web.frontend_layout']" position="inside">
<t t-set="body_classname" t-value="'o_custom_login_body'"/>
<t t-set="no_header" t-value="True"/>
<t t-set="no_footer" t-value="True"/>
<t t-set="head">
<link rel="stylesheet" type="text/css" href="/dine360_dashboard/static/src/css/login_style.css"/>
</t>
</xpath>
<xpath expr="//div[contains(@class,'container')]" position="replace">
<div class="o_login_main_wrapper">
<div class="o_login_left_side">
<!-- Space for right alignment -->
</div>
<div class="o_login_right_side">
<div class="o_login_card">
<div class="o_login_logo_container">
<img t-attf-src="/web/binary/company_logo{{ '?db=' + request.db if request.db else '' }}" alt="Logo"/>
</div>
<div class="o_login_header_text text-center">
<h3>Login</h3>
<p style="color: rgba(255, 255, 255, 0.6) !important;">Welcome back! Please enter your details.</p>
</div>
<div class="o_login_form_container">
<t t-out="0"/>
</div>
<div class="o_login_footer text-center mt-4">
<a href="/web/database/selector" t-if="not disable_database_manager" class="small text-decoration-none" style="color: rgba(255, 255, 255, 0.5) !important;">Manage Databases</a>
</div>
</div>
</div>
<div class="o_login_footer_custom">
<p>Powered by <a href="https://dine360.com/" target="_blank">Dine360 Inc</a></p>
</div>
</div>
</xpath>
</template>
</odoo>

View File

@ -0,0 +1,17 @@
<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>
<!-- Ensure Products Grid Takes Remaining Space -->
<!-- <template id="products_grid_inherit" inherit_id="website_sale.products" name="Adjust Products Grid">
<xpath expr="//div[@id='products_grid']" position="attributes">
<attribute name="class">col-lg-9</attribute>
</xpath>
</template> -->
</odoo>

View File

@ -0,0 +1,25 @@
<odoo>
<template id="shivasakthi_web_layout" inherit_id="web.layout" name="Shivasakthi Title">
<xpath expr="//title" position="replace">
<title>Shivasakthi</title>
</xpath>
<xpath expr="//link[@rel='shortcut icon']" position="replace">
<link rel="shortcut icon" href="/dine360_theme_shivasakthi/static/description/icon.png" type="image/x-icon"/>
</xpath>
<xpath expr="//body" position="inside">
<t t-set="is_editor" t-value="request.params.get('enable_editor') or request.params.get('edit')"/>
<t t-if="not request.env.user._is_public() and not is_editor">
<a href="/" class="o_dashboard_return_btn d-print-none" title="Back to Dashboard"
style="position: fixed; bottom: 20px; right: 20px; z-index: 9999;
background-color: #d6111e; color: #ffff !important; padding: 12px 24px;
border-radius: 50px; text-decoration: none; font-weight: bold;
box-shadow: 0 4px 15px rgba(0,0,0,0.2); display: flex; align-items: center;
gap: 8px; transition: all 0.3s ease; border: 2px solid #ffffffff !important; font-family: sans-serif;">
<i class="fa fa-th-large" style="font-size: 18px;"></i>
<span>Back to Dashboard</span>
</a>
</t>
</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

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

View File

@ -0,0 +1,38 @@
{
'name': 'Dine360 Kitchen Display System',
'version': '1.0',
'category': 'Sales/Point of Sale',
'summary': 'Dedicated KDS for Restaurant Kitchen',
'description': """
Professional Kitchen Display System:
- Real-time order tracking
- Preparation status management
- Cooking time tracking
- Kanban dashboard for chefs
- Floor/Table based organization
""",
'author': 'Dine360',
'depends': ['dine360_restaurant', 'point_of_sale', 'pos_restaurant', 'sale_management', 'website_sale', 'dine360_order_channels'],
'data': [
'security/ir.model.access.csv',
'views/pos_order_line_views.xml',
'views/product_views.xml',
'views/kds_menus.xml',
],
'assets': {
'web.assets_backend': [
'dine360_kds/static/src/css/kds_style.css',
'dine360_kds/static/src/js/kds_backend.js',
],
'point_of_sale._assets_pos': [
'dine360_kds/static/src/css/pos_kds.css',
'dine360_kds/static/src/js/pos_kds.js',
# 'dine360_kds/static/src/xml/pos_kds.xml', # Temporarily disabled
],
},
'installable': True,
'application': True,
'icon': '/dine360_kds/static/description/icon.png',
'license': 'LGPL-3',
}

View File

@ -0,0 +1,4 @@
from . import pos_order_line
from . import product
from . import pos_session
from . import website_sale_integration

View File

@ -0,0 +1,180 @@
from odoo import models, fields, api, _
import logging
_logger = logging.getLogger(__name__)
class PosOrderLine(models.Model):
_inherit = 'pos.order.line'
preparation_status = fields.Selection([
('waiting', 'Waiting'),
('preparing', 'Preparing'),
('ready', 'Ready'),
('served', 'Served'),
('cancelled', 'Cancelled')
], string='Preparation Status', default='waiting', tracking=True, group_expand='_read_group_preparation_status')
@api.model
def _read_group_preparation_status(self, stages, domain, order):
return ['waiting', 'preparing', 'ready', 'served']
color = fields.Integer(string='Color', default=0)
preparation_time_start = fields.Datetime(string='Start Time')
preparation_time_end = fields.Datetime(string='Ready Time')
cooking_time = fields.Integer(string='Cooking Time (min)', compute='_compute_cooking_time', store=True)
table_id = fields.Many2one('restaurant.table', related='order_id.table_id', string='Table', store=True)
floor_id = fields.Many2one('restaurant.floor', related='order_id.table_id.floor_id', string='Floor', store=True)
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'),
], related='order_id.order_source', string='Order Source', store=True)
fulfilment_type = fields.Selection([
('dine_in', 'Dine-In'),
('pickup', 'Pickup'),
('delivery', 'Delivery'),
], related='order_id.fulfilment_type', string='Fulfilment Type', store=True)
@api.depends('preparation_time_start', 'preparation_time_end')
def _compute_cooking_time(self):
for line in self:
if line.preparation_time_start and line.preparation_time_end:
diff = line.preparation_time_end - line.preparation_time_start
line.cooking_time = int(diff.total_seconds() / 60)
else:
line.cooking_time = 0
def _notify_pos(self):
"""Send notification to POS when order line status changes"""
_logger.info("=== _notify_pos called for %s lines ===" % len(self))
for line in self:
_logger.info(f"Processing line {line.id}, order: {line.order_id.name}, config: {line.order_id.config_id}")
if line.order_id.config_id:
channel_name = "pos_config_Channel_%s" % line.order_id.config_id.id
payload = {
'line_id': line.id,
'order_id': line.order_id.id,
'status': line.preparation_status,
'status_label': dict(self._fields['preparation_status']._description_selection(self.env)).get(line.preparation_status),
'order_uid': line.order_id.pos_reference,
'product_id': line.product_id.id,
'qty': line.qty,
}
_logger.info(f"KDS NOTIFICATION: Sending update for Line {line.id} Status {line.preparation_status} to {channel_name}")
self.env['bus.bus']._sendone(channel_name, 'kds_update', payload)
else:
_logger.warning(f"Line {line.id} has no config_id - cannot send notification")
def _notify_kds(self):
"""Send notification to KDS backend when new order lines are created"""
_logger.info("=== _notify_kds called for %s lines ===" % len(self))
for line in self:
# Only notify for kitchen items
if line.product_id.is_kitchen_item and line.product_id.name != 'Water':
# Send to global KDS channel
kds_channel = "kds_channel"
payload = {
'line_id': line.id,
'order_id': line.order_id.id,
'product_name': line.product_id.name,
'qty': line.qty,
'table_name': line.table_id.name if line.table_id else '',
'floor_name': line.floor_id.name if line.floor_id else '',
'customer_note': line.customer_note or '',
'preparation_status': line.preparation_status,
'create_date': line.create_date.isoformat() if line.create_date else '',
}
_logger.info(f"KDS BACKEND NOTIFICATION: New order line {line.id} for {line.product_id.name}")
self.env['bus.bus']._sendone(kds_channel, 'kds_new_order', payload)
@api.model_create_multi
def create(self, vals_list):
"""Override create to send notifications to KDS when new orders are added"""
lines = super(PosOrderLine, self).create(vals_list)
# 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):
"""Prevent resetting 'served' or 'ready' status back to 'waiting' during POS sync"""
if 'preparation_status' in vals and vals['preparation_status'] == 'waiting':
for line in self:
if line.preparation_status in ['served', 'ready', 'preparing']:
# Keep the current status if it's already being processed or served
actual_vals = vals.copy()
actual_vals['preparation_status'] = line.preparation_status
super(PosOrderLine, line).write(actual_vals)
# Handle lines that are actually allowed to be updated to waiting
remaining_lines = self.filtered(lambda l: l.preparation_status not in ['served', 'ready', 'preparing'])
if remaining_lines:
res = super(PosOrderLine, remaining_lines).write(vals)
# If quantity changed or status is waiting, notify KDS to refresh
remaining_lines._notify_kds()
return res
return True
res = super(PosOrderLine, self).write(vals)
# Notify KDS for quantity changes on active items
if 'qty' in vals:
active_lines = self.filtered(lambda l: l.preparation_status in ['waiting', 'preparing'])
if active_lines:
active_lines._notify_kds()
return res
def action_start_preparing(self):
self.write({
'preparation_status': 'preparing',
'preparation_time_start': fields.Datetime.now()
})
self._notify_pos()
def action_mark_ready(self):
self.write({
'preparation_status': 'ready',
'preparation_time_end': fields.Datetime.now()
})
self._notify_pos()
def action_mark_served(self):
self.write({
'preparation_status': 'served'
})
self._notify_pos()
class PosOrder(models.Model):
_inherit = 'pos.order'
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')
fulfilment_type = fields.Selection([
('dine_in', 'Dine-In'),
('pickup', 'Pickup'),
('delivery', 'Delivery'),
], string='Fulfilment Type', default='dine_in')
@api.model
def _prepare_order_line_vals(self, line, session_id=None):
res = super()._prepare_order_line_vals(line, session_id)
if 'preparation_status' in line:
res['preparation_status'] = line['preparation_status']
return res

View File

@ -0,0 +1,9 @@
from odoo import models
class PosSession(models.Model):
_inherit = 'pos.session'
def _loader_params_pos_order_line(self):
params = super()._loader_params_pos_order_line()
params['search_params']['fields'].extend(['preparation_status', 'preparation_time_start', 'preparation_time_end'])
return params

View File

@ -0,0 +1,10 @@
from odoo import models, fields
class ProductTemplate(models.Model):
_inherit = 'product.template'
is_kitchen_item = fields.Boolean(
string='Show in KDS',
default=False,
help="If checked, this product will appear in the Kitchen Display System when ordered."
)

View File

@ -0,0 +1,105 @@
from odoo import models, fields, api, _
import logging
_logger = logging.getLogger(__name__)
class SaleOrder(models.Model):
_inherit = 'sale.order'
def action_confirm(self):
"""
Override to create a POS Order for KDS when a Website Order is confirmed.
"""
res = super(SaleOrder, self).action_confirm()
for order in self:
# Check if it's a website order (usually has website_id)
if order.website_id:
try:
self._create_pos_order_for_kds(order)
except Exception as e:
_logger.error(f"Failed to create POS order for Website Order {order.name}: {str(e)}")
return res
def _create_pos_order_for_kds(self, sale_order):
"""Create a POS Order based on the Sale Order details"""
# Use a savepoint so that if KDS creation fails, the main Sale Order confirmation succeeds
with self.env.cr.savepoint():
PosOrder = self.env['pos.order']
PosSession = self.env['pos.session']
PosConfig = self.env['pos.config']
# 1. Find a suitable POS Config (e.g., 'Website' or first available restaurant)
config = PosConfig.search([('module_pos_restaurant', '=', True), ('active', '=', True)], limit=1)
if not config:
_logger.warning("No active POS Restaurant configuration found. Skipping KDS creation.")
return
# 2. Find or Open a Session
session = PosSession.search([
('config_id', '=', config.id),
('state', '=', 'opened')
], limit=1)
if not session:
_logger.warning(f"No open POS session found for config {config.name}. Cannot send to KDS.")
return
# 3. Create POS Order Lines
lines_data = []
for line in sale_order.order_line:
if not line.product_id:
continue
qty = line.product_uom_qty
if qty <= 0:
continue
# Skip non-kitchen items, but allow delivery lines for accurate total matching
is_delivery_line = getattr(line, 'is_delivery', False)
if not is_delivery_line and (not line.product_id.is_kitchen_item or line.product_id.type == 'service'):
continue
lines_data.append((0, 0, {
'product_id': line.product_id.id,
'qty': qty,
'price_unit': line.price_unit,
'price_subtotal': line.price_subtotal,
'price_subtotal_incl': line.price_total,
'full_product_name': line.name,
'tax_ids': [(6, 0, line.tax_id.ids)],
# 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 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)
# 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,
'pricelist_id': sale_order.pricelist_id.id or session.config_id.pricelist_id.id,
'pos_reference': pos_reference,
'lines': lines_data,
'amount_total': sale_order.amount_total,
'amount_tax': sale_order.amount_tax,
'amount_paid': 0.0, # Not processing payment in POS to avoid duplication
'amount_return': 0.0,
'note': f"From Website Order {sale_order.name}",
# 'state': 'draft', # Default is draft
})
# 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,6 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_kds_order_line_kitchen,pos.order.line.kitchen,point_of_sale.model_pos_order_line,dine360_restaurant.group_restaurant_kitchen,1,1,0,0
access_kds_order_line_manager,pos.order.line.manager,point_of_sale.model_pos_order_line,dine360_restaurant.group_restaurant_manager,1,1,1,1
access_kds_order_line_user,pos.order.line.user,point_of_sale.model_pos_order_line,base.group_user,1,1,1,0
access_kds_pos_session_kitchen,pos.session.kitchen,point_of_sale.model_pos_session,dine360_restaurant.group_restaurant_kitchen,1,0,0,0
access_kds_pos_category_kitchen,pos.category.kitchen,point_of_sale.model_pos_category,dine360_restaurant.group_restaurant_kitchen,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_kds_order_line_kitchen pos.order.line.kitchen point_of_sale.model_pos_order_line dine360_restaurant.group_restaurant_kitchen 1 1 0 0
3 access_kds_order_line_manager pos.order.line.manager point_of_sale.model_pos_order_line dine360_restaurant.group_restaurant_manager 1 1 1 1
4 access_kds_order_line_user pos.order.line.user point_of_sale.model_pos_order_line base.group_user 1 1 1 0
5 access_kds_pos_session_kitchen pos.session.kitchen point_of_sale.model_pos_session dine360_restaurant.group_restaurant_kitchen 1 0 0 0
6 access_kds_pos_category_kitchen pos.category.kitchen point_of_sale.model_pos_category dine360_restaurant.group_restaurant_kitchen 1 0 0 0

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@ -0,0 +1,49 @@
.role_kitchen_card {
transition: transform 0.2s ease, box-shadow 0.2s ease;
cursor: default !important;
}
.role_kitchen_card:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1) !important;
}
.o_kanban_group {
background-color: #f4f7fa !important;
border-radius: 15px !important;
margin: 10px !important;
padding: 10px !important;
}
.o_kanban_header {
background: transparent !important;
padding: 15px 10px !important;
}
.o_kanban_header_title {
font-weight: 700 !important;
text-transform: uppercase !important;
letter-spacing: 1px !important;
}
/* Specific colors for status columns */
.o_kanban_group[data-id="waiting"] .o_kanban_header_title {
color: #f0ad4e;
}
.o_kanban_group[data-id="preparing"] .o_kanban_header_title {
color: #5bc0de;
}
.o_kanban_group[data-id="ready"] .o_kanban_header_title {
color: #5cb85c;
}
.o_kanban_group[data-id="served"] .o_kanban_header_title {
color: #777;
}
.badge.rounded-pill {
padding: 0.5em 1em;
font-weight: 600;
}

View File

@ -0,0 +1,29 @@
.pos .badge {
padding: 2px 6px;
border-radius: 4px;
font-size: 0.8rem;
font-weight: bold;
color: white;
margin-left: 5px;
vertical-align: middle;
}
.pos .badge-waiting {
background-color: #f0ad4e;
}
.pos .badge-preparing {
background-color: #5bc0de;
}
.pos .badge-ready {
background-color: #5cb85c;
}
.pos .badge-served {
background-color: #777;
}
.pos .badge-cancelled {
background-color: #d9534f;
}

View File

@ -0,0 +1,91 @@
/** @odoo-module */
import { registry } from "@web/core/registry";
import { KanbanController } from "@web/views/kanban/kanban_controller";
import { kanbanView } from "@web/views/kanban/kanban_view";
import { onWillUnmount } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
export class KdsKanbanController extends KanbanController {
setup() {
super.setup();
console.log("[KDS Controller] Setup");
// Direct access to services to avoid 'methods is not iterable' error in Owl lifecycle
this.busService = this.env.services.bus_service;
this.notification = this.env.services.notification;
const kdsChannel = "kds_channel";
if (this.busService) {
console.log(`[KDS Controller] Subscribing to channel: ${kdsChannel}`);
this.busService.addChannel(kdsChannel);
const handler = this._onKdsNotification.bind(this);
this.busService.addEventListener("notification", handler);
onWillUnmount(() => {
if (this.busService) {
this.busService.removeEventListener("notification", handler);
}
});
}
}
_onKdsNotification(event) {
// console.log("[KDS Controller] Notification received:", event);
const notifications = event.detail || [];
let shouldReload = false;
for (const notif of notifications) {
// console.log("[KDS Controller] Processing notification:", notif);
if (notif.type === "kds_new_order") {
console.log("[KDS Controller] New order notification:", notif.payload);
// Show notification to user
if (this.notification) {
const payload = notif.payload;
this.notification.add(
`New Order: ${payload.qty}x ${payload.product_name} - ${payload.table_name || 'Web/Takeaway'}`,
{
title: "Kitchen Display",
type: "info",
}
);
}
shouldReload = true;
}
}
if (shouldReload) {
// Reload the view to show the new order
// Adding a small delay to ensure the transaction is fully committed and indexed
console.log("[KDS Controller] New order received. Reloading view in 1.5s...");
setTimeout(async () => {
try {
await this.model.load();
console.log("[KDS Controller] View reloaded. Current record count:", this.model.root.count);
// Force a re-render if the view still thinks it's empty
if (this.model.root.count > 0) {
this.render();
}
} catch (error) {
console.error("[KDS Controller] Error during reload:", error);
}
}, 1500);
}
}
}
export const kdsKanbanView = {
...kanbanView,
Controller: KdsKanbanController,
};
registry.category("views").add("kds_kanban", kdsKanbanView);
console.log("[KDS Backend] kds_backend.js loaded (JS Class mode - manual services)");

View File

@ -0,0 +1,133 @@
/** @odoo-module */
// Immediate console log to verify file is loaded
console.log("==============================================");
console.log("[KDS] pos_kds.js FILE IS LOADING!");
console.log("==============================================");
import { Orderline } from "@point_of_sale/app/store/models";
import { PosStore } from "@point_of_sale/app/store/pos_store";
import { patch } from "@web/core/utils/patch";
console.log("[KDS] Imports successful");
// Patch Orderline model
patch(Orderline.prototype, {
setup(attr) {
super.setup(...arguments);
if (attr && attr.preparation_status) {
this.preparation_status = attr.preparation_status;
} else if (!this.preparation_status) {
this.preparation_status = 'waiting';
}
},
init_from_JSON(json) {
super.init_from_JSON(...arguments);
if (json.preparation_status) {
this.preparation_status = json.preparation_status;
}
},
export_as_JSON() {
const json = super.export_as_JSON(...arguments);
json.preparation_status = this.preparation_status;
return json;
},
set_preparation_status(status) {
this.preparation_status = status;
},
get_preparation_status() {
return this.preparation_status;
},
get_preparation_status_label() {
const labels = {
'waiting': 'Waiting',
'preparing': 'Preparing',
'ready': 'Ready',
'served': 'Served',
'cancelled': 'Cancelled'
};
return labels[this.preparation_status] || this.preparation_status;
},
can_be_merged_with(line) {
// Prevent merging if the existing line is already being prepared or served
if (this.preparation_status && this.preparation_status !== 'waiting') {
return false;
}
return super.can_be_merged_with(...arguments);
}
});
console.log("[KDS] Orderline patched successfully");
// Patch PosStore
patch(PosStore.prototype, {
async _processData(loadedData) {
console.log("[KDS] _processData called!");
await super._processData(...arguments);
const channel = `pos_config_Channel_${this.config.id}`;
console.log(`[KDS] Setting up channel: ${channel}`);
try {
const busService = this.env.services.bus_service;
busService.addChannel(channel);
console.log("[KDS] Channel added successfully");
busService.addEventListener("notification", (event) => {
console.log("[KDS] *** NOTIFICATION RECEIVED ***", event);
const notifications = event.detail || [];
for (const notif of notifications) {
if (notif.type === 'kds_update') {
console.log("[KDS] *** KDS UPDATE ***", notif.payload);
this._handleKdsUpdate(notif.payload);
}
}
});
console.log("[KDS] Listener registered successfully");
} catch (error) {
console.error("[KDS] ERROR:", error);
}
},
_handleKdsUpdate(payload) {
console.log("[KDS] Handling update:", payload);
try {
const { order_uid, product_id, status, status_label, line_id, order_id } = payload;
const orders = this.get_order_list();
let order = orders.find(o => o.server_id === order_id);
if (!order) {
order = orders.find(o => o.name === order_uid || o.pos_reference === order_uid);
}
if (order) {
let line = order.get_orderlines().find(l => l.server_id === line_id);
if (!line) {
line = order.get_orderlines().find(l => l.product.id === product_id);
}
if (line) {
line.set_preparation_status(status);
this.env.services.notification.add(
`${line.product.display_name} is ${status_label}`,
{ title: "Kitchen Update", type: "info" }
);
}
}
} catch (error) {
console.error("[KDS] Error in handler:", error);
}
}
});
console.log("[KDS] PosStore patched successfully");
console.log("[KDS] MODULE FULLY LOADED!");

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="dine360_kds.Orderline" t-inherit="point_of_sale.Orderline" t-inherit-mode="extension" owl="1">
<xpath expr="//ul[hasclass('info-list')]" position="inside">
<li class="info" t-if="props.line.get_preparation_status() and props.line.get_preparation_status() !== 'waiting'">
<span t-attf-class="badge badge-{{props.line.get_preparation_status()}}">
<t t-esc="props.line.get_preparation_status_label()"/>
</span>
</li>
</xpath>
</t>
</templates>

View File

@ -0,0 +1,14 @@
<odoo>
<menuitem id="menu_kds_root"
name="Kitchen (KDS)"
web_icon="dine360_kds,static/description/icon.png"
groups="base.group_user"
sequence="20"/>
<menuitem id="menu_kds_orders"
name="Preparation Orders"
parent="menu_kds_root"
action="action_kds_dashboard"
groups="base.group_user"
sequence="10"/>
</odoo>

View File

@ -0,0 +1,150 @@
<odoo>
<!-- KDS Kanban View -->
<record id="view_pos_order_line_kds_kanban" model="ir.ui.view">
<field name="name">pos.order.line.kds.kanban</field>
<field name="model">pos.order.line</field>
<field name="arch" type="xml">
<kanban js_class="kds_kanban" default_group_by="preparation_status" create="false" class="o_kanban_small_column o_kanban_project_tasks">
<field name="preparation_status"/>
<field name="color"/>
<field name="product_id"/>
<field name="qty"/>
<field name="order_id"/>
<field name="table_id"/>
<field name="floor_id"/>
<field name="order_source"/>
<field name="fulfilment_type"/>
<field name="customer_note"/>
<field name="create_date"/>
<templates>
<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 class="oe_kanban_content p-3">
<div class="o_kanban_record_top mb-2">
<div class="o_kanban_record_headings">
<strong class="o_kanban_record_title" style="font-size: 1.2rem; color: #171422;">
<field name="qty"/> x <field name="product_id"/>
</strong>
</div>
<div class="ms-auto h5 mb-0">
<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>
</div>
<t t-if="record.customer_note.raw_value">
<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" title="Note"/> <strong>Note:</strong> <field name="customer_note"/>
</div>
</t>
<div class="o_kanban_record_body small text-muted mb-3">
<div class="d-flex justify-content-between align-items-center mb-1">
<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>
</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 class="o_kanban_record_bottom border-top pt-3 mt-2">
<div class="oe_kanban_bottom_left">
<field name="preparation_status" widget="badge" decoration-warning="preparation_status == 'waiting'" decoration-info="preparation_status == 'preparing'" decoration-success="preparation_status == 'ready'" decoration-muted="preparation_status == 'served'"/>
</div>
<div class="oe_kanban_bottom_right">
<button t-if="record.preparation_status.raw_value == 'waiting'"
name="action_start_preparing" type="object"
class="btn btn-sm btn-primary px-3 shadow-sm" style="background: #171422; border: none; border-radius: 8px;">
Start Cooking
</button>
<button t-if="record.preparation_status.raw_value == 'preparing'"
name="action_mark_ready" type="object"
class="btn btn-sm btn-success px-3 shadow-sm" style="background: #28a745; border: none; border-radius: 8px;">
Mark Ready
</button>
<button t-if="record.preparation_status.raw_value == 'ready' and record.customer_note.raw_value != 'Web Order'"
name="action_mark_served" type="object"
class="btn btn-sm btn-outline-dark px-3 shadow-sm" style="border-radius: 8px;">
Served
</button>
</div>
</div>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<!-- KDS Tree View -->
<record id="view_pos_order_line_kds_tree" model="ir.ui.view">
<field name="name">pos.order.line.kds.tree</field>
<field name="model">pos.order.line</field>
<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'">
<field name="order_id"/>
<field name="order_source" widget="badge"/>
<field name="fulfilment_type" widget="badge"/>
<field name="floor_id"/>
<field name="table_id"/>
<field name="product_id"/>
<field name="qty"/>
<field name="customer_note" string="Notes"/>
<field name="preparation_status" widget="badge" decoration-info="preparation_status == 'preparing'" decoration-success="preparation_status == 'ready'" decoration-warning="preparation_status == 'waiting'"/>
<field name="create_date" string="Ordered At" widget="remaining_days"/>
</tree>
</field>
</record>
<!-- KDS Search View -->
<record id="view_pos_order_line_kds_search" model="ir.ui.view">
<field name="name">pos.order.line.kds.search</field>
<field name="model">pos.order.line</field>
<field name="arch" type="xml">
<search string="Kitchen Orders Search">
<field name="order_id"/>
<field name="product_id"/>
<field name="table_id"/>
<field name="floor_id"/>
<separator/>
<filter string="Waiting" name="waiting" domain="[('preparation_status', '=', 'waiting')]"/>
<filter string="Preparing" name="preparing" domain="[('preparation_status', '=', 'preparing')]"/>
<filter string="Ready" name="ready" domain="[('preparation_status', '=', 'ready')]"/>
<filter string="In Progress" name="in_progress" domain="[('preparation_status', 'in', ['waiting', 'preparing'])]"/>
<separator/>
<filter string="Today" name="today" domain="[('create_date', '>=', context_today().strftime('%Y-%m-%d 00:00:00'))]"/>
<group expand="0" string="Group By">
<filter string="Status" name="group_status" context="{'group_by': 'preparation_status'}"/>
<filter string="Table" name="group_table" context="{'group_by': 'table_id'}"/>
<filter string="Floor" name="group_floor" context="{'group_by': 'floor_id'}"/>
</group>
</search>
</field>
</record>
<!-- Window Action -->
<record id="action_kds_dashboard" model="ir.actions.act_window">
<field name="name">Kitchen Display System</field>
<field name="res_model">pos.order.line</field>
<field name="view_mode">kanban,tree,form</field>
<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')]</field>
<field name="search_view_id" ref="view_pos_order_line_kds_search"/>
<field name="context">{'search_default_today': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Welcome to the Kitchen!
</p>
<p>
Orders sent from the POS will appear here for preparation.
</p>
</field>
</record>
</odoo>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="product_template_form_view_kds" model="ir.ui.view">
<field name="name">product.template.form.kds</field>
<field name="model">product.template</field>
<field name="inherit_id" ref="product.product_template_only_form_view"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='categ_id']" position="after">
<field name="is_kitchen_item"/>
</xpath>
</field>
</record>
</odoo>

View File

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

View File

@ -0,0 +1,32 @@
{
'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', 'dine360_order_channels'],
'data': [
'security/ir.model.access.csv',
'views/pos_order_views.xml',
'views/kds_override_views.xml',
'views/pos_config_views.xml',
'views/website_sale_templates.xml',
'views/backend_online_orders.xml',
],
'assets': {
'web.assets_frontend': [
'dine360_online_orders/static/src/css/service_mode.css',
'dine360_online_orders/static/src/js/service_mode.js',
],
},
'installable': True,
'application': False,
'license': 'LGPL-3',
}

View File

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

View File

@ -0,0 +1,65 @@
from odoo import http
from odoo.http import request
from odoo.addons.website_sale.controllers.main import WebsiteSale
class Dine360OnlineOrders(http.Controller):
@http.route('/shop/update_service_mode', type='json', auth="public", website=True)
def update_service_mode(self, service_mode, **post):
order = request.website.sale_get_order()
if order and service_mode in ['pickup', 'delivery', 'dine_in']:
order.sudo().write({
'fulfilment_type': service_mode,
'order_source': 'online'
})
return True
class Dine360WebsiteSaleOnline(WebsiteSale):
@http.route(['/shop/payment'], type='http', auth="public", website=True, sitemap=False)
def shop_payment(self, **post):
order = request.website.sale_get_order()
if order and not order.carrier_id:
# Bypass delivery validation by assigning the first available delivery carrier
carriers = order._get_delivery_methods()
if not carriers:
carriers = request.env['delivery.carrier'].sudo().search([('is_published', '=', True)], limit=1)
if carriers:
order.carrier_id = carriers[0].id
price = carriers[0].rate_shipment(order)['price'] if hasattr(carriers[0], 'rate_shipment') and order._get_delivery_methods() else 0.0
order.set_delivery_line(carriers[0], price)
return super(Dine360WebsiteSaleOnline, self).shop_payment(**post)
@http.route('/shop/payment/transaction/<int:order_id>', type='json', auth="public", website=True)
def shop_payment_transaction(self, order_id, access_token=None, **kwargs):
# Force bypass "No shipping method is selected" error during payment processing
order = request.env['sale.order'].sudo().browse(order_id)
if order and not order.carrier_id:
carriers = request.env['delivery.carrier'].sudo().search([('is_published', '=', True)], limit=1)
if carriers:
order.carrier_id = carriers[0].id
order.set_delivery_line(carriers[0], 0.0)
return super(Dine360WebsiteSaleOnline, self).shop_payment_transaction(order_id, access_token=access_token, **kwargs)
@http.route(['/shop/address'], type='http', methods=['GET', 'POST'], auth="public", website=True, sitemap=False)
def address(self, **post):
# Override to inject dummy address values for pickup orders before Odoo validates them
if post.get('submitted') and post.get('fulfilment_type') == 'pickup':
if not post.get('street'): post['street'] = 'Store Pickup'
if not post.get('city'): post['city'] = 'Local'
if not post.get('zip'): post['zip'] = '00000'
if not post.get('country_id'):
country = request.env['res.country'].sudo().search([], limit=1)
post['country_id'] = str(country.id) if country else ''
# Request.params must also be updated for Odoo 17 validation
for k, v in post.items():
if k in ['street', 'city', 'zip', 'country_id']:
if hasattr(request.params, 'update'):
try:
request.params[k] = v
except TypeError:
pass
return super(Dine360WebsiteSaleOnline, self).address(**post)

View File

@ -0,0 +1,5 @@
from . import pos_order
from . import sale_order
from . import pos_config
from . import res_config_settings
from . import pos_order_line

View File

@ -0,0 +1,10 @@
from odoo import models, fields
class PosConfig(models.Model):
_inherit = 'pos.config'
is_kiosk = fields.Boolean(string='Is Self-Order Kiosk', default=False)
kiosk_service_mode = fields.Selection([
('pickup', 'Pickup'),
('dine_in', 'Dine-In')
], string='Default Kiosk Service Mode', default='dine_in')

View File

@ -0,0 +1,206 @@
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 Kitchen'),
('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
)
# delivery_time = fields.Datetime(string='Requested Delivery Time')
# Note: order_source and fulfilment_type fields are defined in dine360_order_channels
# dine360_online_orders just uses these fields
@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 and marks as paid if already paid online"""
self.ensure_one()
self.write({'online_order_status': 'confirmed'})
# If it's an online order with an online payment option or Stripe txn, mark as paid in POS to avoid confusion
has_paid_transaction = False
if self.sale_order_id:
has_paid_transaction = any(t.state in ['authorized', 'done'] for t in self.sale_order_id.transaction_ids)
if self.is_online_order and self.sale_order_id and (self.sale_order_id.payment_option == 'online_gateway' or has_paid_transaction):
# Check if it needs payment (not yet paid in POS)
if self.state == 'draft' and self.amount_total > 0 and self.amount_paid < self.amount_total:
# Find a suitable payment method (Online Payment or Stripe)
# We prioritize methods linked to the current POS config
payment_method = self._get_online_payment_method()
if payment_method:
_logger.info("Automatically adding online payment from Stripe gateway for order %s using method %s", self.name, payment_method.name)
# Use add_payment if it exists, otherwise manual creation
payment_data = {
'amount': self.amount_total,
'payment_date': fields.Datetime.now(),
'payment_method_id': payment_method.id,
'pos_order_id': self.id,
}
if hasattr(self, 'add_payment'):
self.add_payment(payment_data)
else:
self.env['pos.payment'].create(payment_data)
# Force recomputation of amount_paid
self.env.flush_all()
self.invalidate_recordset(['payment_ids', 'amount_paid'])
# Instead of relying strictly on action_pos_order_paid which throws UserError on cache lag,
# we force the state logic directly if we just paid the exact full amount.
self.write({'state': 'paid'})
try:
self._create_order_picking()
except AttributeError:
_logger.warning("No _create_order_picking method found on POS order")
except Exception as e:
_logger.error("Error creating picking for online POS order: %s", str(e))
else:
_logger.warning("Could not find a suitable POS Payment Method for online order %s", self.name)
# 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_create_multi
def create(self, vals_list):
for vals in vals_list:
if 'session_id' in vals:
session = self.env['pos.session'].browse(vals['session_id'])
if session.config_id.is_kiosk:
vals['order_source'] = 'kiosk'
vals['fulfilment_type'] = session.config_id.kiosk_service_mode or 'pickup'
return super().create(vals_list)
def _get_online_payment_method(self):
"""Find a suitable POS payment method for online/stripe payments"""
# 1. Look for methods in the current config first
if self.config_id:
for method in self.config_id.payment_method_ids:
if 'online' in method.name.lower() or 'stripe' in method.name.lower():
return method
# Fallback to any non-cash method in config
for method in self.config_id.payment_method_ids:
if not method.is_cash_count:
return method
# 2. Global search if config search fails
method = self.env['pos.payment.method'].search([
('name', 'ilike', 'Online'),
], limit=1)
if not method:
method = self.env['pos.payment.method'].search([
('name', 'ilike', 'Stripe'),
], limit=1)
if not method:
method = self.env['pos.payment.method'].search([
('is_cash_count', '=', False)
], limit=1)
return method
@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 '',
'service_mode': order.fulfilment_type,
'order_source': order.order_source,
'note': order.note or '',
'lines': lines,
})
return result

View File

@ -0,0 +1,5 @@
from odoo import models
class PosOrderLine(models.Model):
_inherit = 'pos.order.line'
# Related fields order_source and fulfilment_type are now provided by dine360_order_channels

View File

@ -0,0 +1,7 @@
from odoo import models, fields
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
is_kiosk = fields.Boolean(related='pos_config_id.is_kiosk', readonly=False)
kiosk_service_mode = fields.Selection(related='pos_config_id.kiosk_service_mode', readonly=False)

View File

@ -0,0 +1,247 @@
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'
)
# order_source is now canonical field from dine360_order_channels (pos.order)
# We add it to sale.order for tracking which channel the web sale originated from
order_source = fields.Selection([
('online', 'Online'),
('phone', 'Phone'),
('whatsapp', 'WhatsApp'),
('social_media', 'Social Media'),
('in_person', 'In-Person (Walk-in/Dine-in)'),
('kiosk', 'Store Self-Order (Kiosk)'),
('party_order', 'Party Order'),
('platform_integration', 'Platform Integration (3rd Party)'),
], string='Order Source', default='online', tracking=True)
fulfilment_type = fields.Selection([
('pickup', 'Pickup'),
('delivery', 'Delivery'),
('dine_in', 'Dine-In'),
('walk_in', 'Walk-In'),
], string='Fulfillment Type', default='pickup', tracking=True)
def _has_deliverable_products(self):
""" Bypass Odoo's native delivery carrier validation for store pickup orders """
res = super(SaleOrderOnline, self)._has_deliverable_products()
if self.fulfilment_type == 'pickup':
return False
return res
online_order_status = fields.Selection([
('pending', 'Pending Kitchen'),
('confirmed', 'Confirmed'),
('rejected', 'Rejected'),
], string='Online Order Status', compute='_compute_online_order_status', store=True)
is_online_order_accepted_manually = fields.Boolean(string='Manually Accepted', default=False)
@api.depends('pos_order_id.online_order_status', 'state', 'order_source', 'is_online_order_accepted_manually')
def _compute_online_order_status(self):
for order in self:
if order.pos_order_id:
order.online_order_status = order.pos_order_id.online_order_status
elif order.state == 'cancel':
order.online_order_status = 'rejected'
elif order.order_source == 'online' and order.state in ['draft', 'sent', 'sale']:
if order.is_online_order_accepted_manually:
order.online_order_status = 'confirmed'
else:
order.online_order_status = 'pending'
else:
order.online_order_status = False
def action_accept_online_order(self):
for order in self:
if order.state in ['draft', 'sent']:
order.action_confirm()
if not order.pos_order_id and order.state == 'sale':
try:
if hasattr(order, '_create_pos_order_for_kds'):
order._create_pos_order_for_kds(order)
except Exception as e:
_logger.error("Failed to create POS order during accept: %s", str(e))
if order.pos_order_id:
order.pos_order_id.action_confirm_online_order()
else:
order.is_online_order_accepted_manually = True
order._compute_online_order_status()
return {'type': 'ir.actions.client', 'tag': 'reload'}
def action_reject_online_order(self):
for order in self:
if order.state in ['draft', 'sent', 'sale']:
order._action_cancel()
if order.pos_order_id:
order.pos_order_id.action_reject_online_order()
order._compute_online_order_status()
return {'type': 'ir.actions.client', 'tag': 'reload'}
def _check_cart_is_ready_to_be_paid(self):
try:
return super(SaleOrderOnline, self)._check_cart_is_ready_to_be_paid()
except Exception as e:
if self.fulfilment_type == 'pickup' and type(e).__name__ == 'ValidationError':
err_str = str(e).lower()
if 'shipping method' in err_str or 'delivery' in err_str:
return True
raise e
payment_option = fields.Selection([
('in_store', 'In Store'),
('terminal_in_store', 'Payment Terminal (In Store)'),
('terminal_customer', 'Payment Terminal (Customer Place)'),
('online_gateway', 'Online Payment Gateway'),
('cash', 'Cash'),
('interac', 'Interac'),
], string='Payment Option', tracking=True)
# delivery_time = fields.Datetime(string='Requested Delivery Time', tracking=True)
telephone_number = fields.Char('Telephone Number')
reservation_source = fields.Selection([
('online', 'Online'),
('phone', 'Phone'),
('staff', 'Staff'),
], string='Reservation Source', tracking=True)
reservation_status = fields.Selection([
('draft', 'Request Received'),
('confirmed', 'Confirmed'),
('arrived', 'Arrived'),
('seated', 'Seated'),
('cancelled', 'Cancelled'),
], string='Reservation Status', default='draft', tracking=True)
def _is_shippable_order(self):
"""
Treat pickup, delivery and other types as non-shippable for Odoo's standard validation.
This enables 'Billing-only' checkout which is more reliable for payment providers.
The Uber delivery line is protected by our _remove_delivery_line override.
"""
self.ensure_one()
if self.fulfilment_type in ['pickup', 'delivery', 'dine_in', 'walk_in']:
return False
return super()._is_shippable_order()
def _check_carrier_quotation(self, force_carrier_id=None, **kwargs):
"""Allow proceeding to payment if we already have a carrier (Uber) or don't need one"""
self.ensure_one()
_logger.info("Checking carrier quotation for order %s (fulfilment: %s, carrier: %s)", self.name, self.fulfilment_type, self.carrier_id.name if self.carrier_id else 'None')
if self.fulfilment_type in ['pickup', 'delivery', 'dine_in', 'walk_in']:
return True
# If we have a carrier set by our Uber integration, trust it and skip standard re-validation
if self.carrier_id and 'Uber' in (self.carrier_id.name or ''):
return True
return super()._check_carrier_quotation(force_carrier_id=force_carrier_id, **kwargs)
def _remove_delivery_line(self):
"""
Prevent Odoo from removing the delivery line if its an Uber order.
Odoo often tries to clean up delivery lines on page transitions if it
thinks the shipping method is no longer valid.
"""
self.ensure_one()
if self.carrier_id and 'Uber' in (self.carrier_id.name or ''):
_logger.info("Protecting Uber delivery line from removal on order %s", self.name)
return True
return super()._remove_delivery_line()
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(),
'order_source': sale_order.order_source or 'online',
'fulfilment_type': sale_order.fulfilment_type or 'pickup',
# 'delivery_time': sale_order.delivery_time,
# 'uber_eta': sale_order.delivery_time,
})
# Link back to sale order
sale_order.write({'pos_order_id': pos_order.id})
# Check if paid via gateway (custom field) or standard Odoo transaction (Stripe, etc.)
has_paid_transaction = any(t.state in ['authorized', 'done'] for t in sale_order.transaction_ids)
if (sale_order.payment_option == 'online_gateway' or has_paid_transaction) and sale_order.amount_total > 0:
payment_method = pos_order._get_online_payment_method()
if payment_method:
_logger.info("Recording online payment for POS order %s from Sale Order %s", pos_order.name, sale_order.name)
payment_data = {
'amount': sale_order.amount_total,
'payment_date': fields.Datetime.now(),
'payment_method_id': payment_method.id,
'pos_order_id': pos_order.id,
}
if hasattr(pos_order, 'add_payment'):
pos_order.add_payment(payment_data)
else:
pos_order.env['pos.payment'].create(payment_data)
pos_order.env.flush_all()
pos_order.invalidate_recordset(['payment_ids', 'amount_paid'])
# Process as paid so the state changes and payment button disappears safely
pos_order.write({'state': 'paid'})
try:
pos_order._create_order_picking()
except AttributeError:
_logger.warning("No _create_order_picking method found on POS order")
except Exception as e:
_logger.error("Error creating picking for online POS order: %s", str(e))
# 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: #171422;
color: #eee;
font-family: 'Inter', 'Segoe UI', sans-serif;
}
/* Header */
.online-orders-header {
background: #171422;
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: #171422;
}
/* 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(214, 17, 30, 0.1);
border-color: #d6111e;
box-shadow: 0 0 15px rgba(214, 17, 30, 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, #d6111e, #1a1d23);
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(214, 17, 30, 0.2);
color: #d6111e;
}
/* 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(214, 17, 30, 0.15);
border: 1px solid rgba(214, 17, 30, 0.3);
color: #d6111e;
border-radius: 10px;
padding: 6px 14px;
font-weight: 600;
font-size: 0.85rem;
transition: all 0.25s ease;
}
.online-orders-nav-btn:hover {
background: #d6111e;
color: #fff;
border-color: #d6111e;
}
/* 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: #d6111e !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,64 @@
/* Update Service Mode Selector Styles */
#service_mode_selector {
background-color: #ffffff;
border-radius: 16px !important;
transition: all 0.3s ease;
}
.service-option input[type="radio"]:checked+.service-card {
border-color: #FECD4F !important;
background-color: #FFFDF6 !important;
box-shadow: 0 4px 15px rgba(254, 205, 79, 0.25) !important;
transform: translateY(-2px);
}
.service-card {
border: 2px solid #e9ecef !important;
cursor: pointer;
transition: all 0.2s ease-in-out;
}
.service-card:hover {
border-color: #FECD4F;
background-color: #FFFDF6;
}
.service-card i {
font-size: 2rem;
display: block;
margin-bottom: 8px;
}
.service-card h6 {
margin-bottom: 4px;
font-weight: 700;
}
/* Animation for the selector if skipped */
.shake-animation {
animation: shake 0.5s cubic-bezier(.36, .07, .19, .97) both;
}
@keyframes shake {
10%,
90% {
transform: translate3d(-1px, 0, 0);
}
20%,
80% {
transform: translate3d(2px, 0, 0);
}
30%,
50%,
70% {
transform: translate3d(-4px, 0, 0);
}
40%,
60% {
transform: translate3d(4px, 0, 0);
}
}

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,177 @@
/** @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,
error: 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;
this.state.error = null;
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;
this.state.error = "Failed to load orders. Please check your connection.";
}
}
selectOrder(order) {
this.state.selectedOrder = order;
}
async confirmOrder(orderId) {
try {
this.state.confirmingId = orderId;
this.state.error = null;
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.error = "Failed to confirm order. It might have been modified.";
this.state.confirmingId = null;
}
}
async rejectOrder(orderId) {
try {
this.state.error = null;
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");
this.state.error = "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,62 @@
/** @odoo-module **/
import publicWidget from "@web/legacy/js/public/public_widget";
import { jsonrpc } from "@web/core/network/rpc_service";
publicWidget.registry.ServiceModeSelector = publicWidget.Widget.extend({
selector: '#service_mode_selector',
events: {
'change input[name="fulfilment_type"]': '_onChangeServiceMode',
},
start: function () {
// Init visual selection
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)' });
return this._super.apply(this, arguments);
},
_onChangeServiceMode: function (ev) {
var $input = $(ev.currentTarget);
var mode = $input.val();
// Reset styles
this.$('.service-card').css({ 'border-color': '', 'background-color': '', 'box-shadow': '' });
// Apply active styles
$input.closest('.service-option').find('.service-card')
.css({ 'border-color': '#FECD4F', 'background-color': '#fffdf6', 'box-shadow': '0 4px 10px rgba(254, 205, 79, 0.2)' });
// Hide error if present
this.$('#service_mode_error').addClass('d-none');
// RPC Call to update order
jsonrpc('/shop/update_service_mode', {
service_mode: mode
});
}
});
// Intercept checkout to ensure a service mode is selected
publicWidget.registry.CartCheckoutValidation = publicWidget.Widget.extend({
selector: '.oe_cart',
events: {
'click a[href="/shop/checkout"]': '_onCheckoutClicked',
},
_onCheckoutClicked: function (ev) {
// If there's a selector on the page
if (this.$('#service_mode_selector').length > 0) {
var selectedMode = this.$('input[name="fulfilment_type"]:checked').val();
if (!selectedMode) {
ev.preventDefault();
this.$('#service_mode_error').removeClass('d-none');
// Highlight the box
this.$('#service_mode_selector').css('border', '1px solid #dc3545').addClass('shake-animation');
setTimeout(() => {
this.$('#service_mode_selector').removeClass('shake-animation');
}, 500);
}
}
}
});

View File

@ -0,0 +1,261 @@
<?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 position-relative">
<!-- Error Alert -->
<div t-if="state.error" class="position-absolute w-100 p-3" style="z-index: 1050; top: 0;">
<div class="alert alert-danger d-flex justify-content-between align-items-center m-0 shadow">
<div>
<i class="fa fa-exclamation-triangle me-2"/>
<span t-esc="state.error"/>
</div>
<button class="btn btn-sm btn-outline-danger border-0" t-on-click="() => this.state.error = null">
<i class="fa fa-times"/>
</button>
</div>
</div>
<!-- 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,66 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<!-- Tree View specifically for Online Orders -->
<record id="view_backend_online_order_tree" model="ir.ui.view">
<field name="name">sale.order.online.tree</field>
<field name="model">sale.order</field>
<field name="arch" type="xml">
<tree string="Online Orders" decoration-info="online_order_status == 'pending'" decoration-success="online_order_status == 'confirmed'" decoration-muted="online_order_status == 'rejected'" create="false">
<field name="name" string="Order Number"/>
<field name="date_order" string="Order Date"/>
<field name="partner_id" string="Customer"/>
<field name="amount_total" string="Total"/>
<field name="online_order_status" string="Status" widget="badge" decoration-info="online_order_status == 'pending'" decoration-success="online_order_status == 'confirmed'" decoration-danger="online_order_status == 'rejected'"/>
<button name="action_accept_online_order" string="Accept" type="object" class="btn-success" invisible="online_order_status != 'pending'"/>
<button name="action_reject_online_order" string="Reject" type="object" class="btn-danger" invisible="online_order_status != 'pending'"/>
<field name="state" column_invisible="1"/>
</tree>
</field>
</record>
<!-- Form View override for Online Orders buttons -->
<record id="view_order_form_online_orders" model="ir.ui.view">
<field name="name">sale.order.form.online</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<xpath expr="//header" position="inside">
<field name="order_source" invisible="1"/>
<button name="action_accept_online_order" string="Accept Online Order" type="object" class="btn-success" invisible="order_source != 'online' or online_order_status != 'pending'"/>
<button name="action_reject_online_order" string="Reject Online Order" type="object" class="btn-danger" invisible="order_source != 'online' or online_order_status != 'pending'"/>
</xpath>
<xpath expr="//field[@name='payment_term_id']" position="after">
<field name="online_order_status" invisible="order_source != 'online'" readonly="1" widget="badge" decoration-info="online_order_status == 'pending'" decoration-success="online_order_status == 'confirmed'" decoration-danger="online_order_status == 'rejected'"/>
</xpath>
</field>
</record>
<!-- Action to open Online Orders (Sale Orders from website) -->
<record id="action_backend_online_orders" model="ir.actions.act_window">
<field name="name">Online Orders</field>
<field name="res_model">sale.order</field>
<field name="view_mode">tree,kanban,form,calendar,pivot,graph,activity</field>
<field name="view_id" ref="view_backend_online_order_tree"/>
<field name="domain">[('order_source', '=', 'online')]</field>
<field name="context">{'default_order_source': 'online'}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No online orders found.
</p>
<p>
Orders placed on the website will appear here.
</p>
</field>
</record>
<!-- Menu Item at the Root Level -->
<menuitem id="menu_backend_online_orders_root"
name="Online Orders"
web_icon="dine360_dashboard,static/src/img/icons/website.svg"
sequence="20"
action="action_backend_online_orders"/>
</data>
</odoo>

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,22 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form_inherit_dine360" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.dine360</field>
<field name="model">res.config.settings</field>
<field name="priority" eval="95"/>
<field name="inherit_id" ref="point_of_sale.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//block[@id='pos_interface_section']" position="inside">
<setting string="Self-Order Kiosk" help="Enable this POS as a self-order kiosk">
<field name="is_kiosk"/>
<div class="content-group" invisible="not is_kiosk">
<div class="mt16">
<label string="Default Service Mode" for="kiosk_service_mode" class="col-lg-3 o_light_label"/>
<field name="kiosk_service_mode"/>
</div>
</div>
</setting>
</xpath>
</field>
</record>
</odoo>

View File

@ -0,0 +1,75 @@
<?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="order_source"/>
<field name="fulfilment_type" widget="badge"
decoration-info="fulfilment_type=='pickup'"
decoration-primary="fulfilment_type=='delivery'"
decoration-muted="fulfilment_type=='dine_in'"/>
<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"/>
<field name="order_source" string="Source"/>
<field name="fulfilment_type" string="Service"/>
</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,4 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Removed cart_service_mode template as requested by user to move it to checkout page -->
</odoo>

View File

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

View 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', 'dine360_restaurant'],
'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',
}

View File

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

View 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 '',
}

View File

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

View 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'
)

View File

@ -0,0 +1,147 @@
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 Media: reference
social_ref = fields.Char('Social Ref', help='Instagram/Facebook message/post reference')
# Telephone: customer phone for phone orders
telephone_number = fields.Char('Telephone Number', help='Customer phone number for telephone orders')
# -----------------------------------------------------------
# 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', '')
fields_dict['telephone_number'] = ui_order.get('telephone_number', '')
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 ''
result['telephone_number'] = order.telephone_number 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
)

View File

@ -0,0 +1 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink

View File

@ -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;
}

View File

@ -0,0 +1,129 @@
/** @odoo-module */
import { Component, useState } from "@odoo/owl";
import { usePos } from "@point_of_sale/app/store/pos_hook";
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.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,
showDetails: false, // For manual fields toggle
isCollapsed: false, // Panel collapse state
searchQuery: '',
searchResults: [],
searching: false,
});
}
toggleDetails() {
this.state.showDetails = !this.state.showDetails;
}
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 !== false;
}
// --- 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);
}
}

View File

@ -0,0 +1,107 @@
/** @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 = '';
this.telephone_number = '';
},
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;
json.telephone_number = this.telephone_number;
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 || '';
this.telephone_number = json.telephone_number || '';
},
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;
result.telephone_number = this.telephone_number;
return result;
},
});

Some files were not shown because too many files have changed in this diff Show More