Compare commits

...

31 Commits
main ... main

Author SHA1 Message Date
Alaguraj0361
be1c9bd35e Introduce comprehensive website sale views and templates, enhancing product visibility, adding product creation forms, and integrating dine360 order channels and online ordering functionalities. 2026-03-21 17:21:16 +05:30
Alaguraj0361
5531400dc3 add a service mode selector for pickup or delivery options to the website cart page. 2026-03-20 16:12:31 +05:30
Alaguraj0361
73ef8df4b7 Add comprehensive styling for the shop page, including the left sidebar filter and product grid layout. 2026-03-20 15:55:53 +05:30
Alaguraj0361
f9fb63f8c9 implement initial custom styling for the Chennora theme, including header, hero, section, and premium page components. 2026-03-20 13:15:20 +05:30
Alaguraj0361
9d916d4ac0 Add initial custom styles for the Chennora theme via new theme.scss and theme_variables.css files. 2026-03-20 12:59:49 +05:30
Alaguraj0361
b0861cae1e Add a service mode selector to the website cart page, allowing users to choose between pickup and delivery options. 2026-03-20 12:21:53 +05:30
Alaguraj0361
b9e5119dfa Add a service mode selector (pickup/delivery) to the website cart page. 2026-03-20 11:40:32 +05:30
Alaguraj0361
405dae06b5 add a service mode selector for pickup and delivery options to the website cart page. 2026-03-20 11:07:52 +05:30
Alaguraj0361
4e2df91c14 Add a service mode selector (pickup/delivery) to the website shopping cart page. 2026-03-18 10:32:59 +05:30
Alaguraj0361
8b248bee27 Add a service mode selector (pickup/delivery) to the website cart page with corresponding styling and module definition. 2026-03-17 21:18:10 +05:30
Alaguraj0361
832fb9f196 Customize checkout address forms by removing company/VAT fields, renaming zip to postal code, and streamlining shipping sections and wizard steps. 2026-03-17 20:43:18 +05:30
Alaguraj0361
7aafe0c6fb Introduce comprehensive order channel management, including online, self-order, and KDS integration, with detailed fulfilment and delivery options for POS orders and receipts. 2026-03-17 20:26:05 +05:30
Alaguraj0361
216c627369 Implement online order management with service mode selection, KDS integration, and dedicated POS order fields. 2026-03-17 14:56:47 +05:30
Alaguraj0361
d8db1f9334 Add a Dashboard button to the Odoo navigation bar. 2026-03-12 10:54:02 +05:30
Alaguraj0361
46249085cd Add dine360 dashboard module assets including icons, CSS, and main controller. 2026-03-12 10:34:26 +05:30
Alaguraj0361
f7c7359b6c customize Odoo checkout address templates to remove company/VAT fields, rename zip to postal code, and streamline shipping details. 2026-03-10 11:51:05 +05:30
Alaguraj0361
8ddde09c63 Streamline checkout address forms by removing company/VAT, renaming zip to postal code, hiding shipping options, and simplifying shipping steps. 2026-03-10 10:33:03 +05:30
Alaguraj0361
c744485423 Customize checkout and address forms by removing company/VAT fields, renaming zip to postal code, simplifying address titles, hiding shipping options, and renaming the shipping wizard step. 2026-03-09 17:23:22 +05:30
Alaguraj0361
7d20d000f3 add Chennora theme module with shop page UI and checkout address view. 2026-03-09 12:03:39 +05:30
Alaguraj0361
5caf51ecf4 Implement new website layout with custom header and SEO, and add login templates. 2026-03-09 09:25:08 +05:30
Alaguraj0361
daa3fbd056 Introduce Dine360 KDS module and add dashboard button to POS navbar, while removing the category synchronization script. 2026-03-06 22:20:45 +05:30
Alaguraj0361
26c9e252cc add script to synchronize POS categories to public categories for products lacking them. 2026-03-06 22:07:58 +05:30
Alaguraj0361
8ea9a66022 Implement Chennora theme, add dashboard navigation buttons to web and POS, and introduce comprehensive shop page styling. 2026-03-06 21:34:46 +05:30
Alaguraj0361
d58a1fd30f Implement online order management with KDS integration and a custom POS navbar. 2026-03-06 18:00:13 +05:30
Alaguraj0361
015f703026 Customize the web layout by setting the page title to "Chennora", updating the favicon, and adding a "Back to Dashboard" button. 2026-03-04 19:53:25 +05:30
Alaguraj0361
c8ed83248b Implement a custom dashboard for logged-in users with role-based menu filtering, low stock alerts, and branded UI elements. 2026-03-04 19:34:08 +05:30
Alaguraj0361
75292e7b88 Introduce a custom dashboard with role-based menu filtering, low stock alerts, and branded web layout including title, favicon, and a dashboard return button. 2026-03-04 17:44:12 +05:30
Alaguraj0361
efa9b1e14a Updated .gitignore to exclude virtual environments, testing artifacts, temporary files, and database dumps. 2026-03-04 17:32:22 +05:30
55e6b70134 merge upstream 2026-03-04 08:29:52 +00:00
b69bf9bc1c Update README.md 2026-03-03 17:35:24 +00:00
db94d57198 Update README.md 2026-03-03 17:34:14 +00:00
132 changed files with 11618 additions and 88 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.

34
.gitignore vendored
View File

@ -5,6 +5,8 @@ __pycache__/
*.so
.Python
env/
venv/
.venv/
build/
develop-eggs/
dist/
@ -20,6 +22,15 @@ wheels/
*.egg-info/
.installed.cfg
*.egg
.pytest_cache/
.tox/
.nox/
.coverage
htmlcov/
nosetests.xml
coverage.xml
*.cover
*.log
# Odoo artifacts
*.log
@ -27,19 +38,26 @@ odoo.conf
/data/
/filestore/
/sessions/
/logs/
/dump/
# OS artifacts
.DS_Store
Thumbs.db
ehthumbs.db
Desktop.ini
*.tmp
*.bak
*.swp
*.swo
*~
# IDEs
.idea/
.vscode/
*.swp
*.swo
*~
.history/
*.sublime-project
*.sublime-workspace
# Debug/Temp scripts found in project
inspect_*.py
@ -53,10 +71,10 @@ txt.py
update_error.txt
update_log.txt
addons/*.png
blog_posts_*.json
# Local config/environment
.env
.venv
docker-compose.override.yml
# Log / debug dump files
@ -66,7 +84,13 @@ docker-compose.override.yml
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
blog_posts_*.json
test_*.py
temp_*.py

View File

@ -1,4 +1,4 @@
# Dine360 Odoo Addons
# 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.
@ -12,10 +12,10 @@ This repository contains custom Odoo 17 addons for the Dine360 Restaurant Suite.
- 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/` 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
@ -110,4 +110,4 @@ Helper scripts for view diagnostics and homepage issues:
## Notes
- Homepage content is fully overridden in `addons/dine360_theme_chennora/views/pages.xml`.
- If theme changes dont appear, check for COW (customized) views masking the theme.
- If theme changes dont appear, check for COW (customized) views masking the theme.

View File

@ -18,6 +18,7 @@
'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',

View File

@ -7,6 +7,7 @@ class CustomHome(Home):
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
@ -16,28 +17,37 @@ class ImageHome(Website):
@http.route('/', type='http', auth='public', website=True, sitemap=True)
def index(self, **kwargs):
# -----------------------------------------------------------
# WEBSITE EDITOR FIX
# When Odoo's Website editor loads the site, it opens it in an
# iframe. We must NOT intercept that request with our backend
# dashboard; instead let the real website homepage render so the
# editor can attach to it.
#
# Detection methods (any one is enough):
# 1. Sec-Fetch-Dest == 'iframe' → browser signals iframe load
# 2. enable_editor param present → explicit editor activation
# 3. ?debug= in query string → editor dev mode coming from /web
# SUPER SAFE EDITOR & IFRAME DETECTION
# -----------------------------------------------------------
fetch_dest = request.httprequest.headers.get('Sec-Fetch-Dest', '')
is_iframe = fetch_dest == 'iframe'
is_editor = kwargs.get('enable_editor') or request.params.get('enable_editor')
if is_iframe or is_editor:
# Let the standard Website controller render the homepage
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)
# Not logged in → show the public website homepage
if not request.session.uid:
return request.render('website.homepage')
# 2. Check for ANY editor or backend signal
# - Sec-Fetch-Dest: iframe (Chrome/Firefox standard)
# - Any of these common Odoo params:
editor_params = ['enable_editor', 'edit', 'path', 'website_id', 'frontend_edit', 'model', 'id']
is_editor_request = any(p in params for p in editor_params)
# - Referer contains backend markers
is_from_backend = any(m in referer for m in ['/web', '/website/force', 'enable_editor'])
# - Odoo often passes things in kwargs that are not in params
has_kwargs = len(kwargs) > 0
# if it looks like Odoo internal business, return the real website
if fetch_dest == 'iframe' or is_editor_request or is_from_backend or has_kwargs:
return super(ImageHome, self).index(**kwargs)
# 3. Final safety check: if we are not at exactly '/', don't intercept
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([
@ -70,6 +80,37 @@ class ImageHome(Website):
continue
seen_names.add(menu.name)
# 4. Dynamic Icon Override (Dine360 Branding)
# This maps menu names to our custom SVG icons dynamically
icon_mapping = {
'Discuss': 'dine360_dashboard,static/src/img/icons/discuss.svg',
'Calendar': 'dine360_dashboard,static/src/img/icons/calendar.svg',
'Contacts': 'dine360_dashboard,static/src/img/icons/contacts.svg',
'CRM': 'dine360_dashboard,static/src/img/icons/crm.svg',
'Sales': 'dine360_dashboard,static/src/img/icons/sales.svg',
'Dashboards': 'dine360_dashboard,static/src/img/icons/dashboards.svg',
'Point of Sale': 'dine360_dashboard,static/src/img/icons/point_of_sale.svg',
'Invoicing': 'dine360_dashboard,static/src/img/icons/invoicing.svg',
'Website': 'dine360_dashboard,static/src/img/icons/website.svg',
'Purchase': 'dine360_dashboard,static/src/img/icons/purchase.svg',
'Inventory': 'dine360_dashboard,static/src/img/icons/inventory.svg',
'Employees': 'dine360_dashboard,static/src/img/icons/employees.svg',
'Apps': 'dine360_dashboard,static/src/img/icons/apps.svg',
'Settings': 'dine360_dashboard,static/src/img/icons/settings.svg',
'Kitchen (KDS)': 'dine360_dashboard,static/src/img/icons/kitchen_kds.svg',
'Table Reservation': 'dine360_dashboard,static/src/img/icons/table_reservation.svg',
'Uber Integration': 'dine360_dashboard,static/src/img/icons/uber_integration.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():
# We use a virtual field assignment so it doesn't try to save to DB
# but the template picks it up
menu.web_icon = icon_path
break
filtered_menus.append(menu)
# Low Stock Alerts (Ingredients)

View File

@ -71,7 +71,7 @@ body.o_home_dashboard,
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.4) 0%, rgba(255, 255, 255, 0) 50%);
background: linear-gradient(135deg, rgb(175 36 36 / 62%) 0%, rgb(181 84 84 / 20%) 50%);
z-index: 1;
}

View File

@ -113,7 +113,7 @@
.oe_website_sale #products_grid_before li:hover,
.oe_website_sale #products_grid_before .list-group-item:hover {
background: #2bb1a5 !important;
background: #fecd4f !important;
transform: translateX(5px);
}

View File

@ -97,7 +97,7 @@ body,
.o_main_navbar .o_menu_sections .o_nav_entry,
.o_main_navbar .o_menu_systray .o_nav_entry {
color: white !important;
color: #111111 !important;
font-size: 14px !important;
font-weight: 600 !important;
}

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

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

@ -37,7 +37,7 @@
</div>
<div class="o_login_footer_custom">
<p>Powered by <a href="https://dine360.com/" target="_blank">Dine360</a></p>
<p>Powered by <a href="https://dine360.com/" target="_blank">Dine360 Inc</a></p>
</div>
</div>
</xpath>

View File

@ -8,9 +8,10 @@
</xpath>
<xpath expr="//body" position="inside">
<t t-if="not request.env.user._is_public()">
<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: 99999;
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;

View File

@ -12,7 +12,7 @@
- Floor/Table based organization
""",
'author': 'Dine360',
'depends': ['point_of_sale', 'pos_restaurant', 'dine360_restaurant', 'sale_management', 'website_sale'],
'depends': ['point_of_sale', 'pos_restaurant', 'dine360_restaurant', 'sale_management', 'website_sale', 'dine360_order_channels'],
'data': [
'security/ir.model.access.csv',
'views/pos_order_line_views.xml',
@ -24,7 +24,7 @@
'dine360_kds/static/src/css/kds_style.css',
'dine360_kds/static/src/js/kds_backend.js',
],
'point_of_sale.assets_prod': [
'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

View File

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

View File

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

View File

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

View File

@ -4,13 +4,14 @@ 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 useService potential conflicts
// Direct access to services to avoid 'methods is not iterable' error in Owl lifecycle
this.busService = this.env.services.bus_service;
this.notification = this.env.services.notification;
@ -28,8 +29,6 @@ export class KdsKanbanController extends KanbanController {
this.busService.removeEventListener("notification", handler);
}
});
} else {
console.error("[KDS Controller] Bus service not found!");
}
}

View File

@ -12,11 +12,13 @@
<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 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">
@ -25,23 +27,31 @@
</strong>
</div>
<div class="ms-auto h5 mb-0">
<span class="badge rounded-pill bg-light text-dark border">
<i class="fa fa-cutlery me-1"/> <field name="table_id"/>
<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" style="background: rgba(254, 205, 79, 0.1); border-radius: 8px;">
<i class="fa fa-sticky-note-o me-2"/> <strong>Note:</strong> <field name="customer_note"/>
<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">
<span><i class="fa fa-clock-o me-1"/> <field name="create_date"/></span>
<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">
@ -81,6 +91,8 @@
<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"/>

View File

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

View File

@ -0,0 +1,37 @@
{
'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',
],
'assets': {
'point_of_sale._assets_pos': [
'dine360_online_orders/static/src/css/online_orders.css',
'dine360_online_orders/static/src/js/online_orders_screen.js',
'dine360_online_orders/static/src/js/online_orders_navbar.js',
'dine360_online_orders/static/src/xml/online_orders_screen.xml',
],
'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,14 @@
from odoo import http
from odoo.http import request
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

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,134 @@
from odoo import models, fields, api, _
import logging
_logger = logging.getLogger(__name__)
class PosOrder(models.Model):
_inherit = 'pos.order'
is_online_order = fields.Boolean(
string='Online Order',
default=False,
help='Indicates this order came from the website shop'
)
online_order_status = fields.Selection([
('pending', 'Pending'),
('confirmed', 'Confirmed'),
('rejected', 'Rejected'),
], string='Online Order Status', default='pending')
sale_order_id = fields.Many2one(
'sale.order', string='Source Sale Order',
help='The website sale order that generated this POS order'
)
online_customer_name = fields.Char(
string='Online Customer',
compute='_compute_online_customer_name', store=True
)
online_order_date = fields.Datetime(
string='Online Order Date',
default=fields.Datetime.now
)
# 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"""
self.ensure_one()
self.write({'online_order_status': 'confirmed'})
# Set all order lines to 'waiting' so KDS picks them up
for line in self.lines:
if line.product_id.is_kitchen_item and line.product_id.type != 'service':
line.write({
'preparation_status': 'waiting',
})
# Notify KDS
self.lines.filtered(
lambda l: l.product_id.is_kitchen_item and l.product_id.type != 'service'
)._notify_kds()
# Notify POS that order was confirmed
if self.config_id:
channel = "online_orders_%s" % self.config_id.id
self.env['bus.bus']._sendone(channel, 'online_order_confirmed', {
'order_id': self.id,
'order_name': self.pos_reference or self.name,
})
_logger.info("Online order %s confirmed and sent to KDS", self.name)
return True
def action_reject_online_order(self):
"""Cashier rejects the online order"""
self.ensure_one()
self.write({'online_order_status': 'rejected'})
# Notify POS
if self.config_id:
channel = "online_orders_%s" % self.config_id.id
self.env['bus.bus']._sendone(channel, 'online_order_rejected', {
'order_id': self.id,
'order_name': self.pos_reference or self.name,
})
_logger.info("Online order %s rejected", self.name)
return True
@api.model_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)
@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,109 @@
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)
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)
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 _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',
})
# Link back to sale order
sale_order.write({'pos_order_id': pos_order.id})
# Set all lines to a "hold" state - they will go to KDS only when cashier confirms
for line in pos_order.lines:
if line.product_id.is_kitchen_item:
line.write({'preparation_status': 'waiting'})
# Send bus notification to POS
if pos_order.config_id:
channel = "online_orders_%s" % pos_order.config_id.id
self.env['bus.bus']._sendone(channel, 'new_online_order', {
'order_id': pos_order.id,
'order_name': pos_order.pos_reference or pos_order.name,
'customer_name': sale_order.partner_id.name or 'Guest',
'amount_total': pos_order.amount_total,
'items_count': len(pos_order.lines),
})
_logger.info(
"Marked POS Order %s as online order from Sale Order %s",
pos_order.name, sale_order.name
)

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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,41 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="cart_service_mode" inherit_id="website_sale.cart" name="Service Mode Selector" priority="1000">
<xpath expr="//div[hasclass('col')]/h3" position="before">
<div class="container container-fluid mt-4">
<div id="service_mode_selector" class="mb-4 bg-white p-3 p-md-4 rounded-4 shadow-sm border" style="border-left: 5px solid #FECD4F !important;">
<div class="d-flex align-items-center mb-3">
<div class="bg-warning-light p-2 rounded-circle me-3">
<i class="fa fa-shopping-basket text-warning fs-5"></i>
</div>
<h4 class="mb-0 fw-bold">How would you like your order?</h4>
</div>
<div class="d-flex flex-wrap gap-2 gap-md-3">
<label class="service-option position-relative flex-fill cursor-pointer m-0">
<input type="radio" name="fulfilment_type" value="pickup" class="d-none" t-att-checked="'checked' if website_sale_order and website_sale_order.fulfilment_type == 'pickup' else None"/>
<div class="service-card p-3 rounded-3 border text-center transition-all h-100 d-flex flex-column justify-content-center">
<i class="fa fa-shopping-bag mb-2 text-warning"></i>
<div class="fw-bold h6 mb-1">Pickup</div>
<div class="x-small text-muted">Ready in 15-20 mins</div>
</div>
</label>
<label class="service-option position-relative flex-fill cursor-pointer m-0">
<input type="radio" name="fulfilment_type" value="delivery" class="d-none" t-att-checked="'checked' if website_sale_order and website_sale_order.fulfilment_type == 'delivery' else None"/>
<div class="service-card p-3 rounded-3 border text-center transition-all h-100 d-flex flex-column justify-content-center">
<i class="fa fa-truck mb-2 text-primary"></i>
<div class="fw-bold h6 mb-1">Delivery</div>
<div class="x-small text-muted">Estimated 30-45 mins</div>
</div>
</label>
</div>
<div class="mt-3 small text-danger d-none" id="service_mode_error">
<i class="fa fa-exclamation-circle me-1"></i> Please select Pickup or Delivery to continue.
</div>
</div>
</div>
</xpath>
</template>
</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'],
'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,125 @@
/** @odoo-module */
import { Component, useState } from "@odoo/owl";
import { usePos } from "@point_of_sale/app/store/pos_hook";
import { useService } from "@web/core/utils/hooks";
import { jsonrpc } from "@web/core/network/rpc_service";
/**
* ChannelPanel - shown in the POS OrderScreen.
* Allows staff to select:
* - Order Source (Walk-in / Phone / WhatsApp / Social / Online)
* - Fulfilment Type (Dine-In / Pickup / Delivery)
* - Delivery address (when Delivery is selected)
*/
export class ChannelPanel extends Component {
static template = "dine360_order_channels.ChannelPanel";
setup() {
this.pos = usePos();
this.dialog = useService("dialog");
this.SOURCE_LABELS = {
walk_in: '🚶 Walk-In',
phone: '📞 Phone',
whatsapp: '💬 WhatsApp',
social_media: '📱 Social',
online: '🌐 Online',
kiosk: '📟 Kiosk',
qr: '📷 QR Code',
platform: '🛒 Platform',
};
this.FULFILMENT_LABELS = {
dine_in: '🍽️ Dine-In',
pickup: '🛍️ Pickup',
delivery: '🚚 Delivery',
};
this.state = useState({
showDelivery: false,
searchQuery: '',
searchResults: [],
searching: false,
});
}
get currentOrder() {
return this.pos.get_order();
}
get orderSource() {
return this.currentOrder?.order_source || 'walk_in';
}
get fulfilmentType() {
return this.currentOrder?.fulfilment_type || 'dine_in';
}
get isDelivery() {
return this.fulfilmentType === 'delivery';
}
get showPanel() {
return this.pos.config.show_channel_panel;
}
// --- Source Selector ---
onSourceChange(source) {
const order = this.currentOrder;
if (!order) return;
order.setOrderSource(source);
// Trigger re-render
this.state.showDelivery = this.isDelivery;
}
// --- Fulfilment Selector ---
onFulfilmentChange(type) {
const order = this.currentOrder;
if (!order) return;
order.setFulfilmentType(type);
this.state.showDelivery = (type === 'delivery');
}
// --- Partner Address Search ---
async onAddressSearch(ev) {
const query = ev.target.value;
this.state.searchQuery = query;
if (query.length < 2) {
this.state.searchResults = [];
return;
}
this.state.searching = true;
const results = await jsonrpc('/dine360/order_channels/partners', { query, limit: 8 });
this.state.searchResults = results;
this.state.searching = false;
}
onSelectPartner(partner) {
const order = this.currentOrder;
if (!order) return;
order.setDeliveryAddress({
street: partner.street,
city: partner.city,
zip: partner.zip,
phone: partner.phone,
});
this.state.searchQuery = partner.display_name;
this.state.searchResults = [];
}
onDeliveryFieldChange(field, ev) {
const order = this.currentOrder;
if (!order) return;
// Ad-hoc street/city/zip/phone/notes editing
const current = {
street: order.delivery_street,
city: order.delivery_city,
zip: order.delivery_zip,
phone: order.delivery_phone,
notes: order.delivery_notes,
};
current[field] = ev.target.value;
order.setDeliveryAddress(current);
}
}

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;
},
});

View File

@ -0,0 +1,23 @@
/** @odoo-module */
import { patch } from "@web/core/utils/patch";
import { ProductScreen } from "@point_of_sale/app/screens/product_screen/product_screen";
import { ChannelPanel } from "./channel_panel";
/**
* Patch ProductScreen to:
* 1. Register ChannelPanel as a component
* 2. Expose showChannelPanel computed property to the template
*/
patch(ProductScreen, {
components: {
...ProductScreen.components,
ChannelPanel,
},
});
patch(ProductScreen.prototype, {
get showChannelPanel() {
return this.pos?.config?.show_channel_panel !== false;
},
});

View File

@ -0,0 +1,124 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<!-- ChannelPanel Component -->
<t t-name="dine360_order_channels.ChannelPanel" owl="1">
<t t-if="showPanel and currentOrder">
<div class="channel-panel d-flex flex-column gap-2 p-2 border-bottom">
<!-- Order Source Row -->
<div class="channel-section">
<div class="channel-label mb-1">📋 ORDER SOURCE</div>
<div class="d-flex flex-wrap gap-1">
<t t-foreach="Object.entries(SOURCE_LABELS)" t-as="entry" t-key="entry[0]">
<button
t-attf-class="btn btn-sm channel-btn #{orderSource === entry[0] ? 'btn-dark active' : 'btn-outline-secondary'}"
t-on-click="() => this.onSourceChange(entry[0])">
<t t-esc="entry[1]"/>
</button>
</t>
</div>
</div>
<!-- Phone number field -->
<div t-if="orderSource === 'phone'" class="channel-extra">
<input type="text" class="form-control form-control-sm"
placeholder="📞 Phone Number"
t-att-value="currentOrder.telephone_number"
t-on-change="(ev) => { currentOrder.telephone_number = ev.target.value; }"/>
</div>
<!-- WhatsApp number field -->
<div t-if="orderSource === 'whatsapp'" class="channel-extra">
<input type="text" class="form-control form-control-sm"
placeholder="📱 WhatsApp Number"
t-att-value="currentOrder.whatsapp_number"
t-on-change="(ev) => { currentOrder.whatsapp_number = ev.target.value; }"/>
</div>
<!-- Social media ref field -->
<div t-if="orderSource === 'social_media'" class="channel-extra">
<input type="text" class="form-control form-control-sm"
placeholder="📲 Post / Message Reference"
t-att-value="currentOrder.social_ref"
t-on-change="(ev) => { currentOrder.social_ref = ev.target.value; }"/>
</div>
<!-- Fulfilment Row -->
<div class="channel-section mt-1">
<div class="channel-label mb-1">🚀 FULFILMENT TYPE</div>
<div class="btn-group w-100" role="group">
<t t-foreach="Object.entries(FULFILMENT_LABELS)" t-as="entry" t-key="entry[0]">
<button
t-attf-class="btn btn-sm fulfilment-btn #{fulfilmentType === entry[0] ? 'btn-primary active' : 'btn-outline-primary'}"
t-on-click="() => this.onFulfilmentChange(entry[0])">
<t t-esc="entry[1]"/>
</button>
</t>
</div>
</div>
<!-- Delivery Address Section -->
<div t-if="isDelivery" class="delivery-panel p-2 rounded mt-1">
<div class="fw-bold small mb-2">🚚 Delivery Address</div>
<!-- Partner search -->
<div class="position-relative mb-2">
<input type="text" class="form-control form-control-sm"
placeholder="🔍 Search saved address..."
t-att-value="state.searchQuery"
t-on-input="onAddressSearch"/>
<div t-if="state.searching" class="text-muted small ps-1">Searching...</div>
<div t-if="state.searchResults.length > 0"
class="address-dropdown position-absolute bg-white border rounded shadow-sm w-100">
<t t-foreach="state.searchResults" t-as="partner" t-key="partner.id">
<div class="address-result p-2 border-bottom small"
t-on-click="() => this.onSelectPartner(partner)">
<span class="fw-bold" t-esc="partner.name"/>
<span class="text-muted">
<t t-esc="(partner.street || '') + ', ' + (partner.city || '')"/>
</span>
</div>
</t>
</div>
</div>
<!-- Manual fields -->
<input type="text" class="form-control form-control-sm mb-1"
placeholder="Street"
t-att-value="currentOrder.delivery_street"
t-on-change="(ev) => this.onDeliveryFieldChange('street', ev)"/>
<div class="d-flex gap-1 mb-1">
<input type="text" class="form-control form-control-sm"
placeholder="City"
t-att-value="currentOrder.delivery_city"
t-on-change="(ev) => this.onDeliveryFieldChange('city', ev)"/>
<input type="text" class="form-control form-control-sm"
placeholder="Zip" style="max-width:90px"
t-att-value="currentOrder.delivery_zip"
t-on-change="(ev) => this.onDeliveryFieldChange('zip', ev)"/>
</div>
<input type="tel" class="form-control form-control-sm mb-1"
placeholder="Phone"
t-att-value="currentOrder.delivery_phone"
t-on-change="(ev) => this.onDeliveryFieldChange('phone', ev)"/>
<textarea class="form-control form-control-sm" rows="2"
placeholder="Delivery notes (gate code, floor...)"
t-att-value="currentOrder.delivery_notes"
t-on-change="(ev) => this.onDeliveryFieldChange('notes', ev)"/>
</div>
</div>
</t>
</t>
<!-- Inject ChannelPanel into the left pane of ProductScreen, above OrderWidget -->
<t t-name="point_of_sale.ProductScreen"
t-inherit="point_of_sale.ProductScreen"
t-inherit-mode="extension" owl="1">
<xpath expr="//div[hasclass('leftpane')]//OrderWidget" position="before">
<ChannelPanel/>
</xpath>
</t>
</templates>

View File

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="dine360_order_channels.OrderReceipt" t-inherit="point_of_sale.OrderReceipt" t-inherit-mode="extension" owl="1">
<xpath expr="//div[hasclass('pos-receipt-order-data')]" position="before">
<div class="channel-receipt-info mt-2 border-top pt-2">
<div t-if="props.data.order_source" class="d-flex justify-content-between">
<span>Order Source:</span>
<span class="fw-bold text-uppercase" t-esc="props.data.order_source_label"/>
</div>
<div t-if="props.data.fulfilment_type" class="d-flex justify-content-between">
<span>Fulfilment:</span>
<span class="fw-bold text-uppercase" t-esc="props.data.fulfilment_type_label"/>
</div>
<t t-if="props.data.fulfilment_type === 'delivery'">
<div class="mt-2 pt-2 border-top">
<div class="fw-bold">DELIVERY ADDRESS:</div>
<div t-if="props.data.delivery_street" t-esc="props.data.delivery_street"/>
<div t-if="props.data.delivery_city || props.data.delivery_zip">
<t t-esc="props.data.delivery_city"/> <t t-esc="props.data.delivery_zip"/>
</div>
<div t-if="props.data.delivery_phone">Phone: <t t-esc="props.data.delivery_phone"/></div>
<div t-if="props.data.delivery_notes" class="mt-1 small italic">
Note: <t t-esc="props.data.delivery_notes"/>
</div>
</div>
</t>
<div t-if="props.data.social_ref" class="mt-1 small">
Ref: <t t-esc="props.data.social_ref"/>
</div>
<div t-if="props.data.whatsapp_number" class="mt-1 small">
WhatsApp: <t t-esc="props.data.whatsapp_number"/>
</div>
</div>
</xpath>
</t>
</templates>

View File

@ -0,0 +1,93 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Inherit pos.order list view to show channel fields -->
<record id="view_pos_order_channels_list" model="ir.ui.view">
<field name="name">pos.order.channels.list</field>
<field name="model">pos.order</field>
<field name="inherit_id" ref="point_of_sale.view_pos_order_tree"/>
<field name="arch" type="xml">
<field name="amount_total" position="before">
<field name="order_source" string="Source" widget="badge"
decoration-info="order_source == 'online'"
decoration-warning="order_source == 'phone' or order_source == 'whatsapp'"
decoration-primary="order_source == 'kiosk' or order_source == 'qr'"
decoration-muted="order_source == 'walk_in'"/>
<field name="fulfilment_type" string="Fulfilment" widget="badge"
decoration-success="fulfilment_type == 'pickup'"
decoration-info="fulfilment_type == 'delivery'"
decoration-muted="fulfilment_type == 'dine_in'"/>
</field>
</field>
</record>
<!-- Inherit pos.order form view -->
<record id="view_pos_order_channels_form" model="ir.ui.view">
<field name="name">pos.order.channels.form</field>
<field name="model">pos.order</field>
<field name="inherit_id" ref="point_of_sale.view_pos_pos_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='state']" position="after">
<field name="order_source" string="Order Source" widget="badge"/>
<field name="fulfilment_type" string="Fulfilment" widget="badge"/>
</xpath>
<!-- Delivery section in the Notes tab area -->
<xpath expr="//field[@name='note']" position="before">
<group string="Delivery Details" invisible="fulfilment_type != 'delivery'">
<field name="delivery_partner_id" string="Saved Address"/>
<field name="delivery_street"/>
<field name="delivery_city"/>
<field name="delivery_zip"/>
<field name="delivery_phone"/>
<field name="delivery_notes"/>
</group>
<group string="WhatsApp" invisible="order_source != 'whatsapp'">
<field name="whatsapp_number"/>
</group>
<group string="Social Media" invisible="order_source != 'social_media'">
<field name="social_ref"/>
</group>
</xpath>
</field>
</record>
<!-- POS Config settings - add channel settings to existing POS config form -->
<!-- Note: We skip config view inheritance to avoid xpath issues with pos_self_order -->
<!-- Action: All Orders by Channel -->
<record id="action_pos_orders_by_channel" model="ir.actions.act_window">
<field name="name">Orders by Channel</field>
<field name="res_model">pos.order</field>
<field name="view_mode">tree,form</field>
<field name="context">{'search_default_group_source': 1}</field>
</record>
<!-- Search view extension for group-by Source/Fulfilment -->
<record id="view_pos_order_channels_search" model="ir.ui.view">
<field name="name">pos.order.channels.search</field>
<field name="model">pos.order</field>
<field name="inherit_id" ref="point_of_sale.view_pos_order_filter"/>
<field name="arch" type="xml">
<search>
<field name="order_source"/>
<field name="fulfilment_type"/>
<filter name="filter_phone" string="Phone Orders" domain="[('order_source','=','phone')]"/>
<filter name="filter_whatsapp" string="WhatsApp Orders" domain="[('order_source','=','whatsapp')]"/>
<filter name="filter_online" string="Online Orders" domain="[('order_source','=','online')]"/>
<filter name="filter_kiosk" string="Kiosk Orders" domain="[('order_source','=','kiosk')]"/>
<filter name="filter_delivery" string="Delivery" domain="[('fulfilment_type','=','delivery')]"/>
<filter name="filter_pickup" string="Pickup" domain="[('fulfilment_type','=','pickup')]"/>
<group expand="0" string="Group By">
<filter name="group_source" string="Order Source" context="{'group_by': 'order_source'}"/>
<filter name="group_fulfilment" string="Fulfilment Type" context="{'group_by': 'fulfilment_type'}"/>
</group>
</search>
</field>
</record>
<!-- Menu item in POS menu -->
<menuitem id="menu_pos_orders_by_channel"
name="Orders by Channel"
parent="point_of_sale.menu_point_of_sale"
action="action_pos_orders_by_channel"
sequence="25"/>
</odoo>

View File

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

View File

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

View File

@ -0,0 +1,30 @@
/* Custom POS Navbar styling */
.pos .pos-topheader {
background: inherit;
}
.btn-dashboard:hover {
background: linear-gradient(135deg, #a00d0e 0%, #d61112 100%) !important;
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3) !important;
}
/* Category Sidebar Red Background */
.pos .category-button {
background: #d61112 !important;
color: white !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
transition: all 0.2s ease;
}
.pos .category-button:hover {
background: #a00d0e !important;
transform: scale(1.02);
}
.pos .category-button.active {
background: #111 !important;
/* Contrast active category */
box-shadow: inset 0 0 8px rgba(0, 0, 0, 0.5) !important;
border: 1px solid #FECD4F !important;
}

View File

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

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-inherit="point_of_sale.Navbar" t-inherit-mode="extension" owl="1">
<xpath expr="//img[hasclass('pos-logo')]" position="after">
<a href="/" class="btn-dashboard ms-2 d-flex align-items-center justify-content-center text-white text-decoration-none"
style="background: linear-gradient(135deg, #d61112 0%, #a00d0e 100%); border-radius: 6px; padding: 0 25px; height: 100%; font-weight: 700; border: 1px solid rgba(255,255,255,0.3); transition: all 0.3s ease; box-shadow: 0 2px 5px rgba(0,0,0,0.2);">
<i class="fa fa-th-large me-2"></i>
<span style="font-size: 14px;">Dashboard</span>
</a>
</xpath>
</t>
</templates>

View File

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

View File

@ -0,0 +1,29 @@
{
'name': 'Dine360 Self-Order',
'version': '17.0.1.0',
'category': 'Sales/Point of Sale',
'summary': 'QR Table Ordering and Kiosk Mode for Dine360',
'description': """
Custom Self-Order module for Dine360:
- QR Code generation for restaurant tables
- Public web interface for menu browsing and ordering
- JSON API for product data and order submission
- Integration with POS and KDS
""",
'author': 'Dine360',
'depends': ['point_of_sale', 'pos_restaurant', 'dine360_order_channels', 'website'],
'data': [
'views/restaurant_table_views.xml',
'views/self_order_templates.xml',
],
'assets': {
'web.assets_frontend': [
'dine360_self_order/static/src/css/self_order.css',
'dine360_self_order/static/src/js/self_order.js',
'dine360_self_order/static/src/xml/self_order.xml',
],
},
'installable': True,
'application': True,
'license': 'LGPL-3',
}

View File

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

View File

@ -0,0 +1,114 @@
from odoo import http, _
from odoo.http import request
import json
import logging
_logger = logging.getLogger(__name__)
class Dine360SelfOrderController(http.Controller):
@http.route('/dine360/menu', type='http', auth='public', website=True)
def self_order_menu(self, **kwargs):
"""Displays the self-order menu for a specific table/kiosk"""
table_id = kwargs.get('table_id')
table = False
if table_id:
table = request.env['restaurant.table'].sudo().browse(int(table_id))
values = {
'table': table,
'floor': table.floor_id if table else False,
}
return request.render('dine360_self_order.self_order_menu_template', values)
@http.route('/dine360/self_order/products', type='json', auth='public', methods=['POST'])
def get_products(self):
"""API to fetch products for the self-order menu"""
# We only show items available in POS
products = request.env['product.product'].sudo().search([
('available_in_pos', '=', True),
('sale_ok', '=', True)
])
# Group by category if needed, but for now simple list
result = []
for p in products:
result.append({
'id': p.id,
'display_name': p.display_name,
'list_price': p.list_price,
'pos_categ_id': p.pos_categ_ids[0].id if p.pos_categ_ids else False,
'pos_categ_name': p.pos_categ_ids[0].name if p.pos_categ_ids else 'General',
'description': p.description_sale or '',
# Image URL helper
'image_url': f"/web/image/product.product/{p.id}/image_128",
'is_kitchen_item': p.is_kitchen_item,
})
return result
@http.route('/dine360/self_order/submit_order', type='json', auth='public', methods=['POST'])
def submit_self_order(self, **kwargs):
"""API to submit a self-order and create a pos.order"""
data = kwargs.get('order_data')
if not data:
return {'error': 'No order data received'}
try:
# We need an active session to link the order
# For self-order, we might have a dedicated "Self-Order POS" config
# Here we'll find the first open POS session or one marked as 'self_order'
session = request.env['pos.session'].sudo().search([
('state', '=', 'opened'),
('config_id.module_pos_self_order', '=', True) # Or a custom flag
], limit=1)
if not session:
# Fallback to any open session
session = request.env['pos.session'].sudo().search([
('state', '=', 'opened')
], limit=1)
if not session:
return {'error': 'No open POS session found. Please wait for staff to open the store.'}
partner = request.env.user.partner_id if not request.env.user._is_public() else False
# Prepare order values
table_id = data.get('table_id')
lines = data.get('lines', [])
order_vals = {
'session_id': session.id,
'partner_id': partner.id if partner else False,
'table_id': int(table_id) if table_id else False,
'order_source': 'qr' if table_id else 'kiosk',
'fulfilment_type': data.get('fulfilment_type', 'dine_in'),
'lines': [],
}
for line in lines:
order_vals['lines'].append((0, 0, {
'product_id': line['product_id'],
'qty': line['qty'],
'price_unit': line['price_unit'],
'customer_note': line.get('note', ''),
}))
# Use sudo to create the order since it's from public flow
order = request.env['pos.order'].sudo().create([order_vals])
# To trigger KDS, we might need to call specific methods or the confirmation flow
# For self-order, we often mark as "Pending Payment" or "Paid" depending on flow
# If kiosk, it might be unpaid until cashier handles it.
return {
'success': True,
'order_id': order.id,
'order_name': order.name,
'message': _("Your order has been sent to the kitchen!")
}
except Exception as e:
_logger.error("Error submitting self-order: %s", str(e))
return {'error': str(e)}

View File

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

View File

@ -0,0 +1,38 @@
from odoo import models, fields, api, _
import werkzeug.urls
import logging
_logger = logging.getLogger(__name__)
class RestaurantTable(models.Model):
_inherit = 'restaurant.table'
self_order_url = fields.Char(
string='Self-Order URL',
compute='_compute_self_order_url',
help='The unique URL for this table to open the ordering menu'
)
@api.depends('floor_id', 'name')
def _compute_self_order_url(self):
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
for table in self:
# We use a unique access token if we want more security, but for now ID is fine for the flow
params = {
'table_id': table.id,
'floor_id': table.floor_id.id,
}
# The public URL that the QR will point to
url = f"{base_url}/dine360/menu?{werkzeug.urls.url_encode(params)}"
table.self_order_url = url
def action_generate_qr_code(self):
"""Action to generate or print the QR code for this table"""
# In a full implementation, we'd use report logic to print a PDF with the QR
# For now, we'll expose the URL for testing
return {
'type': 'ir.actions.act_url',
'url': self.self_order_url,
'target': 'new',
}

View File

@ -0,0 +1,89 @@
/* Self-Order UI Styling */
#self_order_app {
font-family: 'Outfit', 'Inter', -apple-system, sans-serif;
color: #171422;
}
.transition-all {
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.search-bar {
border-radius: 14px;
background: #ffffff;
}
.product-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.08) !important;
}
.product-card .card-img-top {
border-top-left-radius: 14px;
border-top-right-radius: 14px;
}
.btn-warning {
background-color: #FECD4F;
border-color: #FECD4F;
color: #171422;
}
.btn-warning:hover,
.btn-warning:active {
background-color: #fdbd1a;
border-color: #fdbd1a;
}
.bg-warning-light {
background-color: rgba(254, 205, 79, 0.08) !important;
}
.service-select.active {
border: 2px solid #FECD4F !important;
background-color: rgba(254, 205, 79, 0.05) !important;
}
.cart-item {
border-bottom: 1px dashed #eee;
padding-bottom: 1rem;
}
.cart-item:last-child {
border-bottom: none;
}
#category_filter button {
font-size: 13px;
font-weight: 500;
}
.badge-primary {
background-color: #171422;
}
#footer_cart {
border-top-left-radius: 20px;
border-top-right-radius: 20px;
z-index: 1050;
}
.modal-content {
border: none;
box-shadow: 0 -10px 40px rgba(0, 0, 0, 0.1);
}
.spinner-border {
width: 2rem;
height: 2rem;
}

View File

@ -0,0 +1,259 @@
/** @odoo-module */
import { jsonrpc } from "@web/core/network/rpc_service";
// We'll use a standard self-invoking function style since it's a public web module
(function () {
'use strict';
document.addEventListener('DOMContentLoaded', async () => {
const app = document.querySelector('#self_order_app');
if (!app) return;
const dataEl = document.querySelector('#self_order_data');
const config = {
tableId: dataEl.dataset.tableId,
tableName: dataEl.dataset.tableName,
};
const state = {
products: [],
cart: [],
activeCategory: 'all',
searchTerm: '',
};
// --- UI Elements ---
const productList = document.querySelector('#product_list');
const categoryFilter = document.querySelector('#category_filter');
const cartCount = document.querySelector('#cart_count');
const cartTotal = document.querySelector('#cart_total');
const footerCart = document.querySelector('#footer_cart');
const loadingOverlay = document.querySelector('#loading_overlay');
const contentArea = document.querySelector('#self_order_content');
// --- Init ---
try {
const products = await jsonrpc('/dine360/self_order/products', {});
state.products = products;
renderCategories();
renderProducts();
loadingOverlay.classList.add('d-none');
contentArea.classList.remove('d-none');
} catch (e) {
console.error("Failed to load products", e);
alert("Error connecting to server. Please try again later.");
}
// --- Functions ---
function renderCategories() {
const categories = ['all', ...new Set(state.products.map(p => p.pos_categ_name))];
categoryFilter.innerHTML = categories.map(cat => `
<button class="btn btn-sm ${state.activeCategory === cat ? 'btn-warning fw-bold shadow-sm' : 'btn-white border'} rounded-pill px-3 py-2" data-category="${cat}">
${cat === 'all' ? 'All Items' : cat}
</button>
`).join('');
categoryFilter.querySelectorAll('button').forEach(btn => {
btn.onclick = () => {
state.activeCategory = btn.dataset.category;
renderCategories();
renderProducts();
};
});
}
function renderProducts() {
let filtered = state.products;
if (state.activeCategory !== 'all') {
filtered = filtered.filter(p => p.pos_categ_name === state.activeCategory);
}
if (state.searchTerm) {
const term = state.searchTerm.toLowerCase();
filtered = filtered.filter(p => p.display_name.toLowerCase().includes(term));
}
productList.innerHTML = filtered.map(p => `
<div class="col-6 col-md-4 col-lg-3">
<div class="card h-100 border-0 shadow-sm product-card transition-all" data-id="${p.id}">
<div class="position-relative">
<img src="${p.image_url}" class="card-img-top rounded-top-4" alt="${p.display_name}" style="height: 140px; object-fit: cover; opacity: 1;"/>
<div class="position-absolute bottom-0 end-0 p-1">
<button class="btn btn-warning btn-sm rounded-circle add-to-cart-btn shadow" style="width: 32px; height: 32px; padding: 0;">
<i class="fa fa-plus"/>
</button>
</div>
</div>
<div class="card-body p-3">
<div class="fw-bold text-dark small mb-1 text-truncate">${p.display_name}</div>
<div class="d-flex justify-content-between align-items-center">
<span class="fw-bold text-primary">$${p.list_price.toFixed(2)}</span>
<t t-if="p.is_kitchen_item">
<i class="fa fa-fire text-danger small opacity-50"/>
</t>
</div>
</div>
</div>
</div>
`).join('');
productList.querySelectorAll('.add-to-cart-btn').forEach(btn => {
btn.onclick = (ev) => {
ev.stopPropagation();
const card = btn.closest('.product-card');
const productId = parseInt(card.dataset.id);
addToCart(productId);
};
});
}
function addToCart(productId) {
const product = state.products.find(p => p.id === productId);
const existing = state.cart.find(item => item.product_id === productId);
if (existing) {
existing.qty++;
} else {
state.cart.push({
product_id: product.id,
display_name: product.display_name,
price_unit: product.list_price,
qty: 1
});
}
updateCartUI();
}
function updateCartUI() {
const count = state.cart.reduce((acc, item) => acc + item.qty, 0);
const total = state.cart.reduce((acc, item) => acc + (item.qty * item.price_unit), 0);
cartCount.textContent = count;
cartTotal.textContent = `$${total.toFixed(2)}`;
if (count > 0) {
footerCart.classList.remove('d-none');
setTimeout(() => footerCart.style.transform = 'translateY(0)', 10);
} else {
footerCart.style.transform = 'translateY(100%)';
setTimeout(() => footerCart.classList.add('d-none'), 300);
}
}
// --- Search ---
document.querySelector('#product_search').oninput = (ev) => {
state.searchTerm = ev.target.value;
renderProducts();
};
// --- Cart Modal ---
const cartModal = new bootstrap.Modal(document.getElementById('cart_modal'));
document.querySelector('#view_cart_btn').onclick = () => {
renderCartModal();
cartModal.show();
};
function renderCartModal() {
const list = document.querySelector('#cart_items_list');
const total = state.cart.reduce((acc, item) => acc + (item.qty * item.price_unit), 0);
list.innerHTML = state.cart.map((item, index) => `
<div class="d-flex align-items-center mb-4 cart-item">
<div class="flex-grow-1">
<div class="fw-bold mb-1">${item.display_name}</div>
<div class="text-primary fw-bold">$${(item.qty * item.price_unit).toFixed(2)}</div>
</div>
<div class="d-flex align-items-center gap-2 bg-light rounded-pill p-1 border">
<button class="btn btn-sm btn-white rounded-circle shadow-sm decrease-qty" data-index="${index}" style="width:28px; height:28px; padding:0;">-</button>
<span class="px-2 fw-bold" style="min-width: 20px; text-align:center;">${item.qty}</span>
<button class="btn btn-sm btn-white rounded-circle shadow-sm increase-qty" data-index="${index}" style="width:28px; height:28px; padding:0;">+</button>
</div>
</div>
`).join('');
document.querySelector('#checkout_subtotal').textContent = `$${total.toFixed(2)}`;
document.querySelector('#checkout_total').textContent = `$${total.toFixed(2)}`;
list.querySelectorAll('.increase-qty').forEach(btn => {
btn.onclick = () => {
state.cart[btn.dataset.index].qty++;
renderCartModal();
updateCartUI();
};
});
list.querySelectorAll('.decrease-qty').forEach(btn => {
btn.onclick = () => {
const index = btn.dataset.index;
state.cart[index].qty--;
if (state.cart[index].qty <= 0) {
state.cart.splice(index, 1);
}
if (state.cart.length === 0) cartModal.hide();
renderCartModal();
updateCartUI();
};
});
}
// --- Submit Order ---
document.querySelector('#submit_order_btn').onclick = async () => {
const btn = document.querySelector('#submit_order_btn');
const originalText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"/>Sending...';
const fulfilmentType = document.querySelector('input[name="fulfilment"]:checked')?.value || 'dine_in';
const orderData = {
table_id: config.tableId,
fulfilment_type: fulfilmentType,
lines: state.cart
};
try {
const response = await jsonrpc('/dine360/self_order/submit_order', { order_data: orderData });
if (response.success) {
cartModal.hide();
state.cart = [];
updateCartUI();
// Success View
document.querySelector('#self_order_content').innerHTML = `
<div class="text-center py-5">
<div class="mb-4">
<div class="bg-success text-white d-inline-flex align-items-center justify-content-center rounded-circle shadow-lg" style="width: 100px; height: 100px;">
<i class="fa fa-check fa-4x"/>
</div>
</div>
<h2 class="fw-bold mb-3">Order Received!</h2>
<p class="text-muted mb-4 px-4">${response.message}</p>
<div class="bg-white p-4 rounded-4 shadow-sm mb-4">
<div class="small text-muted mb-1">Order Number</div>
<div class="h4 fw-bold text-dark mb-0">${response.order_name}</div>
</div>
<button onclick="window.location.reload()" class="btn btn-dark btn-lg px-5 rounded-pill shadow">
Order More Items
</button>
</div>
`;
} else {
alert(response.error || "Order submission failed");
}
} catch (e) {
console.error(e);
alert("Connection lost. Please check your internet.");
} finally {
btn.disabled = false;
btn.innerHTML = originalText;
}
};
// Service Type Toggle
document.querySelectorAll('.service-select').forEach(label => {
label.onclick = () => {
document.querySelectorAll('.service-select').forEach(l => l.classList.remove('active', 'border-warning', 'bg-warning-light'));
label.classList.add('active', 'border-warning', 'bg-warning-light');
};
});
});
})();

View File

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_restaurant_table_form_inherit_self_order" model="ir.ui.view">
<field name="name">restaurant.table.form.inherit.self.order</field>
<field name="model">restaurant.table</field>
<field name="inherit_id" ref="pos_restaurant.view_restaurant_table_form"/>
<field name="arch" type="xml">
<field name="seats" position="after">
<field name="self_order_url" widget="url" readonly="1" string="Self-Order URL"/>
</field>
<xpath expr="//sheet" position="before">
<header>
<button name="action_generate_qr_code" string="Open Front-end" type="object" class="btn-primary"/>
</header>
</xpath>
</field>
</record>
<record id="view_restaurant_table_tree_self_order" model="ir.ui.view">
<field name="name">restaurant.table.tree.self.order</field>
<field name="model">restaurant.table</field>
<field name="arch" type="xml">
<tree string="Restaurant Tables">
<field name="floor_id"/>
<field name="name"/>
<field name="seats"/>
<field name="self_order_url" widget="url" readonly="1"/>
</tree>
</field>
</record>
</odoo>

View File

@ -0,0 +1,124 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="self_order_menu_template" name="Self-Order Menu">
<t t-call="website.layout">
<div id="self_order_app" class="bg-light min-vh-100 pb-5">
<!-- Data injection for JS -->
<div id="self_order_data" class="d-none"
t-att-data-table-id="table.id if table else ''"
t-att-data-floor-id="floor.id if floor else ''"
t-att-data-table-name="table.display_name if table else ''"/>
<!-- Header -->
<nav class="navbar navbar-expand-lg navbar-dark bg-dark sticky-top shadow-sm py-2">
<div class="container px-3 d-flex justify-content-between">
<a class="navbar-brand fw-bold d-flex align-items-center" href="/dine360/menu">
<i class="fa fa-cutlery me-2 text-warning"/> Dine360
</a>
<div t-if="table" class="text-white-50 small">
<span class="badge bg-warning text-dark px-2 py-1 rounded-pill">
Table <t t-esc="table.name"/>
</span>
</div>
<div t-else="" class="text-white-50 small">
<span class="badge bg-info text-dark px-2 py-1 rounded-pill">
Kiosk Mode
</span>
</div>
</div>
</nav>
<!-- Loading State -->
<div id="loading_overlay" class="d-flex justify-content-center align-items-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<!-- Main Content (Populated by JS) -->
<div id="self_order_content" class="container py-3 d-none">
<!-- Search & Filter -->
<div class="mb-4 d-flex gap-2 sticky-top bg-light py-2" style="top: 56px; z-index: 100;">
<div class="input-group search-bar shadow-sm">
<span class="input-group-text bg-white border-0 ps-3">
<i class="fa fa-search text-muted"/>
</span>
<input type="text" id="product_search" class="form-control border-0 py-2" placeholder="Search for food..."/>
</div>
</div>
<!-- Categories Scroll -->
<div id="category_filter" class="d-flex overflow-auto gap-2 pb-3 mb-3 no-scrollbar" style="white-space: nowrap;">
<!-- JS injects category buttons here -->
</div>
<!-- Products List -->
<div id="product_list" class="row g-3">
<!-- JS injects product cards here -->
</div>
</div>
<!-- Footer Cart Button -->
<div id="footer_cart" class="fixed-bottom p-3 d-none shadow-lg bg-white border-top transition-all" style="transform: translateY(100%);">
<div class="container d-flex justify-content-between align-items-center">
<div class="cart-info">
<span id="cart_count" class="badge bg-primary rounded-pill me-2">0</span>
<span id="cart_total" class="fw-bold fs-5 text-dark">$0.00</span>
</div>
<button id="view_cart_btn" class="btn btn-warning btn-lg px-4 fw-bold rounded-pill shadow-sm">
View Order <i class="fa fa-arrow-right ms-2"/>
</button>
</div>
</div>
<!-- Cart Modal -->
<div class="modal fade" id="cart_modal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered modal-fullscreen-sm-down">
<div class="modal-content border-0 overflow-hidden" style="border-radius: 20px;">
<div class="modal-header border-0 bg-dark text-white p-4">
<h5 class="modal-title fw-bold">My Order</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"/>
</div>
<div class="modal-body p-0">
<div id="cart_items_list" class="p-4" style="max-height: 400px; overflow-y: auto;">
<!-- JS injects checkout lines here -->
</div>
<div t-if="not table" class="p-4 bg-light border-top">
<h6 class="fw-bold mb-3">Service Type</h6>
<div class="d-flex gap-2">
<label class="flex-fill border p-3 rounded-3 text-center cursor-pointer service-select active" data-mode="pickup">
<input type="radio" name="fulfilment" value="pickup" checked="checked" class="d-none"/>
<i class="fa fa-shopping-basket mb-2 d-block"/> Pickup
</label>
<label class="flex-fill border p-3 rounded-3 text-center cursor-pointer service-select" data-mode="delivery">
<input type="radio" name="fulfilment" value="delivery" class="d-none"/>
<i class="fa fa-truck mb-2 d-block"/> Delivery
</label>
</div>
</div>
<div class="px-4 py-3 bg-white border-top">
<div class="d-flex justify-content-between mb-2">
<span class="text-muted">Subtotal</span>
<span id="checkout_subtotal">$0.00</span>
</div>
<div class="d-flex justify-content-between">
<span class="fw-bold fs-4">Total</span>
<span id="checkout_total" class="fw-bold fs-4 text-warning">$0.00</span>
</div>
</div>
</div>
<div class="modal-footer border-0 p-4 pt-0">
<button id="submit_order_btn" class="btn btn-warning w-100 btn-lg fw-bold rounded-pill shadow">
Send to Kitchen <i class="fa fa-paper-plane ms-2"/>
</button>
</div>
</div>
</div>
</div>
</div>
</t>
</template>
</odoo>

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