Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be1c9bd35e | ||
|
|
5531400dc3 | ||
|
|
73ef8df4b7 | ||
|
|
f9fb63f8c9 | ||
|
|
9d916d4ac0 | ||
|
|
b0861cae1e | ||
|
|
b9e5119dfa | ||
|
|
405dae06b5 | ||
|
|
4e2df91c14 | ||
|
|
8b248bee27 | ||
|
|
832fb9f196 | ||
|
|
7aafe0c6fb | ||
|
|
216c627369 | ||
|
|
d8db1f9334 | ||
|
|
46249085cd | ||
|
|
f7c7359b6c | ||
|
|
8ddde09c63 | ||
|
|
c744485423 | ||
|
|
7d20d000f3 | ||
|
|
5caf51ecf4 | ||
|
|
daa3fbd056 | ||
|
|
26c9e252cc | ||
|
|
8ea9a66022 | ||
|
|
d58a1fd30f | ||
|
|
015f703026 | ||
|
|
c8ed83248b | ||
|
|
75292e7b88 | ||
|
|
efa9b1e14a | ||
| 55e6b70134 | |||
| b69bf9bc1c | |||
| db94d57198 |
50
.agents/workflows/testing.md
Normal file
@ -0,0 +1,50 @@
|
||||
---
|
||||
description: Dine360 End-to-End (E2E) Integration Testing Workflow
|
||||
---
|
||||
|
||||
# 🚀 Dine360 E2E Testing Workflow
|
||||
|
||||
This workflow ensures all modules (`Self-Order`, `Online Orders`, `KDS`, and `POS`) are communicating correctly.
|
||||
|
||||
## 1. Environment Check
|
||||
Before testing, verify the services are up:
|
||||
// turbo
|
||||
`docker ps`
|
||||
Ensure `odoo_client2` and `db` are in a 'Healthy' or 'Up' state.
|
||||
|
||||
## 2. Setup POS Session
|
||||
1. Open your Odoo instance (usually `http://localhost:8069`).
|
||||
2. Go to **Point of Sale**.
|
||||
3. **Open** a new session for your main Shop/Restaurant.
|
||||
|
||||
## 3. Test Flow: Self-Order (Table QR)
|
||||
1. Go to **Point of Sale > Configuration > Floor Plans**.
|
||||
2. Select a floor and a table (e.g., "Table 1").
|
||||
3. Click the **Open Front-end** button (this opens the Self-Order menu).
|
||||
4. **Action**: Add 2-3 items to the cart and click **Send to Kitchen**.
|
||||
5. **Verification**:
|
||||
- [ ] Go to the **Kitchen (KDS)** module.
|
||||
- [ ] Check that the items appear in the **Waiting** column.
|
||||
- [ ] Confirm the source badge shows **QR Table Order / Table 1**.
|
||||
|
||||
## 4. Test Flow: Online Orders (Website)
|
||||
1. Navigate to the Website Shop (`/shop`).
|
||||
2. **Action**: Add items to the cart, proceed to checkout, and complete the order.
|
||||
3. **Internal POS Verification**:
|
||||
- [ ] Open the POS UI.
|
||||
- [ ] Click the **Online Orders** tab in the top navbar.
|
||||
- [ ] Select your order and click **Confirm & Send to Kitchen**.
|
||||
4. **KDS Verification**:
|
||||
- [ ] Check the **Kitchen (KDS)** module.
|
||||
- [ ] Source badge should show **Online / eCommerce**.
|
||||
|
||||
## 5. Test Flow: KDS Management
|
||||
1. In the **Kitchen (KDS)** dashboard:
|
||||
2. **Action**: Click the **Preparing** button on one of the cards.
|
||||
3. **Action**: Click the **Ready** button when finished.
|
||||
4. **Verification**:
|
||||
- [ ] Confirm the item moves to the correct column.
|
||||
- [ ] If you are in the POS UI, check if any notifications appear regarding readiness (if implemented).
|
||||
|
||||
## 6. Verification Summary
|
||||
If all checks above pass, the integration between the Frontend (Customer), Middle-end (POS), and Backend (KDS) is working perfectly.
|
||||
34
.gitignore
vendored
@ -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
|
||||
|
||||
12
README.md
@ -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 don’t appear, check for COW (customized) views masking the theme.
|
||||
- If theme changes don’t appear, check for COW (customized) views masking the theme.
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
28
addons/dine360_dashboard/static/src/img/icons/apps.svg
Normal 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 |
32
addons/dine360_dashboard/static/src/img/icons/calendar.svg
Normal 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 |
17
addons/dine360_dashboard/static/src/img/icons/contacts.svg
Normal 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 |
17
addons/dine360_dashboard/static/src/img/icons/crm.svg
Normal 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 |
21
addons/dine360_dashboard/static/src/img/icons/dashboards.svg
Normal 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 |
22
addons/dine360_dashboard/static/src/img/icons/discuss.svg
Normal 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 |
19
addons/dine360_dashboard/static/src/img/icons/employees.svg
Normal 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 |
23
addons/dine360_dashboard/static/src/img/icons/inventory.svg
Normal 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 |
25
addons/dine360_dashboard/static/src/img/icons/invoicing.svg
Normal 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 |
@ -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 |
@ -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 |
18
addons/dine360_dashboard/static/src/img/icons/purchase.svg
Normal 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 |
18
addons/dine360_dashboard/static/src/img/icons/sales.svg
Normal 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 |
25
addons/dine360_dashboard/static/src/img/icons/settings.svg
Normal 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 |
@ -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 |
@ -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 |
25
addons/dine360_dashboard/static/src/img/icons/website.svg
Normal 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 |
14
addons/dine360_dashboard/static/src/xml/navbar_extension.xml
Normal 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>
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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."
|
||||
)
|
||||
|
||||
@ -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).")
|
||||
|
||||
@ -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!");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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"/>
|
||||
|
||||
2
addons/dine360_online_orders/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from . import models
|
||||
from . import controllers
|
||||
37
addons/dine360_online_orders/__manifest__.py
Normal 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',
|
||||
}
|
||||
1
addons/dine360_online_orders/controllers/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from . import main
|
||||
14
addons/dine360_online_orders/controllers/main.py
Normal 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
|
||||
5
addons/dine360_online_orders/models/__init__.py
Normal 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
|
||||
10
addons/dine360_online_orders/models/pos_config.py
Normal 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')
|
||||
134
addons/dine360_online_orders/models/pos_order.py
Normal 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
|
||||
5
addons/dine360_online_orders/models/pos_order_line.py
Normal 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
|
||||
@ -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)
|
||||
109
addons/dine360_online_orders/models/sale_order.py
Normal 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
|
||||
)
|
||||
@ -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
|
||||
|
BIN
addons/dine360_online_orders/static/description/icon.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
320
addons/dine360_online_orders/static/src/css/online_orders.css
Normal 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;
|
||||
}
|
||||
}
|
||||
64
addons/dine360_online_orders/static/src/css/service_mode.css
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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!");
|
||||
@ -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!");
|
||||
62
addons/dine360_online_orders/static/src/js/service_mode.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -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 & 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>
|
||||
13
addons/dine360_online_orders/views/kds_override_views.xml
Normal 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>
|
||||
22
addons/dine360_online_orders/views/pos_config_views.xml
Normal 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>
|
||||
75
addons/dine360_online_orders/views/pos_order_views.xml
Normal 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>
|
||||
@ -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>
|
||||
2
addons/dine360_order_channels/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from . import models
|
||||
from . import controllers
|
||||
34
addons/dine360_order_channels/__manifest__.py
Normal file
@ -0,0 +1,34 @@
|
||||
{
|
||||
'name': 'Dine360 Order Channels',
|
||||
'version': '17.0.1.0',
|
||||
'category': 'Sales/Point of Sale',
|
||||
'summary': 'Multi-channel order intake: Phone, WhatsApp, Social, Kiosk, Online',
|
||||
'description': """
|
||||
Extends POS to support multiple order intake channels:
|
||||
- Channel 1: Telephone Orders (order_source=phone, fulfilment, delivery address)
|
||||
- Channel 2: WhatsApp Orders (order_source=whatsapp, number tracking)
|
||||
- Channel 3: Social Media Orders (order_source=social_media, ref tracking)
|
||||
- Channel 4: Kiosk / QR Orders (order_source=kiosk/qr)
|
||||
- Fulfilment Type: Dine-In, Pickup, Delivery
|
||||
- Address capture for Delivery orders with partner search
|
||||
""",
|
||||
'author': 'Dine360',
|
||||
'depends': ['point_of_sale'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'views/pos_order_views.xml',
|
||||
],
|
||||
'assets': {
|
||||
'point_of_sale._assets_pos': [
|
||||
'dine360_order_channels/static/src/css/channel_panel.css',
|
||||
'dine360_order_channels/static/src/js/order_channel_model.js',
|
||||
'dine360_order_channels/static/src/js/channel_panel.js',
|
||||
'dine360_order_channels/static/src/js/product_screen_patch.js',
|
||||
'dine360_order_channels/static/src/xml/channel_panel.xml',
|
||||
'dine360_order_channels/static/src/xml/receipt_extension.xml',
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
1
addons/dine360_order_channels/controllers/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from . import main
|
||||
44
addons/dine360_order_channels/controllers/main.py
Normal file
@ -0,0 +1,44 @@
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
import json
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Dine360OrderChannelsController(http.Controller):
|
||||
|
||||
@http.route('/dine360/order_channels/partners', type='json', auth='user', methods=['POST'])
|
||||
def search_partners(self, query='', limit=10):
|
||||
"""Search for partners (for delivery address lookup from POS)"""
|
||||
domain = [('name', 'ilike', query)]
|
||||
partners = request.env['res.partner'].search(domain, limit=limit)
|
||||
return [{
|
||||
'id': p.id,
|
||||
'name': p.name,
|
||||
'phone': p.phone or p.mobile or '',
|
||||
'street': p.street or '',
|
||||
'city': p.city or '',
|
||||
'zip': p.zip or '',
|
||||
'display_name': p.display_name,
|
||||
} for p in partners]
|
||||
|
||||
@http.route('/dine360/order_channels/create_partner', type='json', auth='user', methods=['POST'])
|
||||
def create_partner(self, name, phone='', street='', city='', zip_code=''):
|
||||
"""Quick create a delivery partner from POS"""
|
||||
partner = request.env['res.partner'].create({
|
||||
'name': name,
|
||||
'phone': phone,
|
||||
'street': street,
|
||||
'city': city,
|
||||
'zip': zip_code,
|
||||
'type': 'delivery',
|
||||
})
|
||||
return {
|
||||
'id': partner.id,
|
||||
'name': partner.name,
|
||||
'phone': partner.phone or '',
|
||||
'street': partner.street or '',
|
||||
'city': partner.city or '',
|
||||
'zip': partner.zip or '',
|
||||
}
|
||||
2
addons/dine360_order_channels/models/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from . import pos_order
|
||||
from . import pos_config
|
||||
30
addons/dine360_order_channels/models/pos_config.py
Normal file
@ -0,0 +1,30 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class PosConfigChannels(models.Model):
|
||||
_inherit = 'pos.config'
|
||||
|
||||
default_order_source = fields.Selection([
|
||||
('walk_in', 'Walk-In (Standard POS)'),
|
||||
('phone', 'Telephone Order'),
|
||||
('online', 'Online / eCommerce'),
|
||||
('whatsapp', 'WhatsApp'),
|
||||
('social_media', 'Social Media'),
|
||||
('platform', 'Third-Party Platform'),
|
||||
('kiosk', 'Self-Order Kiosk'),
|
||||
('qr', 'QR Table Order'),
|
||||
], string='Default Order Source', default='walk_in',
|
||||
help='Pre-select this order source when opening a new order in this terminal')
|
||||
|
||||
default_fulfilment_type = fields.Selection([
|
||||
('dine_in', 'Dine-In'),
|
||||
('pickup', 'Pickup'),
|
||||
('delivery', 'Delivery'),
|
||||
], string='Default Fulfilment Type', default='dine_in',
|
||||
help='Pre-select this fulfilment type for new orders on this terminal')
|
||||
|
||||
show_channel_panel = fields.Boolean(
|
||||
string='Show Channel / Fulfilment Panel',
|
||||
default=True,
|
||||
help='Show the Order Source and Fulfilment Type selector on the order screen'
|
||||
)
|
||||
147
addons/dine360_order_channels/models/pos_order.py
Normal 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
|
||||
)
|
||||
@ -0,0 +1 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
|
@ -0,0 +1,43 @@
|
||||
.channel-panel {
|
||||
background: #f9fafb;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.channel-btn {
|
||||
font-size: 11px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 20px !important;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.channel-btn.active {
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.fulfilment-btn {
|
||||
font-size: 12px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.delivery-panel {
|
||||
background-color: #eef3ff !important;
|
||||
border: 1px solid #c7d8ff;
|
||||
}
|
||||
|
||||
.address-dropdown {
|
||||
z-index: 999;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.address-result:hover {
|
||||
background-color: #f0f4ff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.channel-label {
|
||||
color: #666;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
125
addons/dine360_order_channels/static/src/js/channel_panel.js
Normal file
@ -0,0 +1,125 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { Component, useState } from "@odoo/owl";
|
||||
import { usePos } from "@point_of_sale/app/store/pos_hook";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { jsonrpc } from "@web/core/network/rpc_service";
|
||||
|
||||
/**
|
||||
* ChannelPanel - shown in the POS OrderScreen.
|
||||
* Allows staff to select:
|
||||
* - Order Source (Walk-in / Phone / WhatsApp / Social / Online)
|
||||
* - Fulfilment Type (Dine-In / Pickup / Delivery)
|
||||
* - Delivery address (when Delivery is selected)
|
||||
*/
|
||||
export class ChannelPanel extends Component {
|
||||
static template = "dine360_order_channels.ChannelPanel";
|
||||
|
||||
setup() {
|
||||
this.pos = usePos();
|
||||
this.dialog = useService("dialog");
|
||||
|
||||
this.SOURCE_LABELS = {
|
||||
walk_in: '🚶 Walk-In',
|
||||
phone: '📞 Phone',
|
||||
whatsapp: '💬 WhatsApp',
|
||||
social_media: '📱 Social',
|
||||
online: '🌐 Online',
|
||||
kiosk: '📟 Kiosk',
|
||||
qr: '📷 QR Code',
|
||||
platform: '🛒 Platform',
|
||||
};
|
||||
|
||||
this.FULFILMENT_LABELS = {
|
||||
dine_in: '🍽️ Dine-In',
|
||||
pickup: '🛍️ Pickup',
|
||||
delivery: '🚚 Delivery',
|
||||
};
|
||||
|
||||
this.state = useState({
|
||||
showDelivery: false,
|
||||
searchQuery: '',
|
||||
searchResults: [],
|
||||
searching: false,
|
||||
});
|
||||
}
|
||||
|
||||
get currentOrder() {
|
||||
return this.pos.get_order();
|
||||
}
|
||||
|
||||
get orderSource() {
|
||||
return this.currentOrder?.order_source || 'walk_in';
|
||||
}
|
||||
|
||||
get fulfilmentType() {
|
||||
return this.currentOrder?.fulfilment_type || 'dine_in';
|
||||
}
|
||||
|
||||
get isDelivery() {
|
||||
return this.fulfilmentType === 'delivery';
|
||||
}
|
||||
|
||||
get showPanel() {
|
||||
return this.pos.config.show_channel_panel;
|
||||
}
|
||||
|
||||
// --- Source Selector ---
|
||||
onSourceChange(source) {
|
||||
const order = this.currentOrder;
|
||||
if (!order) return;
|
||||
order.setOrderSource(source);
|
||||
// Trigger re-render
|
||||
this.state.showDelivery = this.isDelivery;
|
||||
}
|
||||
|
||||
// --- Fulfilment Selector ---
|
||||
onFulfilmentChange(type) {
|
||||
const order = this.currentOrder;
|
||||
if (!order) return;
|
||||
order.setFulfilmentType(type);
|
||||
this.state.showDelivery = (type === 'delivery');
|
||||
}
|
||||
|
||||
// --- Partner Address Search ---
|
||||
async onAddressSearch(ev) {
|
||||
const query = ev.target.value;
|
||||
this.state.searchQuery = query;
|
||||
if (query.length < 2) {
|
||||
this.state.searchResults = [];
|
||||
return;
|
||||
}
|
||||
this.state.searching = true;
|
||||
const results = await jsonrpc('/dine360/order_channels/partners', { query, limit: 8 });
|
||||
this.state.searchResults = results;
|
||||
this.state.searching = false;
|
||||
}
|
||||
|
||||
onSelectPartner(partner) {
|
||||
const order = this.currentOrder;
|
||||
if (!order) return;
|
||||
order.setDeliveryAddress({
|
||||
street: partner.street,
|
||||
city: partner.city,
|
||||
zip: partner.zip,
|
||||
phone: partner.phone,
|
||||
});
|
||||
this.state.searchQuery = partner.display_name;
|
||||
this.state.searchResults = [];
|
||||
}
|
||||
|
||||
onDeliveryFieldChange(field, ev) {
|
||||
const order = this.currentOrder;
|
||||
if (!order) return;
|
||||
// Ad-hoc street/city/zip/phone/notes editing
|
||||
const current = {
|
||||
street: order.delivery_street,
|
||||
city: order.delivery_city,
|
||||
zip: order.delivery_zip,
|
||||
phone: order.delivery_phone,
|
||||
notes: order.delivery_notes,
|
||||
};
|
||||
current[field] = ev.target.value;
|
||||
order.setDeliveryAddress(current);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,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;
|
||||
},
|
||||
});
|
||||
@ -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;
|
||||
},
|
||||
});
|
||||
124
addons/dine360_order_channels/static/src/xml/channel_panel.xml
Normal 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>
|
||||
@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="dine360_order_channels.OrderReceipt" t-inherit="point_of_sale.OrderReceipt" t-inherit-mode="extension" owl="1">
|
||||
<xpath expr="//div[hasclass('pos-receipt-order-data')]" position="before">
|
||||
<div class="channel-receipt-info mt-2 border-top pt-2">
|
||||
<div t-if="props.data.order_source" class="d-flex justify-content-between">
|
||||
<span>Order Source:</span>
|
||||
<span class="fw-bold text-uppercase" t-esc="props.data.order_source_label"/>
|
||||
</div>
|
||||
<div t-if="props.data.fulfilment_type" class="d-flex justify-content-between">
|
||||
<span>Fulfilment:</span>
|
||||
<span class="fw-bold text-uppercase" t-esc="props.data.fulfilment_type_label"/>
|
||||
</div>
|
||||
|
||||
<t t-if="props.data.fulfilment_type === 'delivery'">
|
||||
<div class="mt-2 pt-2 border-top">
|
||||
<div class="fw-bold">DELIVERY ADDRESS:</div>
|
||||
<div t-if="props.data.delivery_street" t-esc="props.data.delivery_street"/>
|
||||
<div t-if="props.data.delivery_city || props.data.delivery_zip">
|
||||
<t t-esc="props.data.delivery_city"/> <t t-esc="props.data.delivery_zip"/>
|
||||
</div>
|
||||
<div t-if="props.data.delivery_phone">Phone: <t t-esc="props.data.delivery_phone"/></div>
|
||||
<div t-if="props.data.delivery_notes" class="mt-1 small italic">
|
||||
Note: <t t-esc="props.data.delivery_notes"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<div t-if="props.data.social_ref" class="mt-1 small">
|
||||
Ref: <t t-esc="props.data.social_ref"/>
|
||||
</div>
|
||||
<div t-if="props.data.whatsapp_number" class="mt-1 small">
|
||||
WhatsApp: <t t-esc="props.data.whatsapp_number"/>
|
||||
</div>
|
||||
</div>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
93
addons/dine360_order_channels/views/pos_order_views.xml
Normal file
@ -0,0 +1,93 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Inherit pos.order list view to show channel fields -->
|
||||
<record id="view_pos_order_channels_list" model="ir.ui.view">
|
||||
<field name="name">pos.order.channels.list</field>
|
||||
<field name="model">pos.order</field>
|
||||
<field name="inherit_id" ref="point_of_sale.view_pos_order_tree"/>
|
||||
<field name="arch" type="xml">
|
||||
<field name="amount_total" position="before">
|
||||
<field name="order_source" string="Source" widget="badge"
|
||||
decoration-info="order_source == 'online'"
|
||||
decoration-warning="order_source == 'phone' or order_source == 'whatsapp'"
|
||||
decoration-primary="order_source == 'kiosk' or order_source == 'qr'"
|
||||
decoration-muted="order_source == 'walk_in'"/>
|
||||
<field name="fulfilment_type" string="Fulfilment" widget="badge"
|
||||
decoration-success="fulfilment_type == 'pickup'"
|
||||
decoration-info="fulfilment_type == 'delivery'"
|
||||
decoration-muted="fulfilment_type == 'dine_in'"/>
|
||||
</field>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Inherit pos.order form view -->
|
||||
<record id="view_pos_order_channels_form" model="ir.ui.view">
|
||||
<field name="name">pos.order.channels.form</field>
|
||||
<field name="model">pos.order</field>
|
||||
<field name="inherit_id" ref="point_of_sale.view_pos_pos_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='state']" position="after">
|
||||
<field name="order_source" string="Order Source" widget="badge"/>
|
||||
<field name="fulfilment_type" string="Fulfilment" widget="badge"/>
|
||||
</xpath>
|
||||
<!-- Delivery section in the Notes tab area -->
|
||||
<xpath expr="//field[@name='note']" position="before">
|
||||
<group string="Delivery Details" invisible="fulfilment_type != 'delivery'">
|
||||
<field name="delivery_partner_id" string="Saved Address"/>
|
||||
<field name="delivery_street"/>
|
||||
<field name="delivery_city"/>
|
||||
<field name="delivery_zip"/>
|
||||
<field name="delivery_phone"/>
|
||||
<field name="delivery_notes"/>
|
||||
</group>
|
||||
<group string="WhatsApp" invisible="order_source != 'whatsapp'">
|
||||
<field name="whatsapp_number"/>
|
||||
</group>
|
||||
<group string="Social Media" invisible="order_source != 'social_media'">
|
||||
<field name="social_ref"/>
|
||||
</group>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- POS Config settings - add channel settings to existing POS config form -->
|
||||
<!-- Note: We skip config view inheritance to avoid xpath issues with pos_self_order -->
|
||||
|
||||
<!-- Action: All Orders by Channel -->
|
||||
<record id="action_pos_orders_by_channel" model="ir.actions.act_window">
|
||||
<field name="name">Orders by Channel</field>
|
||||
<field name="res_model">pos.order</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="context">{'search_default_group_source': 1}</field>
|
||||
</record>
|
||||
|
||||
<!-- Search view extension for group-by Source/Fulfilment -->
|
||||
<record id="view_pos_order_channels_search" model="ir.ui.view">
|
||||
<field name="name">pos.order.channels.search</field>
|
||||
<field name="model">pos.order</field>
|
||||
<field name="inherit_id" ref="point_of_sale.view_pos_order_filter"/>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="order_source"/>
|
||||
<field name="fulfilment_type"/>
|
||||
<filter name="filter_phone" string="Phone Orders" domain="[('order_source','=','phone')]"/>
|
||||
<filter name="filter_whatsapp" string="WhatsApp Orders" domain="[('order_source','=','whatsapp')]"/>
|
||||
<filter name="filter_online" string="Online Orders" domain="[('order_source','=','online')]"/>
|
||||
<filter name="filter_kiosk" string="Kiosk Orders" domain="[('order_source','=','kiosk')]"/>
|
||||
<filter name="filter_delivery" string="Delivery" domain="[('fulfilment_type','=','delivery')]"/>
|
||||
<filter name="filter_pickup" string="Pickup" domain="[('fulfilment_type','=','pickup')]"/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter name="group_source" string="Order Source" context="{'group_by': 'order_source'}"/>
|
||||
<filter name="group_fulfilment" string="Fulfilment Type" context="{'group_by': 'fulfilment_type'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Menu item in POS menu -->
|
||||
<menuitem id="menu_pos_orders_by_channel"
|
||||
name="Orders by Channel"
|
||||
parent="point_of_sale.menu_point_of_sale"
|
||||
action="action_pos_orders_by_channel"
|
||||
sequence="25"/>
|
||||
</odoo>
|
||||
2
addons/dine360_pos_navbar/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
18
addons/dine360_pos_navbar/__manifest__.py
Normal 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',
|
||||
}
|
||||
30
addons/dine360_pos_navbar/static/src/css/pos_navbar.css
Normal 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;
|
||||
}
|
||||
9
addons/dine360_pos_navbar/static/src/js/pos_navbar.js
Normal 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
|
||||
});
|
||||
12
addons/dine360_pos_navbar/static/src/xml/pos_navbar.xml
Normal 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>
|
||||
2
addons/dine360_self_order/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from . import models
|
||||
from . import controllers
|
||||
29
addons/dine360_self_order/__manifest__.py
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
'name': 'Dine360 Self-Order',
|
||||
'version': '17.0.1.0',
|
||||
'category': 'Sales/Point of Sale',
|
||||
'summary': 'QR Table Ordering and Kiosk Mode for Dine360',
|
||||
'description': """
|
||||
Custom Self-Order module for Dine360:
|
||||
- QR Code generation for restaurant tables
|
||||
- Public web interface for menu browsing and ordering
|
||||
- JSON API for product data and order submission
|
||||
- Integration with POS and KDS
|
||||
""",
|
||||
'author': 'Dine360',
|
||||
'depends': ['point_of_sale', 'pos_restaurant', 'dine360_order_channels', 'website'],
|
||||
'data': [
|
||||
'views/restaurant_table_views.xml',
|
||||
'views/self_order_templates.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_frontend': [
|
||||
'dine360_self_order/static/src/css/self_order.css',
|
||||
'dine360_self_order/static/src/js/self_order.js',
|
||||
'dine360_self_order/static/src/xml/self_order.xml',
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
'application': True,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
1
addons/dine360_self_order/controllers/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from . import main
|
||||
114
addons/dine360_self_order/controllers/main.py
Normal file
@ -0,0 +1,114 @@
|
||||
from odoo import http, _
|
||||
from odoo.http import request
|
||||
import json
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Dine360SelfOrderController(http.Controller):
|
||||
|
||||
@http.route('/dine360/menu', type='http', auth='public', website=True)
|
||||
def self_order_menu(self, **kwargs):
|
||||
"""Displays the self-order menu for a specific table/kiosk"""
|
||||
table_id = kwargs.get('table_id')
|
||||
table = False
|
||||
if table_id:
|
||||
table = request.env['restaurant.table'].sudo().browse(int(table_id))
|
||||
|
||||
values = {
|
||||
'table': table,
|
||||
'floor': table.floor_id if table else False,
|
||||
}
|
||||
return request.render('dine360_self_order.self_order_menu_template', values)
|
||||
|
||||
@http.route('/dine360/self_order/products', type='json', auth='public', methods=['POST'])
|
||||
def get_products(self):
|
||||
"""API to fetch products for the self-order menu"""
|
||||
# We only show items available in POS
|
||||
products = request.env['product.product'].sudo().search([
|
||||
('available_in_pos', '=', True),
|
||||
('sale_ok', '=', True)
|
||||
])
|
||||
|
||||
# Group by category if needed, but for now simple list
|
||||
result = []
|
||||
for p in products:
|
||||
result.append({
|
||||
'id': p.id,
|
||||
'display_name': p.display_name,
|
||||
'list_price': p.list_price,
|
||||
'pos_categ_id': p.pos_categ_ids[0].id if p.pos_categ_ids else False,
|
||||
'pos_categ_name': p.pos_categ_ids[0].name if p.pos_categ_ids else 'General',
|
||||
'description': p.description_sale or '',
|
||||
# Image URL helper
|
||||
'image_url': f"/web/image/product.product/{p.id}/image_128",
|
||||
'is_kitchen_item': p.is_kitchen_item,
|
||||
})
|
||||
return result
|
||||
|
||||
@http.route('/dine360/self_order/submit_order', type='json', auth='public', methods=['POST'])
|
||||
def submit_self_order(self, **kwargs):
|
||||
"""API to submit a self-order and create a pos.order"""
|
||||
data = kwargs.get('order_data')
|
||||
if not data:
|
||||
return {'error': 'No order data received'}
|
||||
|
||||
try:
|
||||
# We need an active session to link the order
|
||||
# For self-order, we might have a dedicated "Self-Order POS" config
|
||||
# Here we'll find the first open POS session or one marked as 'self_order'
|
||||
session = request.env['pos.session'].sudo().search([
|
||||
('state', '=', 'opened'),
|
||||
('config_id.module_pos_self_order', '=', True) # Or a custom flag
|
||||
], limit=1)
|
||||
|
||||
if not session:
|
||||
# Fallback to any open session
|
||||
session = request.env['pos.session'].sudo().search([
|
||||
('state', '=', 'opened')
|
||||
], limit=1)
|
||||
|
||||
if not session:
|
||||
return {'error': 'No open POS session found. Please wait for staff to open the store.'}
|
||||
|
||||
partner = request.env.user.partner_id if not request.env.user._is_public() else False
|
||||
|
||||
# Prepare order values
|
||||
table_id = data.get('table_id')
|
||||
lines = data.get('lines', [])
|
||||
|
||||
order_vals = {
|
||||
'session_id': session.id,
|
||||
'partner_id': partner.id if partner else False,
|
||||
'table_id': int(table_id) if table_id else False,
|
||||
'order_source': 'qr' if table_id else 'kiosk',
|
||||
'fulfilment_type': data.get('fulfilment_type', 'dine_in'),
|
||||
'lines': [],
|
||||
}
|
||||
|
||||
for line in lines:
|
||||
order_vals['lines'].append((0, 0, {
|
||||
'product_id': line['product_id'],
|
||||
'qty': line['qty'],
|
||||
'price_unit': line['price_unit'],
|
||||
'customer_note': line.get('note', ''),
|
||||
}))
|
||||
|
||||
# Use sudo to create the order since it's from public flow
|
||||
order = request.env['pos.order'].sudo().create([order_vals])
|
||||
|
||||
# To trigger KDS, we might need to call specific methods or the confirmation flow
|
||||
# For self-order, we often mark as "Pending Payment" or "Paid" depending on flow
|
||||
# If kiosk, it might be unpaid until cashier handles it.
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'order_id': order.id,
|
||||
'order_name': order.name,
|
||||
'message': _("Your order has been sent to the kitchen!")
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
_logger.error("Error submitting self-order: %s", str(e))
|
||||
return {'error': str(e)}
|
||||
1
addons/dine360_self_order/models/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from . import restaurant_table
|
||||
38
addons/dine360_self_order/models/restaurant_table.py
Normal file
@ -0,0 +1,38 @@
|
||||
from odoo import models, fields, api, _
|
||||
import werkzeug.urls
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RestaurantTable(models.Model):
|
||||
_inherit = 'restaurant.table'
|
||||
|
||||
self_order_url = fields.Char(
|
||||
string='Self-Order URL',
|
||||
compute='_compute_self_order_url',
|
||||
help='The unique URL for this table to open the ordering menu'
|
||||
)
|
||||
|
||||
@api.depends('floor_id', 'name')
|
||||
def _compute_self_order_url(self):
|
||||
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
|
||||
for table in self:
|
||||
# We use a unique access token if we want more security, but for now ID is fine for the flow
|
||||
params = {
|
||||
'table_id': table.id,
|
||||
'floor_id': table.floor_id.id,
|
||||
}
|
||||
# The public URL that the QR will point to
|
||||
url = f"{base_url}/dine360/menu?{werkzeug.urls.url_encode(params)}"
|
||||
table.self_order_url = url
|
||||
|
||||
def action_generate_qr_code(self):
|
||||
"""Action to generate or print the QR code for this table"""
|
||||
# In a full implementation, we'd use report logic to print a PDF with the QR
|
||||
# For now, we'll expose the URL for testing
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': self.self_order_url,
|
||||
'target': 'new',
|
||||
}
|
||||
89
addons/dine360_self_order/static/src/css/self_order.css
Normal file
@ -0,0 +1,89 @@
|
||||
/* Self-Order UI Styling */
|
||||
|
||||
#self_order_app {
|
||||
font-family: 'Outfit', 'Inter', -apple-system, sans-serif;
|
||||
color: #171422;
|
||||
}
|
||||
|
||||
.transition-all {
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
border-radius: 14px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.product-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.08) !important;
|
||||
}
|
||||
|
||||
.product-card .card-img-top {
|
||||
border-top-left-radius: 14px;
|
||||
border-top-right-radius: 14px;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background-color: #FECD4F;
|
||||
border-color: #FECD4F;
|
||||
color: #171422;
|
||||
}
|
||||
|
||||
.btn-warning:hover,
|
||||
.btn-warning:active {
|
||||
background-color: #fdbd1a;
|
||||
border-color: #fdbd1a;
|
||||
}
|
||||
|
||||
.bg-warning-light {
|
||||
background-color: rgba(254, 205, 79, 0.08) !important;
|
||||
}
|
||||
|
||||
.service-select.active {
|
||||
border: 2px solid #FECD4F !important;
|
||||
background-color: rgba(254, 205, 79, 0.05) !important;
|
||||
}
|
||||
|
||||
.cart-item {
|
||||
border-bottom: 1px dashed #eee;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.cart-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
#category_filter button {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge-primary {
|
||||
background-color: #171422;
|
||||
}
|
||||
|
||||
#footer_cart {
|
||||
border-top-left-radius: 20px;
|
||||
border-top-right-radius: 20px;
|
||||
z-index: 1050;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
border: none;
|
||||
box-shadow: 0 -10px 40px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.spinner-border {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
259
addons/dine360_self_order/static/src/js/self_order.js
Normal file
@ -0,0 +1,259 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { jsonrpc } from "@web/core/network/rpc_service";
|
||||
|
||||
// We'll use a standard self-invoking function style since it's a public web module
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const app = document.querySelector('#self_order_app');
|
||||
if (!app) return;
|
||||
|
||||
const dataEl = document.querySelector('#self_order_data');
|
||||
const config = {
|
||||
tableId: dataEl.dataset.tableId,
|
||||
tableName: dataEl.dataset.tableName,
|
||||
};
|
||||
|
||||
const state = {
|
||||
products: [],
|
||||
cart: [],
|
||||
activeCategory: 'all',
|
||||
searchTerm: '',
|
||||
};
|
||||
|
||||
// --- UI Elements ---
|
||||
const productList = document.querySelector('#product_list');
|
||||
const categoryFilter = document.querySelector('#category_filter');
|
||||
const cartCount = document.querySelector('#cart_count');
|
||||
const cartTotal = document.querySelector('#cart_total');
|
||||
const footerCart = document.querySelector('#footer_cart');
|
||||
const loadingOverlay = document.querySelector('#loading_overlay');
|
||||
const contentArea = document.querySelector('#self_order_content');
|
||||
|
||||
// --- Init ---
|
||||
try {
|
||||
const products = await jsonrpc('/dine360/self_order/products', {});
|
||||
state.products = products;
|
||||
renderCategories();
|
||||
renderProducts();
|
||||
loadingOverlay.classList.add('d-none');
|
||||
contentArea.classList.remove('d-none');
|
||||
} catch (e) {
|
||||
console.error("Failed to load products", e);
|
||||
alert("Error connecting to server. Please try again later.");
|
||||
}
|
||||
|
||||
// --- Functions ---
|
||||
function renderCategories() {
|
||||
const categories = ['all', ...new Set(state.products.map(p => p.pos_categ_name))];
|
||||
categoryFilter.innerHTML = categories.map(cat => `
|
||||
<button class="btn btn-sm ${state.activeCategory === cat ? 'btn-warning fw-bold shadow-sm' : 'btn-white border'} rounded-pill px-3 py-2" data-category="${cat}">
|
||||
${cat === 'all' ? 'All Items' : cat}
|
||||
</button>
|
||||
`).join('');
|
||||
|
||||
categoryFilter.querySelectorAll('button').forEach(btn => {
|
||||
btn.onclick = () => {
|
||||
state.activeCategory = btn.dataset.category;
|
||||
renderCategories();
|
||||
renderProducts();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function renderProducts() {
|
||||
let filtered = state.products;
|
||||
if (state.activeCategory !== 'all') {
|
||||
filtered = filtered.filter(p => p.pos_categ_name === state.activeCategory);
|
||||
}
|
||||
if (state.searchTerm) {
|
||||
const term = state.searchTerm.toLowerCase();
|
||||
filtered = filtered.filter(p => p.display_name.toLowerCase().includes(term));
|
||||
}
|
||||
|
||||
productList.innerHTML = filtered.map(p => `
|
||||
<div class="col-6 col-md-4 col-lg-3">
|
||||
<div class="card h-100 border-0 shadow-sm product-card transition-all" data-id="${p.id}">
|
||||
<div class="position-relative">
|
||||
<img src="${p.image_url}" class="card-img-top rounded-top-4" alt="${p.display_name}" style="height: 140px; object-fit: cover; opacity: 1;"/>
|
||||
<div class="position-absolute bottom-0 end-0 p-1">
|
||||
<button class="btn btn-warning btn-sm rounded-circle add-to-cart-btn shadow" style="width: 32px; height: 32px; padding: 0;">
|
||||
<i class="fa fa-plus"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-3">
|
||||
<div class="fw-bold text-dark small mb-1 text-truncate">${p.display_name}</div>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span class="fw-bold text-primary">$${p.list_price.toFixed(2)}</span>
|
||||
<t t-if="p.is_kitchen_item">
|
||||
<i class="fa fa-fire text-danger small opacity-50"/>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
productList.querySelectorAll('.add-to-cart-btn').forEach(btn => {
|
||||
btn.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
const card = btn.closest('.product-card');
|
||||
const productId = parseInt(card.dataset.id);
|
||||
addToCart(productId);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function addToCart(productId) {
|
||||
const product = state.products.find(p => p.id === productId);
|
||||
const existing = state.cart.find(item => item.product_id === productId);
|
||||
if (existing) {
|
||||
existing.qty++;
|
||||
} else {
|
||||
state.cart.push({
|
||||
product_id: product.id,
|
||||
display_name: product.display_name,
|
||||
price_unit: product.list_price,
|
||||
qty: 1
|
||||
});
|
||||
}
|
||||
updateCartUI();
|
||||
}
|
||||
|
||||
function updateCartUI() {
|
||||
const count = state.cart.reduce((acc, item) => acc + item.qty, 0);
|
||||
const total = state.cart.reduce((acc, item) => acc + (item.qty * item.price_unit), 0);
|
||||
|
||||
cartCount.textContent = count;
|
||||
cartTotal.textContent = `$${total.toFixed(2)}`;
|
||||
|
||||
if (count > 0) {
|
||||
footerCart.classList.remove('d-none');
|
||||
setTimeout(() => footerCart.style.transform = 'translateY(0)', 10);
|
||||
} else {
|
||||
footerCart.style.transform = 'translateY(100%)';
|
||||
setTimeout(() => footerCart.classList.add('d-none'), 300);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Search ---
|
||||
document.querySelector('#product_search').oninput = (ev) => {
|
||||
state.searchTerm = ev.target.value;
|
||||
renderProducts();
|
||||
};
|
||||
|
||||
// --- Cart Modal ---
|
||||
const cartModal = new bootstrap.Modal(document.getElementById('cart_modal'));
|
||||
document.querySelector('#view_cart_btn').onclick = () => {
|
||||
renderCartModal();
|
||||
cartModal.show();
|
||||
};
|
||||
|
||||
function renderCartModal() {
|
||||
const list = document.querySelector('#cart_items_list');
|
||||
const total = state.cart.reduce((acc, item) => acc + (item.qty * item.price_unit), 0);
|
||||
|
||||
list.innerHTML = state.cart.map((item, index) => `
|
||||
<div class="d-flex align-items-center mb-4 cart-item">
|
||||
<div class="flex-grow-1">
|
||||
<div class="fw-bold mb-1">${item.display_name}</div>
|
||||
<div class="text-primary fw-bold">$${(item.qty * item.price_unit).toFixed(2)}</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2 bg-light rounded-pill p-1 border">
|
||||
<button class="btn btn-sm btn-white rounded-circle shadow-sm decrease-qty" data-index="${index}" style="width:28px; height:28px; padding:0;">-</button>
|
||||
<span class="px-2 fw-bold" style="min-width: 20px; text-align:center;">${item.qty}</span>
|
||||
<button class="btn btn-sm btn-white rounded-circle shadow-sm increase-qty" data-index="${index}" style="width:28px; height:28px; padding:0;">+</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
document.querySelector('#checkout_subtotal').textContent = `$${total.toFixed(2)}`;
|
||||
document.querySelector('#checkout_total').textContent = `$${total.toFixed(2)}`;
|
||||
|
||||
list.querySelectorAll('.increase-qty').forEach(btn => {
|
||||
btn.onclick = () => {
|
||||
state.cart[btn.dataset.index].qty++;
|
||||
renderCartModal();
|
||||
updateCartUI();
|
||||
};
|
||||
});
|
||||
list.querySelectorAll('.decrease-qty').forEach(btn => {
|
||||
btn.onclick = () => {
|
||||
const index = btn.dataset.index;
|
||||
state.cart[index].qty--;
|
||||
if (state.cart[index].qty <= 0) {
|
||||
state.cart.splice(index, 1);
|
||||
}
|
||||
if (state.cart.length === 0) cartModal.hide();
|
||||
renderCartModal();
|
||||
updateCartUI();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// --- Submit Order ---
|
||||
document.querySelector('#submit_order_btn').onclick = async () => {
|
||||
const btn = document.querySelector('#submit_order_btn');
|
||||
const originalText = btn.innerHTML;
|
||||
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"/>Sending...';
|
||||
|
||||
const fulfilmentType = document.querySelector('input[name="fulfilment"]:checked')?.value || 'dine_in';
|
||||
|
||||
const orderData = {
|
||||
table_id: config.tableId,
|
||||
fulfilment_type: fulfilmentType,
|
||||
lines: state.cart
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await jsonrpc('/dine360/self_order/submit_order', { order_data: orderData });
|
||||
if (response.success) {
|
||||
cartModal.hide();
|
||||
state.cart = [];
|
||||
updateCartUI();
|
||||
|
||||
// Success View
|
||||
document.querySelector('#self_order_content').innerHTML = `
|
||||
<div class="text-center py-5">
|
||||
<div class="mb-4">
|
||||
<div class="bg-success text-white d-inline-flex align-items-center justify-content-center rounded-circle shadow-lg" style="width: 100px; height: 100px;">
|
||||
<i class="fa fa-check fa-4x"/>
|
||||
</div>
|
||||
</div>
|
||||
<h2 class="fw-bold mb-3">Order Received!</h2>
|
||||
<p class="text-muted mb-4 px-4">${response.message}</p>
|
||||
<div class="bg-white p-4 rounded-4 shadow-sm mb-4">
|
||||
<div class="small text-muted mb-1">Order Number</div>
|
||||
<div class="h4 fw-bold text-dark mb-0">${response.order_name}</div>
|
||||
</div>
|
||||
<button onclick="window.location.reload()" class="btn btn-dark btn-lg px-5 rounded-pill shadow">
|
||||
Order More Items
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
alert(response.error || "Order submission failed");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("Connection lost. Please check your internet.");
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalText;
|
||||
}
|
||||
};
|
||||
|
||||
// Service Type Toggle
|
||||
document.querySelectorAll('.service-select').forEach(label => {
|
||||
label.onclick = () => {
|
||||
document.querySelectorAll('.service-select').forEach(l => l.classList.remove('active', 'border-warning', 'bg-warning-light'));
|
||||
label.classList.add('active', 'border-warning', 'bg-warning-light');
|
||||
};
|
||||
});
|
||||
});
|
||||
})();
|
||||
31
addons/dine360_self_order/views/restaurant_table_views.xml
Normal file
@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_restaurant_table_form_inherit_self_order" model="ir.ui.view">
|
||||
<field name="name">restaurant.table.form.inherit.self.order</field>
|
||||
<field name="model">restaurant.table</field>
|
||||
<field name="inherit_id" ref="pos_restaurant.view_restaurant_table_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<field name="seats" position="after">
|
||||
<field name="self_order_url" widget="url" readonly="1" string="Self-Order URL"/>
|
||||
</field>
|
||||
<xpath expr="//sheet" position="before">
|
||||
<header>
|
||||
<button name="action_generate_qr_code" string="Open Front-end" type="object" class="btn-primary"/>
|
||||
</header>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_restaurant_table_tree_self_order" model="ir.ui.view">
|
||||
<field name="name">restaurant.table.tree.self.order</field>
|
||||
<field name="model">restaurant.table</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Restaurant Tables">
|
||||
<field name="floor_id"/>
|
||||
<field name="name"/>
|
||||
<field name="seats"/>
|
||||
<field name="self_order_url" widget="url" readonly="1"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
124
addons/dine360_self_order/views/self_order_templates.xml
Normal file
@ -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>
|
||||