commit fc319c1f61af924696b86a777bd1f15dc7320e7e Author: Alaguraj0361 Date: Fri Jun 19 10:39:27 2026 +0530 first commit diff --git a/.agents/workflows/testing.md b/.agents/workflows/testing.md new file mode 100644 index 0000000..f5f12ab --- /dev/null +++ b/.agents/workflows/testing.md @@ -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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eb9851c --- /dev/null +++ b/.gitignore @@ -0,0 +1,96 @@ +# Python artifacts +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +.venv/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +.pytest_cache/ +.tox/ +.nox/ +.coverage +htmlcov/ +nosetests.xml +coverage.xml +*.cover +*.log + +# Odoo artifacts +*.log +odoo.conf +/data/ +/filestore/ +/sessions/ +/logs/ +/dump/ + +# OS artifacts +.DS_Store +Thumbs.db +ehthumbs.db +Desktop.ini +*.tmp +*.bak +*.swp +*.swo +*~ + +# IDEs +.idea/ +.vscode/ +.history/ +*.sublime-project +*.sublime-workspace + +# Debug/Temp scripts found in project +inspect_*.py +dump_*.py +debug_*.py +read_arch.py +resolve_homepage.py +fix_homepage.py +force_inherit.py +txt.py +update_error.txt +update_log.txt +addons/*.png +blog_posts_*.json + +# Local config/environment +.env +docker-compose.override.yml + +# Log / debug dump files +*.txt +*.log +*_logs.txt +shop_output.html +upgrade_log.txt + +# Database dumps +*.sql +*.sqlite +*.dump + +# One-off scripts (keep .ps1 compose scripts but ignore others) +cleanup_*.py +fix_*.py +test_*.py +temp_*.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..cb2d307 --- /dev/null +++ b/README.md @@ -0,0 +1,113 @@ +# Dine360 Odoo Addons_New by mohan1 + +This repository contains custom Odoo 17 addons for the Dine360 Restaurant Suite. It includes a website theme, a custom login/dashboard experience, and a restaurant role-based access module, all bundled by a meta module for one-click install. + +## Stack +- Odoo 17 (Docker) +- Postgres 15 (Docker) +- Addons mounted from `./addons` + +## Services (Docker) +- Odoo: `http://localhost:10001` +- DB: Postgres 15 (`odoo` / `odoo`) + +## Repository Layout +- `addons/` – Odoo addons +- `docker-compose.yml` – Odoo + Postgres stack +- `backup_db.ps1`, `export_odoo.ps1` – Windows helpers +- `inspect_views.py`, `resolve_homepage.py`, etc. – view debugging helpers + +## Addons + +### 1) `dine360_dashboard` +Custom login layout and app-grid dashboard landing page. + +Key features: +- Redirect `/web/login` to `/` after successful login +- Override `/` for authenticated users to show a custom app dashboard +- Custom login page layout and styling + +Key files: +- `addons/dine360_dashboard/controllers/main.py` +- `addons/dine360_dashboard/views/home_template.xml` +- `addons/dine360_dashboard/views/login_templates.xml` +- `addons/dine360_dashboard/views/web_title_template.xml` +- `addons/dine360_dashboard/views/website_logo.xml` +- `addons/dine360_dashboard/static/src/css/*` + +### 2) `dine360_restaurant` +Role-based access control for restaurant staff. + +Roles: +- Admin/Owner, Manager, Cashier, Waiter/Captain, Kitchen (KDS), Store Keeper + +Key files: +- `addons/dine360_restaurant/models/res_users.py` +- `addons/dine360_restaurant/security/*` +- `addons/dine360_restaurant/views/*` + +### 3) `dine360_theme_shivasakthi` +Custom website theme and page content (homepage + contact us). + +Key files: +- `addons/dine360_theme_shivasakthi/views/layout.xml` +- `addons/dine360_theme_shivasakthi/views/pages.xml` +- `addons/dine360_theme_shivasakthi/static/src/scss/*` +- `addons/dine360_theme_shivasakthi/static/src/img/*` + +### 4) `Dine360_Shivasakthi` (meta module) +Install this single module to pull in all required addons. + +Depends on: +- `dine360_dashboard` +- `dine360_restaurant` +- `dine360_theme_shivasakthi` + +## Standard Install / Upgrade + +### Start the stack +```bash +docker-compose up -d +``` + +### Update Apps list +- Apps -> Update Apps List + +### Install the suite (recommended) +- Apps -> search `Dine360 Restaurant Suite` -> Install + +### Upgrade the suite (after code changes) +```bash +docker exec odoo_client1 odoo -u Dine360_Shivasakthi -d shivasakthi_db --db_host db --db_user odoo --db_password odoo --stop-after-init +``` + +### Upgrade a single addon +```bash +docker exec odoo_client1 odoo -u dine360_dashboard -d shivasakthi_db --db_host db --db_user odoo --db_password odoo --stop-after-init +``` + +## Logos (Apps icons) +Place PNGs here (128x128 or 256x256 recommended): +- `addons/Dine360_Shivasakthi/static/description/icon.png` +- `addons/dine360_dashboard/static/description/icon.png` +- `addons/dine360_restaurant/static/description/icon.png` +- `addons/dine360_theme_shivasakthi/static/description/icon.png` + +## Troubleshooting + +### 500 error after view edits +- Upgrade the affected module +- Restart Odoo: `docker-compose restart odoo` +- Hard refresh browser (Ctrl + F5) + +### Old modules still present (home_dashboard, restaurant_management, theme_shivasakthi) +If you renamed modules, uninstall the old ones in Apps to avoid conflicts. + +## Utilities +Helper scripts for view diagnostics and homepage issues: +- `inspect_views.py`, `inspect_views_v2.py` +- `resolve_homepage.py`, `fix_homepage.py`, `force_inherit.py` + +## Notes +- Homepage content is fully overridden in `addons/dine360_theme_shivasakthi/views/pages.xml`. +- If theme changes don’t appear, check for COW (customized) views masking the theme. diff --git a/addons/Dine360_Shivasakthi/__init__.py b/addons/Dine360_Shivasakthi/__init__.py new file mode 100644 index 0000000..4c62977 --- /dev/null +++ b/addons/Dine360_Shivasakthi/__init__.py @@ -0,0 +1,2 @@ +# Meta module for Dine360 Shivasakthi +from .hooks import uninstall_hook diff --git a/addons/Dine360_Shivasakthi/__manifest__.py b/addons/Dine360_Shivasakthi/__manifest__.py new file mode 100644 index 0000000..224f026 --- /dev/null +++ b/addons/Dine360_Shivasakthi/__manifest__.py @@ -0,0 +1,44 @@ +{ + 'name': 'Dine360 Shivasakthi Restaurant Suite', + 'version': '1.0.0', + 'license': 'LGPL-3', + 'category': 'Website', + 'summary': 'Installs all Dine360 Shivasakthi Restaurant modules', + 'author': 'Dine360', + 'depends': [ + 'dine360_restaurant', + 'dine360_order_channels', + 'dine360_dashboard', + 'dine360_theme_shivasakthi', + 'dine360_kds', + 'dine360_reservation', + 'dine360_uber', + 'dine360_recipe', + 'dine360_self_order', + 'dine360_online_orders', + 'dine360_pos_navbar', + 'mail', + 'calendar', + 'contacts', + 'crm', + 'sale_management', + 'board', + 'point_of_sale', + 'account', + 'website', + 'purchase', + 'stock', + 'hr', + ], + 'uninstall_hook': 'uninstall_hook', + 'data': [ + 'views/apps_kanban_menu.xml', + ], + 'assets': { + 'web.assets_backend': [ + 'Dine360_Shivasakthi/static/src/css/apps_kanban_fix.css', + ], + }, + 'installable': True, + 'application': True, +} diff --git a/addons/Dine360_Shivasakthi/hooks.py b/addons/Dine360_Shivasakthi/hooks.py new file mode 100644 index 0000000..d4f9da2 --- /dev/null +++ b/addons/Dine360_Shivasakthi/hooks.py @@ -0,0 +1,24 @@ +from odoo import api, SUPERUSER_ID + +def uninstall_hook(env): + """ + Synchronized uninstallation: When Dine360 Shivasakthi Restaurant Suite is uninstalled, + automatically trigger uninstallation for all its core sub-modules. + """ + modules_to_uninstall = [ + 'dine360_dashboard', + 'dine360_restaurant', + 'dine360_theme_shivasakthi', + 'dine360_kds', + 'dine360_reservation' + ] + + # Search for these modules if they are installed + modules = env['ir.module.module'].search([ + ('name', 'in', modules_to_uninstall), + ('state', '=', 'installed') + ]) + + if modules: + # Mark modules for uninstallation + modules.button_uninstall() diff --git a/addons/Dine360_Shivasakthi/static/src/css/apps_kanban_fix.css b/addons/Dine360_Shivasakthi/static/src/css/apps_kanban_fix.css new file mode 100644 index 0000000..7418207 --- /dev/null +++ b/addons/Dine360_Shivasakthi/static/src/css/apps_kanban_fix.css @@ -0,0 +1,31 @@ +/* Fix Apps kanban icon sizing and dropdown overlap */ +.o_modules_kanban .oe_module_vignette, +.o_modules_kanban .o_kanban_record { + overflow: visible; +} + +.o_modules_kanban .o_kanban_record { + position: relative; + z-index: 1; +} + +.o_modules_kanban .oe_module_icon { + width: 64px; + height: 64px; + display: flex; + align-items: center; + justify-content: center; +} + +.o_modules_kanban .oe_module_icon img { + width: 64px; + height: 64px; + object-fit: contain; +} + +.o_modules_kanban .oe_module_vignette .dropdown-menu { + right: 0; + left: auto; + min-width: 160px; + z-index: 1060; +} diff --git a/addons/Dine360_Shivasakthi/views/apps_kanban_menu.xml b/addons/Dine360_Shivasakthi/views/apps_kanban_menu.xml new file mode 100644 index 0000000..49eb1ab --- /dev/null +++ b/addons/Dine360_Shivasakthi/views/apps_kanban_menu.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/addons/dine360_dashboard/__init__.py b/addons/dine360_dashboard/__init__.py new file mode 100644 index 0000000..8f97342 --- /dev/null +++ b/addons/dine360_dashboard/__init__.py @@ -0,0 +1,4 @@ +# dine360_dashboard/__init__.py +from . import controllers +from . import models + diff --git a/addons/dine360_dashboard/__manifest__.py b/addons/dine360_dashboard/__manifest__.py new file mode 100644 index 0000000..62e7e1b --- /dev/null +++ b/addons/dine360_dashboard/__manifest__.py @@ -0,0 +1,38 @@ +{ + 'name': 'Dine360 Dashboard', + 'version': '1.0.2', + 'license': 'LGPL-3', + 'category': 'Website', + 'summary': 'Redirect login to home and show icon grid', + 'depends': ['base', 'web', 'auth_signup', 'website', 'website_sale'], + 'data': [ + 'views/home_template.xml', + 'views/login_templates.xml', + 'views/web_title_template.xml', + 'views/website_logo.xml', + 'views/shop_template.xml', + 'data/branding_data.xml', + ], + 'assets': { + 'web.assets_backend': [ + 'dine360_dashboard/static/src/css/theme_variables.css', + 'dine360_dashboard/static/src/css/home_menu.css', + 'dine360_dashboard/static/src/js/chennora_title.js', + 'dine360_dashboard/static/src/xml/navbar_extension.xml', + ], + 'web.assets_frontend': [ + 'dine360_dashboard/static/src/css/theme_variables.css', + 'dine360_dashboard/static/src/css/login_style.css', + 'dine360_dashboard/static/src/css/website_style.css', + 'dine360_dashboard/static/src/css/shop_style.css', + ], + 'web.assets_common': [ + 'dine360_dashboard/static/src/css/theme_variables.css', + ], + 'point_of_sale._assets_pos': [ + 'dine360_dashboard/static/src/css/pos_style.css', + ], + }, + 'installable': True, + 'application': True, +} diff --git a/addons/dine360_dashboard/controllers/__init__.py b/addons/dine360_dashboard/controllers/__init__.py new file mode 100644 index 0000000..e402e9d --- /dev/null +++ b/addons/dine360_dashboard/controllers/__init__.py @@ -0,0 +1,4 @@ +# dine360_dashboard/controllers/__init__.py +from . import main +from . import cors + diff --git a/addons/dine360_dashboard/controllers/cors.py b/addons/dine360_dashboard/controllers/cors.py new file mode 100644 index 0000000..5b43d33 --- /dev/null +++ b/addons/dine360_dashboard/controllers/cors.py @@ -0,0 +1,15 @@ +from odoo import http +from odoo.http import request +import json + +class CorsHandler(http.Controller): + # This specifically targets the authentication endpoint for CORS + @http.route('/web/session/authenticate', type='json', auth="none", cors="*") + def authenticate_cors(self, db, login, password, base_location=None): + request.session.authenticate(db, login, password) + return request.env['ir.http'].session_info() + + # Generic search_read for the dashboard apps + @http.route('/web/dataset/call_kw', type='json', auth="user", cors="*") + def call_kw_cors(self, model, method, args, kwargs, path=None): + return request.env[model].with_user(request.uid).call_kw(method, args, kwargs) diff --git a/addons/dine360_dashboard/controllers/main.py b/addons/dine360_dashboard/controllers/main.py new file mode 100644 index 0000000..e40ddd0 --- /dev/null +++ b/addons/dine360_dashboard/controllers/main.py @@ -0,0 +1,140 @@ +from odoo import http +from odoo.http import request +from odoo.addons.web.controllers.home import Home + +class CustomHome(Home): + @http.route('/web/login', type='http', auth="public", website=True) + def web_login(self, *args, **kw): + response = super(CustomHome, self).web_login(*args, **kw) + if request.params.get('login_success') and request.session.uid: + # Use relative redirect to maintain HTTPS/HTTP protocol + return request.redirect('/') + return response + +from odoo.addons.website.controllers.main import Website + +class ImageHome(Website): + @http.route('/', type='http', auth='public', website=True, sitemap=True) + def index(self, **kwargs): + # ----------------------------------------------------------- + # SUPER SAFE EDITOR & IFRAME DETECTION + # ----------------------------------------------------------- + path = request.httprequest.path + params = request.params + headers = request.httprequest.headers + referer = headers.get('Referer', '') + fetch_dest = headers.get('Sec-Fetch-Dest', '') + + # 1. If not logged in, always show standard homepage + if not request.session.uid: + return super(ImageHome, self).index(**kwargs) + + # 2. ROLE-BASED AUTO REDIRECTION (FOR STAFF) + # Skip the dashboard/website entirely for Chefs and Waiters + user = request.env.user.sudo() + is_admin = user.has_group('base.group_system') or \ + user.has_group('dine360_restaurant.group_restaurant_admin') + + if not is_admin: + # 1. WAITER / CASHIER -> Priority goes to POS + if user.has_group('dine360_restaurant.group_restaurant_waiter') or \ + user.has_group('dine360_restaurant.group_restaurant_cashier'): + return request.redirect('/web#action=point_of_sale.action_client_pos_menu') + + # 2. CHEF -> Directly to KDS + if user.has_group('dine360_restaurant.group_restaurant_kitchen'): + return request.redirect('/web#action=dine360_kds.action_kds_dashboard') + + # 3. SUPER SAFE EDITOR & IFRAME DETECTION + path = request.httprequest.path + params = request.params + headers = request.httprequest.headers + referer = headers.get('Referer', '') + fetch_dest = headers.get('Sec-Fetch-Dest', '') + + # Check for ANY editor or backend signal + editor_params = ['enable_editor', 'edit', 'path', 'website_id', 'frontend_edit', 'model', 'id'] + is_editor_request = any(p in params for p in editor_params) + is_from_backend = any(m in referer for m in ['/website/force', 'enable_editor']) + + # if it looks like Odoo internal business, return the real website + if fetch_dest == 'iframe' or is_editor_request or is_from_backend: + return super(ImageHome, self).index(**kwargs) + + if path != '/': + return super(ImageHome, self).index(**kwargs) + + # Remove sudo() to respect Odoo's standard menu group restrictions + menus = request.env['ir.ui.menu'].search([ + ('parent_id', '=', False) + ], order='sequence') + + # User role checks + try: + is_admin = request.env.user.has_group('base.group_system') or \ + request.env.user.has_group('dine360_restaurant.group_restaurant_admin') + is_kitchen = request.env.user.has_group('dine360_restaurant.group_restaurant_kitchen') + except Exception: + is_admin = request.env.user.has_group('base.group_system') + is_kitchen = False + + # User requested to hide all standard apps and POS/KDS. + # Only allow specific menus based on user request + admin tools. + allowed_menus = ['Online Orders', 'Website', 'Table Reservations', 'Uber Integration', 'Apps', 'Settings'] + + filtered_menus = [] + seen_names = set() + for menu in menus: + # Match strictly against allowed menus + if menu.name not in allowed_menus and menu.name != 'Table Reservation': + continue + + # Hide "Apps" and "Settings" for non-admins + if menu.name in ['Apps', 'Settings'] and not is_admin: + continue + + # De-duplicate by name + if menu.name in seen_names: + continue + seen_names.add(menu.name) + + # Dynamic Icon Override (Dine360 Branding) + icon_mapping = { + 'Apps': 'dine360_dashboard,static/src/img/icons/apps.svg', + 'Settings': 'dine360_dashboard,static/src/img/icons/settings.svg', + 'Table Reservation': 'dine360_dashboard,static/src/img/icons/table_reservation.svg', + 'Table Reservations': 'dine360_dashboard,static/src/img/icons/table_reservation.svg', + 'Uber Integration': 'dine360_dashboard,static/src/img/icons/uber_integration.svg', + 'Online Orders': 'dine360_dashboard,static/src/img/icons/website.svg', + } + + # Find the best match in the mapping + current_name = menu.name + for key, icon_path in icon_mapping.items(): + if key.lower() in current_name.lower(): + menu.web_icon = icon_path + break + + filtered_menus.append(menu) + + # Low Stock Alerts (Ingredients) + low_stock_products = [] + try: + ProductTemplate = request.env['product.template'].sudo() + if hasattr(ProductTemplate, 'get_low_stock_products'): + low_stock_products = ProductTemplate.get_low_stock_products(limit=5) + except Exception: + low_stock_products = [] + + return request.render('dine360_dashboard.image_home_template', { + 'menus': filtered_menus, + 'user_id': request.env.user, + 'low_stock_products': low_stock_products + }) + + + + @http.route('/home', type='http', auth="public", website=True, sitemap=True) + def website_home(self, **kw): + # Explicit route for standard Website Homepage + return request.render('website.homepage') diff --git a/addons/dine360_dashboard/data/branding_data.xml b/addons/dine360_dashboard/data/branding_data.xml new file mode 100644 index 0000000..3b62c4c --- /dev/null +++ b/addons/dine360_dashboard/data/branding_data.xml @@ -0,0 +1,17 @@ + + + + + web.base_title + Shivasakthi + + + + + +1(647)856-2878 + + + + + diff --git a/addons/dine360_dashboard/models/__init__.py b/addons/dine360_dashboard/models/__init__.py new file mode 100644 index 0000000..7930c4e --- /dev/null +++ b/addons/dine360_dashboard/models/__init__.py @@ -0,0 +1 @@ +from . import ir_ui_menu diff --git a/addons/dine360_dashboard/models/ir_ui_menu.py b/addons/dine360_dashboard/models/ir_ui_menu.py new file mode 100644 index 0000000..38d6e81 --- /dev/null +++ b/addons/dine360_dashboard/models/ir_ui_menu.py @@ -0,0 +1,34 @@ +from odoo import models, api + +class IrUiMenu(models.Model): + _inherit = 'ir.ui.menu' + + @api.model + def load_menus(self, debug): + """ + Override standard menu loading to hide unwanted root menus. + """ + menus = super(IrUiMenu, self).load_menus(debug) + + user = self.env.user + is_admin = user.has_group('base.group_system') + + allowed_menus = ['Online Orders', 'Website', 'Table Reservations', 'Uber Integration', 'Apps', 'Settings'] + + if 'root' in menus and 'children' in menus['root']: + new_children = [] + for child_id in menus['root']['children']: + child_menu = menus.get(child_id) + if not child_menu: + continue + name = child_menu.get('name') + + # Allow matching menus (with fallback for Table Reservation naming variations) + if name in allowed_menus or name == 'Table Reservation': + # Hide Apps and Settings for non-admins + if name in ['Apps', 'Settings'] and not is_admin: + continue + new_children.append(child_id) + menus['root']['children'] = new_children + + return menus diff --git a/addons/dine360_dashboard/static/description/icon.png b/addons/dine360_dashboard/static/description/icon.png new file mode 100644 index 0000000..0ece662 Binary files /dev/null and b/addons/dine360_dashboard/static/description/icon.png differ diff --git a/addons/dine360_dashboard/static/src/css/home_menu.css b/addons/dine360_dashboard/static/src/css/home_menu.css new file mode 100644 index 0000000..22a97be --- /dev/null +++ b/addons/dine360_dashboard/static/src/css/home_menu.css @@ -0,0 +1,176 @@ +/* Main background */ +body.o_home_dashboard, +#wrapwrap.o_home_dashboard, +.o_home_menu_background { + background: linear-gradient(rgb(0 0 0 / 83%), rgb(0 0 0 / 83%)), + url('/dine360_dashboard/static/src/img/dashboard_bg.png') no-repeat center center !important; + background-size: cover !important; + background-attachment: fixed !important; + min-height: 100vh !important; + padding: 0 !important; + margin: 0 !important; + font-family: 'Inter', 'Segoe UI', Roboto, sans-serif !important; + position: relative !important; +} + +#wrapwrap.o_home_dashboard #wrap { + background: transparent !important; +} + +.o_apps { + display: flex !important; + flex-wrap: wrap !important; + justify-content: center !important; + gap: 40px 50px !important; + max-width: 1000px !important; + margin: 0 auto !important; + padding: 20px !important; +} + +.o_app { + display: flex !important; + flex-direction: column !important; + align-items: center !important; + text-decoration: none !important; + transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) !important; + width: 120px !important; +} + +.o_app_icon_container { + background: #ffffff !important; + width: 90px !important; + height: 90px !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + border-radius: 24px !important; + box-shadow: + 0 4px 6px -1px rgba(0, 0, 0, 0.05), + 0 2px 4px -1px rgba(0, 0, 0, 0.03) !important; + margin-bottom: 12px !important; + position: relative !important; + overflow: hidden; + border: 1px solid rgba(0, 0, 0, 0.03) !important; + transition: all 0.4s ease !important; +} + +.o_app:hover .o_app_icon_container { + transform: translateY(-10px) rotate(2deg) !important; + background: #ffffff !important; + border-bottom: 2px solid #d6111e !important; + box-shadow: + 0 20px 25px -5px rgba(214, 17, 30, 0.2), + 0 10px 10px -5px rgba(23, 20, 34, 0.1) !important; +} + +.o_app_icon_container::before { + content: ""; + --pos-accent-red: #d6111e; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: linear-gradient(135deg, rgb(175 36 36 / 62%) 0%, rgb(181 84 84 / 20%) 50%); + z-index: 1; +} + +.o_app_icon { + width: 56px !important; + height: 56px !important; + object-fit: contain !important; + z-index: 2; + transition: transform 0.3s ease !important; +} + +.o_app:hover .o_app_icon { + transform: scale(1.1) !important; +} + +.o_app_name { + color: #ffffff !important; + font-size: 14px !important; + font-weight: 500 !important; + text-align: center !important; + letter-spacing: -0.2px !important; + transition: color 0.3s ease !important; +} + +.o_app:hover .o_app_name { + color: #d6111e !important; + font-weight: 700 !important; +} + +.o_home_top_bar { + position: fixed !important; + top: 25px !important; + left: 0; + right: 0; + display: flex !important; + justify-content: center !important; + z-index: 1000 !important; +} + +.o_top_bar_island { + display: flex !important; + align-items: center !important; + gap: 12px !important; + padding: 10px 25px !important; + background: rgba(0, 0, 0, 0.9) !important; + backdrop-filter: blur(15px) !important; + -webkit-backdrop-filter: blur(15px); + border-radius: 50px !important; + border-color: #d6111e !important; + box-shadow: 0 0 0 3px rgba(214, 17, 30, 0.15) !important; + box-shadow: 0 12px 25px rgba(214, 17, 30, 0.3) !important; +} + +.o_top_item { + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + color: #ffffff !important; + transition: all 0.3s ease; + cursor: pointer; + position: relative; + text-decoration: none !important; +} + +.o_top_item:hover { + color: #d6111e !important; + transform: translateY(-3px); +} + +.o_bar_divider { + width: 1px; + height: 24px; + background: rgba(255, 255, 255, 0.2); + margin: 0 10px; +} + +.o_ai_icon { + font-weight: 800; + color: #d6111e !important; +} + +.badge_dot { + position: absolute !important; + top: 8px; + right: 8px; + width: 8px; + height: 8px; + background: #d6111e !important; + border-radius: 50% !important; + border: 2px solid white !important; +} + +.o_user_avatar { + width: 35px !important; + height: 35px !important; + border-radius: 12px !important; + background: #d6111e !important; + color: white !important; + font-weight: bold !important; +} \ No newline at end of file diff --git a/addons/dine360_dashboard/static/src/css/login_style.css b/addons/dine360_dashboard/static/src/css/login_style.css new file mode 100644 index 0000000..352e243 --- /dev/null +++ b/addons/dine360_dashboard/static/src/css/login_style.css @@ -0,0 +1,279 @@ +/* Container: Full Screen Split */ +.o_login_main_wrapper { + display: flex !important; + height: 100vh !important; + width: 100vw !important; + overflow: hidden; + background: linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0.6)), url('/dine360_theme_shivasakthi/static/src/img/chen-banner-2.webp') !important; + background-repeat: no-repeat !important; + background-position: center center !important; + background-size: cover !important; +} + +/* Left Side: Background Image and Branding */ +.o_login_left_side { + flex: 1.5 !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + padding: 60px !important; + position: relative; + /* Dark overlay on image for text readability */ + /* background: linear-gradient(rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5)), + url('/dine360_dashboard/static/src/img/login_bg.png') no-repeat center center !important; + background-size: cover !important; */ + color: white !important; +} + +.o_login_content { + text-align: center; + max-width: 80%; +} + +.o_login_content h1 { + font-size: 3.8rem !important; + font-weight: 800 !important; + margin-bottom: 20px; + text-shadow: 2px 4px 15px rgba(0, 0, 0, 0.6) !important; +} + +.o_login_content p { + font-size: 1.4rem !important; + opacity: 0.9; +} + +/* Right Side: Form Section */ +.o_login_right_side { + flex: 1 !important; + /* background: #ffffff !important; */ + display: flex !important; + justify-content: center !important; + align-items: center !important; + padding: 40px !important; + position: relative; +} + +.o_login_card_wrapper { + width: 60% +} + +/* Glassmorphism Card UI */ +.o_login_card { + width: 100% !important; + max-width: 450px !important; + padding: 40px !important; + background: rgba(255, 255, 255, 0.05) !important; + backdrop-filter: blur(15px) !important; + -webkit-backdrop-filter: blur(15px) !important; + border-radius: 20px !important; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5) !important; + border: 1px solid rgba(255, 255, 255, 0.1) !important; + z-index: 2; +} + +/* Header Text Gradient */ +.o_login_header_text h3 { + font-size: 2rem !important; + font-weight: 800 !important; + margin-bottom: 10px; + color: #d6111e !important; +} + +/* Logo */ +.o_login_logo_container { + text-align: center; + margin-bottom: 30px; +} + +.o_login_logo_container img { + max-height: 120px; + /* Larger logo */ + width: auto; + filter: drop-shadow(0 0 10px rgba(255, 255, 255, 0.2)); +} + +/* Form Inputs */ +.form-control:focus { + border-color: #FECD4F !important; + box-shadow: 0 0 0 3px rgba(254, 205, 79, 0.2) !important; + background: #ffffff !important; +} + +.o_login_form_container label { + color: #ffffff !important; + margin-bottom: 8px; + font-weight: 500; +} + +.o_login_form_container a, +.o_login_form_container .btn-link { + color: #ffffff !important; +} + +.form-control { + background: rgba(255, 255, 255, 0.1) !important; + border: 1px solid rgba(255, 255, 255, 0.3) !important; + border-radius: 12px !important; + padding: 12px 15px !important; + height: auto !important; + color: white !important; +} + +.form-control::placeholder { + color: rgba(255, 255, 255, 0.6) !important; +} + +/* Accessibility contrast for error messages */ +.alert-danger { + background-color: rgba(220, 53, 69, 0.9) !important; + color: white !important; + border: none !important; +} + +/* Login Button with Gradient */ +.oe_login_buttons .btn-primary { + background: #FECD4F !important; + border: none !important; + border-radius: 12px !important; + padding: 14px !important; + font-weight: 700 !important; + font-size: 1rem !important; + color: #04121D !important; + width: 100% !important; + transition: all 0.3s ease !important; + box-shadow: 0 4px 15px rgba(254, 205, 79, 0.3) !important; +} + +.oe_login_buttons .btn-primary:hover { + background: #04121D !important; + color: #FECD4F !important; + transform: translateY(-2px); + box-shadow: 0 8px 20px rgba(254, 205, 79, 0.4) !important; +} + + +/* Responsive Handling */ +@media (max-width: 992px) { + .o_login_main_wrapper { + flex-direction: column; + } + + .o_login_left_side { + display: none !important; + } + + .o_login_right_side { + background: #0f172a !important; + } + + .o_login_card_wrapper { + width: 90% !important; + } + + .oe_website_login_container .oe_login_form, + .oe_website_login_container .oe_signup_form, + .oe_website_login_container .oe_reset_password_form { + width: 90% !important; + } +} + +/* Hide Website Header/Footer ONLY on Login, Signup, Reset Password */ +body.o_custom_login_body header, +body.o_custom_login_body footer, +body.o_custom_login_body .o_footer_copyright, +body.o_custom_login_body #o_main_nav, +body.o_custom_login_body .o_header_standard, +body.o_custom_login_body #wrapwrap>header, +body.o_custom_login_body #wrapwrap>footer { + display: none !important; + height: 0 !important; + width: 0 !important; + visibility: hidden !important; + opacity: 0 !important; + position: absolute !important; + pointer-events: none !important; +} + +/* Ensure wrapwrap doesn't have padding/margin from header on these pages */ +body.o_custom_login_body #wrapwrap { + padding-top: 0 !important; + margin-top: 0 !important; +} + +/* Ensure the wrapper covers the entire screen, ignoring website container constraints */ +body.o_custom_login_body .o_login_main_wrapper { + position: fixed !important; + top: 0; + left: 0; + width: 100vw !important; + height: 100vh !important; + z-index: 9999; + /* Ensure it stays on top */ + margin: 0 !important; + padding: 0 !important; +} + +/* Custom Footer */ +.o_login_footer_custom { + position: absolute; + bottom: 0; + right: 0; + width: 50%; + /* Only on the right side which is 50% usually, or just right side */ + text-align: center; + padding: 15px; + background: transparent; + color: #6c757d; + z-index: 10; + font-size: 0.9rem; +} + +@media (max-width: 992px) { + .o_login_footer_custom { + width: 100%; + color: white; + /* Visible on gradient background */ + } +} + +.o_login_footer_custom a { + color: #d6111e; + text-decoration: none; + font-weight: 600; +} + +.o_login_footer_custom a:hover { + color: #171422; +} + + +/* website login screen */ +.oe_website_login_container { + display: flex !important; + justify-content: center !important; + align-items: center !important; + height: 100vh !important; + /* width: 100vw !important; */ + overflow: hidden !important; + background: linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0.6)), url('/dine360_theme_shivasakthi/static/src/img/chen-banner-2.webp') no-repeat center center !important; + background-size: cover !important; +} + +.oe_website_login_container .oe_login_form, +.oe_website_login_container .oe_signup_form, +.oe_website_login_container .oe_reset_password_form { + width: 60% !important; + padding: 40px !important; + background: rgba(255, 255, 255, 0.05) !important; + backdrop-filter: blur(15px) !important; + -webkit-backdrop-filter: blur(15px) !important; + border-radius: 20px !important; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5) !important; + border: 1px solid rgba(255, 255, 255, 0.1) !important; + z-index: 2; + max-width: 400px; +} +.oe_website_login_container label, +.oe_website_login_container a { + color: #ffffff !important; +} diff --git a/addons/dine360_dashboard/static/src/css/pos_style.css b/addons/dine360_dashboard/static/src/css/pos_style.css new file mode 100644 index 0000000..3e750c4 --- /dev/null +++ b/addons/dine360_dashboard/static/src/css/pos_style.css @@ -0,0 +1,446 @@ +/* ======================================== + SHIVASAKTHI RUSH MODE - POS THEME (ULTRA PREMIUM) + ======================================== */ + +:root { + --pos-primary: #171422; + /* Secondary Dark */ + --pos-secondary: #d6111e; + /* Main Teal */ + --pos-accent-orange: #e66421; + /* Biryani Orange */ + --pos-accent-gold: #cc9900; + /* Tandoor Gold */ + --pos-accent-red: #d6111e; + /* Main Teal */ + --pos-bg-main: #f8fafc; + /* Ultra light gray */ + --pos-card-dark: #1a1d23; + /* Dark Card Background */ + --pos-text-light: #ffffff; + --pos-text-dark: #111827; + --border-radius-xl: 24px; + --border-radius-lg: 16px; +} + +/* 1. Main POS Layout Swapping */ +/* Standard Odoo: Leftpane = Cart, Rightpane = Products */ +/* Goal: Food List on Left, Orders/Calculations on Right */ +/* .pos .pos-content, +.pos .product-screen { + display: flex !important; + flex-direction: row-reverse !important; */ +/* SWAP Panes */ +/* height: calc(100vh - 85px) !important; +} */ + +.pos .leftpane { + /* The Cart Section - Now on Right */ + width: 480px !important; + border-left: 2px solid #f1f5f9 !important; + border-right: none !important; + background: #ffffff !important; + display: flex !important; + flex-direction: column !important; + flex: none !important; + height: calc(100vh - 85px) !important; + overflow: hidden !important; + z-index: 100 !important; +} + +.pos .order-container { + flex: 1 1 auto !important; + overflow-y: auto !important; + background: #ffffff !important; + min-height: 0 !important; +} + +.product-screen { + display: flex !important; + flex-direction: row-reverse !important; + height: calc(100vh - 85px) !important; + overflow: hidden !important; +} + +.pos .rightpane { + /* The Product Grid Section - Now on Left */ + flex: 1 1 auto !important; + background: #f8fafc !important; + height: 100% !important; + display: flex !important; + flex-direction: column !important; + overflow: hidden !important; +} + +.pos .product-list-container { + flex: 1 1 auto !important; + overflow-y: auto !important; + background: #f8fafc !important; +} + +/* 2. Header & Branding */ +.pos .pos-topheader { + background: #171422 !important; + border-bottom: 2px solid #d6111e !important; + color: #fff !important; + height: 85px !important; + display: flex !important; + align-items: center !important; + padding: 0 30px !important; +} + +/* Logo from Odoo Settings */ +.pos .pos-logo { + display: flex !important; + align-items: center !important; + gap: 15px !important; + margin-right: 50px !important; + width: auto !important; +} + +.pos .pos-logo img, +.pos .pos-logo { + content: url('/web/binary/company_logo') !important; + height: 55px !important; + width: auto !important; + object-fit: contain !important; +} + +/* "Rush Mode" Label removed for production */ + +/* 3. Search Bar - Teal Theme */ +.pos .search-bar { + background: #e0f2f1 !important; + border-radius: var(--border-radius-lg) !important; + margin: 0 !important; + width: 380px !important; + height: 50px !important; + display: flex !important; + align-items: center !important; + padding: 0 20px !important; +} + +.pos .search-bar input { + background: transparent !important; + border: none !important; + font-weight: 800 !important; + color: #00695c !important; + font-size: 16px !important; + text-transform: uppercase !important; +} + +/* 4. Category Bar - Fix Truncation */ +.pos .category-list { + display: flex !important; + gap: 12px !important; + padding: 15px 30px !important; + background: #ffffff !important; + border-bottom: 2px solid #f1f5f9 !important; +} + +.pos .category-button { + border-radius: var(--border-radius-lg) !important; + font-weight: 900 !important; + text-transform: uppercase !important; + padding: 12px 30px !important; + font-size: 14px !important; + border: none !important; + min-width: 140px !important; + /* STOP MAI... truncation */ + white-space: nowrap !important; + background: #f1f5f9 !important; + color: #475569 !important; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05) !important; +} + +.pos .category-button.active { + background: #d6111e !important; + color: #ffffff !important; +} + +/* Dynamic Colors for specific indexes */ +.pos .category-button:nth-child(2) { + background: var(--pos-accent-orange) !important; + color: white !important; +} + +.pos .category-button:nth-child(3) { + background: var(--pos-accent-gold) !important; + color: white !important; +} + +/* 5. Product Cards - Dark Premium */ +.pos .product-list-container { + background: #f8fafc !important; +} + +.pos .product-list { + display: flex !important; + flex-wrap: wrap !important; + padding: 25px !important; + gap: 25px !important; +} + +.pos .product { + background: #171422 !important; + border-radius: var(--border-radius-lg) !important; + width: 210px !important; + height: 230px !important; + flex: 0 0 auto !important; + flex-shrink: 0 !important; + border: none !important; + box-shadow: 0 2px 4px rgba(42, 106, 126, 0.05) !important; + transition: all 0.3s ease !important; + position: relative !important; + overflow: hidden !important; +} + +.pos .product:hover { + transform: translateY(-8px) scale(1.02) !important; +} + +.pos .product .product-img { + width: 100% !important; + height: 150px !important; + object-fit: cover !important; +} + +.product-img img { + width: 100% !important; + height: 100% !important; + /* object-fit: cover !important; */ +} + +.pos .product .product-name { + color: #ffffff !important; + font-size: 16px !important; + font-weight: 700 !important; + padding: 12px 15px 5px 15px !important; + background: transparent !important; +} + +.pos .product .price-tag { + background: transparent !important; + color: #ffffff !important; + font-size: 15px !important; + font-weight: 600 !important; + position: relative !important; + bottom: 5px !important; + left: 15px !important; + top: auto !important; + right: auto !important; + padding: 0 !important; +} + +/* 6. Order (Cart) Styling - Now on Right */ +.pos .order-container { + background: #ffffff !important; + padding: 10px 0 !important; +} + +.pos .orderline { + border-radius: var(--border-radius-lg) !important; + border: 2px solid #f1f5f9 !important; + margin: 8px 20px !important; + padding: 15px !important; + background: white !important; + transition: all 0.2s ease !important; +} + +.pos .orderline.selected { + border: 2px solid var(--pos-secondary) !important; + background: #f0fdf4 !important; + box-shadow: 0 4px 12px rgba(16, 185, 129, 0.1) !important; +} + +.pos .orderline .product-name { + font-weight: 900 !important; + font-size: 17px !important; + color: #111827 !important; +} + +/* 7. Summary & Pay Button */ +.pos .order-summary { + background: #ffffff !important; + padding: 25px 30px !important; + border-top: 2px dashed #e2e8f0 !important; +} + +.pos .summary .total { + color: var(--pos-secondary) !important; + font-size: 40px !important; + font-weight: 900 !important; + letter-spacing: -1px; +} + +.pos .action-pad .button.pay { + background: var(--pos-secondary) !important; + color: white !important; + border-radius: var(--border-radius-lg) !important; + margin: 20px !important; + height: 75px !important; + font-size: 24px !important; + font-weight: 900 !important; + text-transform: uppercase !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + box-shadow: 0 12px 25px rgba(214, 17, 30, 0.3) !important; + border: none !important; +} + +.pos .action-pad .button.pay::after { + content: "\2794"; + margin-left: 15px; + font-size: 30px; +} + +/* 8. Numpad styling */ +.pos .numpad { + background: #f8fafc !important; + padding: 20px !important; +} + +.pos .numpad button { + border-radius: 12px !important; + border: 1px solid #e2e8f0 !important; + background: white !important; + height: 55px !important; + font-weight: 700 !important; + font-size: 14px !important; +} + +/* 9. Receipt Screen & New Order Button - Ultra Aggressive Theme Match */ +.pos .button.next, +.pos .button.validation, +.pos .receipt-screen .button.next, +.pos .receipt-screen .validation.button, +.pos .receipt-screen .button { + background: #d61112 !important; + color: white !important; + border-radius: var(--border-radius-lg) !important; + height: 70px !important; + font-size: 22px !important; + font-weight: 900 !important; + text-transform: uppercase !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + box-shadow: 0 10px 20px rgba(214, 17, 18, 0.2) !important; + border: none !important; +} + +.pos .receipt-screen .button.next:active { + transform: scale(0.98); +} + +/* 10. Premium Bill UI (Right Side) */ +.pos-receipt { + font-family: 'Inter', sans-serif !important; + color: #1a1d23 !important; + padding: 30px !important; + background: #fff !important; +} + +.pos-receipt .pos-receipt-contact { + font-size: 13px !important; + color: #64748b !important; + margin-bottom: 20px !important; +} + +.pos-receipt .pos-receipt-center-align { + text-align: center !important; + font-weight: 700 !important; +} + +.pos-receipt .pos-receipt-order-data { + color: #94a3b8 !important; + font-size: 12px !important; + margin-top: 10px !important; +} + +.pos-receipt .receipt-orderlines { + border-top: 2px solid #f1f5f9 !important; + padding-top: 15px !important; +} + +.pos-receipt .orderline { + border: none !important; + margin: 0 !important; + padding: 8px 0 !important; + border-bottom: 1px dashed #e2e8f0 !important; + display: flex !important; + flex-wrap: wrap !important; + justify-content: space-between !important; + align-items: baseline !important; +} + +.pos-receipt .orderline .product-name { + flex: 1 1 65% !important; + font-weight: 700 !important; + white-space: normal !important; + word-break: break-word !important; + padding-right: 5px !important; +} + +.pos-receipt .orderline .pos-receipt-right-align { + flex: 0 0 auto !important; + text-align: right !important; + font-weight: 800 !important; +} + +.pos-receipt .pos-receipt-total { + font-size: 24px !important; + font-weight: 900 !important; + color: var(--pos-secondary) !important; + border-top: 2px solid #1a1d23 !important; + padding-top: 15px !important; +} + +.pos-receipt-amount { + font-weight: 800 !important; +} + +/* ======================================== + RESPONSIVE MEDIA QUERIES + ======================================== */ +@media (max-width: 1024px) { + .pos .leftpane { + width: 380px !important; + } + .pos .product { + width: 160px !important; + height: 180px !important; + } + .pos .product .product-img { + height: 110px !important; + } + .pos .search-bar { + width: 280px !important; + } +} + +@media (max-width: 768px) { + .product-screen { + flex-direction: column !important; + height: 100vh !important; + } + .pos .leftpane { + width: 100% !important; + height: 50vh !important; + border-left: none !important; + border-top: 2px solid #f1f5f9 !important; + } + .pos .rightpane { + height: 50vh !important; + } + .pos .pos-topheader { + height: auto !important; + flex-wrap: wrap !important; + padding: 10px !important; + } + .pos .search-bar { + width: 100% !important; + margin-top: 10px !important; + } +} \ No newline at end of file diff --git a/addons/dine360_dashboard/static/src/css/shop_style.css b/addons/dine360_dashboard/static/src/css/shop_style.css new file mode 100644 index 0000000..5c12ae5 --- /dev/null +++ b/addons/dine360_dashboard/static/src/css/shop_style.css @@ -0,0 +1,312 @@ +/* ======================================== + SHOP PAGE - LEFT SIDEBAR FILTER STYLING + ======================================== */ + +/* Ensure Shop Page Layout with Sidebar */ +#wrapwrap.oe_website_sale { + background: #ffffff !important; +} + +/* Main Shop Container - Two Column Layout */ +.oe_website_sale #products_grid_before { + display: block !important; + visibility: visible !important; + opacity: 1 !important; + width: 100% !important; +} + +/* Left Sidebar Filter Section */ +.oe_website_sale #o_shop_collapse_category, +.oe_website_sale .o_wsale_products_searchbar_form, +.oe_website_sale #wsale_products_categories_collapse { + display: block !important; + visibility: visible !important; + opacity: 1 !important; +} + +/* Shop Layout Container */ +.oe_website_sale .container, +.oe_website_sale .container-fluid { + max-width: 1400px; + margin: 0 auto; + padding: 20px; +} + +/* Row with Sidebar and Products */ +.oe_website_sale .row { + display: flex !important; + flex-wrap: wrap; +} + +/* Left Sidebar Column (Categories/Filters) */ +.oe_website_sale #products_grid_before, +.oe_website_sale .col-lg-3, +.oe_website_sale aside { + flex: 0 0 280px !important; + max-width: 280px !important; + display: block !important; + visibility: visible !important; + padding-right: 20px; +} + +/* Right Products Column */ +.oe_website_sale #products_grid, +.oe_website_sale .col-lg-9 { + flex: 1 !important; + max-width: calc(100% - 280px) !important; +} + +/* Filter Sidebar Styling */ +.oe_website_sale #products_grid_before { + background: rgba(255, 255, 255, 0.95) !important; + border-radius: 16px !important; + padding: 20px !important; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08) !important; + backdrop-filter: blur(10px); + border: 1px solid rgba(0, 0, 0, 0.05); +} + +/* Category Filter Section */ +.oe_website_sale .o_wsale_products_searchbar_form, +.oe_website_sale #wsale_products_categories_collapse { + margin-bottom: 25px; +} + +/* Category Filter Title */ +.oe_website_sale #products_grid_before h5, +.oe_website_sale #products_grid_before .h5, +.oe_website_sale #products_grid_before h6 { + color: var(--secondary-color, #2BB1A5) !important; + font-weight: 700 !important; + font-size: 1.1rem !important; + margin-bottom: 15px !important; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* Category List Items */ +.oe_website_sale #products_grid_before ul, +.oe_website_sale #products_grid_before .list-group { + list-style: none; + padding: 0; + margin: 0; +} + +.oe_website_sale #products_grid_before li, +.oe_website_sale #products_grid_before .list-group-item { + background: transparent !important; + border: none !important; + padding: 10px 15px !important; + margin-bottom: 5px !important; + border-radius: 10px !important; + transition: all 0.2s ease !important; +} + +.oe_website_sale #products_grid_before li a, +.oe_website_sale #products_grid_before .list-group-item a { + color: #4a5568 !important; + text-decoration: none !important; + font-weight: 500 !important; + display: block; + width: 100%; +} + +.oe_website_sale #products_grid_before li:hover, +.oe_website_sale #products_grid_before .list-group-item:hover { + background: #fecd4f !important; + transform: translateX(5px); +} + +.oe_website_sale #products_grid_before li:hover a, +.oe_website_sale #products_grid_before .list-group-item:hover a { + color: #ffffff !important; +} + +/* Active Category */ +.oe_website_sale #products_grid_before li.active, +.oe_website_sale #products_grid_before .list-group-item.active { + background: transparent !important; + box-shadow: none !important; +} + +.oe_website_sale #products_grid_before li.active a, +.oe_website_sale #products_grid_before .list-group-item.active a { + color: #000000 !important; + font-weight: 700 !important; +} + +/* Search Bar in Sidebar */ +.oe_website_sale #products_grid_before .form-control, +.oe_website_sale #products_grid_before input[type="text"], +.oe_website_sale #products_grid_before input[type="search"] { + border-radius: 10px !important; + border: 1px solid #e2e8f0 !important; + padding: 10px 15px !important; + background: #f8f9fa !important; + transition: all 0.2s ease; +} + +.oe_website_sale #products_grid_before .form-control:focus, +.oe_website_sale #products_grid_before input:focus { + border-color: var(--secondary-color, #2BB1A5) !important; + background: #ffffff !important; + box-shadow: 0 0 0 3px rgba(43, 177, 165, 0.15) !important; + outline: none; +} + +/* Filter Buttons */ +.oe_website_sale #products_grid_before .btn, +.oe_website_sale #products_grid_before button { + border-radius: 10px !important; + padding: 8px 20px !important; + font-weight: 600 !important; + transition: all 0.2s ease; +} + +.oe_website_sale #products_grid_before .btn-primary { + background: #2BB1A5 !important; + border: none !important; + color: #ffffff !important; + box-shadow: 0 4px 12px rgba(43, 177, 165, 0.3) !important; +} + +.oe_website_sale #products_grid_before .btn-primary:hover { + transform: translateY(-2px); + box-shadow: 0 8px 15px rgba(43, 177, 165, 0.4) !important; +} + +/* Attribute Filters (Color, Size, etc.) */ +.oe_website_sale #products_grid_before .css_attribute_color { + display: inline-block; + width: 30px; + height: 30px; + border-radius: 50%; + margin: 5px; + border: 2px solid #e2e8f0; + cursor: pointer; + transition: all 0.2s ease; +} + +.oe_website_sale #products_grid_before .css_attribute_color:hover, +.oe_website_sale #products_grid_before .css_attribute_color.active { + border-color: var(--secondary-color, #2BB1A5); + transform: scale(1.1); + box-shadow: 0 2px 8px rgba(43, 177, 165, 0.3); +} + +/* Price Range Filter */ +.oe_website_sale #products_grid_before .o_wsale_price_filter { + margin-top: 20px; +} + +.oe_website_sale #products_grid_before .o_wsale_price_filter input[type="range"] { + width: 100%; + accent-color: var(--secondary-color, #2BB1A5); +} + +/* Collapse/Expand Buttons */ +.oe_website_sale #products_grid_before .btn-link { + color: var(--secondary-color, #2BB1A5) !important; + text-decoration: none !important; + font-weight: 600 !important; +} + +.oe_website_sale #products_grid_before .btn-link:hover { + color: var(--primary-color, #171422) !important; +} + +/* Mobile Responsive */ +@media (max-width: 991px) { + + .oe_website_sale #products_grid_before, + .oe_website_sale .col-lg-3, + .oe_website_sale aside { + flex: 0 0 100% !important; + max-width: 100% !important; + margin-bottom: 20px; + padding-right: 0; + } + + .oe_website_sale #products_grid, + .oe_website_sale .col-lg-9 { + flex: 0 0 100% !important; + max-width: 100% !important; + } +} + +/* Ensure visibility of all filter elements */ +.oe_website_sale .o_wsale_products_searchbar_form, +.oe_website_sale .o_wsale_products_categories, +.oe_website_sale #wsale_products_categories_collapse, +.oe_website_sale .o_wsale_products_attributes, +.oe_website_sale .o_wsale_products_price { + display: block !important; + visibility: visible !important; + opacity: 1 !important; +} + +/* Category Navigation */ +.oe_website_sale .o_wsale_products_categories ul.o_wsale_categories_list { + padding-left: 0 !important; +} + +.oe_website_sale .o_wsale_products_categories ul.o_wsale_categories_list li { + list-style: none; +} + +/* Attribute Filters Section */ +.oe_website_sale .o_wsale_products_attributes { + margin-top: 20px; +} + +.oe_website_sale .o_wsale_products_attributes .o_wsale_attribute { + margin-bottom: 20px; +} + +.oe_website_sale .o_wsale_products_attributes .o_wsale_attribute label { + display: block; + margin-bottom: 10px; + color: #4a5568; + font-weight: 500; +} + +/* Clear Filters Button */ +.oe_website_sale .o_wsale_clear_filters { + margin-top: 20px; + width: 100%; +} + +.oe_website_sale .o_wsale_clear_filters .btn { + width: 100%; + background: #f8f9fa !important; + border: 1px solid #e2e8f0 !important; + color: #4a5568 !important; +} + +.oe_website_sale .o_wsale_clear_filters .btn:hover { + background: #ffffff !important; + border-color: var(--secondary-color, #2BB1A5) !important; + color: var(--secondary-color, #2BB1A5) !important; +} + +.css_quantity input { + max-width: 20px !important; +} + +/* Product Details Quantity Input Fix */ +.css_quantity input.quantity { + color: #000000 !important; + background-color: #ffffff !important; + opacity: 1 !important; + width: 60px !important; + max-width: 60px !important; + height: 45px !important; + line-height: 45px !important; + font-weight: 600 !important; + font-size: 18px !important; + padding: 0 !important; + text-align: center !important; + border: 1px solid #000000 !important; + display: inline-block !important; + visibility: visible !important; +} \ No newline at end of file diff --git a/addons/dine360_dashboard/static/src/css/theme_variables.css b/addons/dine360_dashboard/static/src/css/theme_variables.css new file mode 100644 index 0000000..a9504a8 --- /dev/null +++ b/addons/dine360_dashboard/static/src/css/theme_variables.css @@ -0,0 +1,599 @@ +/* ======================================== + ODOO THEME COLOR VARIABLES + Primary Color: #ec0000 (Dine360 Red) + Secondary Color: #000000 (Black) + ======================================== */ + +:root { + /* Primary Brand Colors */ + --primary-color: #d6111e; + --primary-dark: #8B0A0E; + --primary-light: #E57373; + --primary-lighter: #FFCDD2; + + /* Secondary/Accent Color */ + --secondary-color: #171422; + --secondary-dark: #000000; + --secondary-light: #2d293d; + + /* Gradient Combinations Removed - Using Solid Colors */ + --gradient-primary: #d6111e; + --gradient-primary-hover: #8B0A0E; + + /* Home Page Background */ + --bg-gradient-main: #ffffff; + + /* UI Element Colors */ + --btn-primary-bg: #d6111e; + --link-color: #171422; + --link-hover: #d6111e; + + /* Status Colors */ + --success-color: #171422; + --warning-color: #FECD4F; + --danger-color: #d6111e; + --info-color: #2d293d; + + /* Shadows */ + --shadow-primary: 0 4px 15px rgba(214, 17, 30, 0.2); + --shadow-primary-hover: 0 8px 20px rgba(214, 17, 30, 0.3); + --shadow-card: 0 10px 30px rgba(23, 20, 34, 0.08); +} + +/* ::selection - Text Selection Color */ +::selection { + background: var(--primary-color) !important; + color: #fff !important; +} + +/* Scrollbar Styling (Webkit) */ +::-webkit-scrollbar-thumb { + background: var(--secondary-color) !important; + border-radius: 10px; +} + +::-webkit-scrollbar-track { + background: #f1f1f1; +} + +/* --------------------------------------------------------- + GLOBAL UI OVERRIDES (HOME PAGE STYLE) + --------------------------------------------------------- */ + +/* Global Background & Font */ +body, +.o_web_client { + background: var(--bg-gradient-main) !important; + font-family: 'Inter', 'Segoe UI', Roboto, sans-serif !important; + font-size: 15px !important; + /* Increased Font Size for Readability */ + background-attachment: fixed !important; + background-size: cover !important; +} + +/* + REMOVED AGGRESSIVE LAYOUT OVERRIDES + (o_action_manager, o_view_controller overrides deleted to restore Odoo default structural behavior) +*/ + +/* --------------------------------------------------------- + NAVBAR & SIDEBAR (CONTROL PANEL) + --------------------------------------------------------- */ + +/* Top Navbar - Glassmorphism */ +.o_main_navbar { + background: #ffffff !important; + border-bottom: 2px solid var(--primary-color) !important; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.03) !important; + color: #2c3e50 !important; +} + +.o_main_navbar .o_menu_brand { + color: var(--secondary-color) !important; + font-weight: 800 !important; + font-size: 1.4rem !important; + letter-spacing: -0.5px; +} + +.o_main_navbar .o_menu_sections .o_nav_entry, +.o_main_navbar .o_menu_systray .o_nav_entry { + color: #111111 !important; + font-size: 14px !important; + font-weight: 600 !important; +} + +.o_main_navbar .o_menu_sections .o_nav_entry:hover, +.o_main_navbar .o_menu_systray .o_nav_entry:hover { + background: var(--primary-color) !important; +} + +/* Control Panel (Search, Filter Bar) */ +.o_control_panel { + background: #fdfdfd !important; + border-bottom: 1px solid rgba(0, 0, 0, 0.05) !important; +} + +/* Breadcrumbs */ +.breadcrumb-item a { + color: #FECD4F !important; + font-weight: 700 !important; + font-size: 18px !important; + /* Larger headers */ +} + +.breadcrumb-item.active { + color: #718096 !important; + font-weight: 600 !important; + font-size: 18px !important; +} + +/* Search Bar Input */ +.o_searchview { + background: #f8f9fa !important; + border: 1px solid #e2e8f0 !important; + border-radius: 10px !important; + padding: 6px 15px !important; +} + +.o_searchview .o_searchview_input { + background: transparent !important; + font-size: 14px !important; +} + +/* --------------------------------------------------------- + TABLES (LIST VIEW) + --------------------------------------------------------- */ + +.o_list_view .o_list_table { + background: transparent !important; +} + +/* Table Header */ +.o_list_view .o_list_table thead { + background: rgba(248, 249, 250, 0.8) !important; + color: #4a5568 !important; + border-bottom: 2px solid var(--secondary-color) !important; +} + +.o_list_view .o_list_table thead th { + border: none !important; + text-transform: uppercase; + font-size: 0.75rem; + letter-spacing: 0.05em; + font-weight: 700 !important; + padding: 12px 10px !important; +} + +/* Table Rows */ +.o_list_view .o_list_table tbody tr.o_data_row { + transition: all 0.2s ease !important; + border-bottom: 1px solid rgba(0, 0, 0, 0.03) !important; +} + +.o_list_view .o_list_table tbody tr.o_data_row:hover { + background-color: rgba(214, 17, 30, 0.05) !important; + /* Light Red Hover */ + transform: scale(1.002); + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.02); +} + +/* --------------------------------------------------------- + KANBAN CARDS + --------------------------------------------------------- */ + +.o_kanban_view .o_kanban_record { + background: #fff !important; + border: 1px solid rgba(0, 0, 0, 0.04) !important; + border-radius: 16px !important; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.02) !important; + margin-bottom: 10px !important; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important; + overflow: hidden; +} + +/* Hover Effect for Cards */ +.o_kanban_view .o_kanban_record:hover { + transform: translateY(-5px) !important; + box-shadow: 0 15px 30px rgba(0, 0, 0, 0.08) !important; + border-color: var(--primary-color) !important; +} + +/* Card Header/Title */ +.o_kanban_record .o_kanban_record_title, +.o_kanban_record strong { + color: #2d3748 !important; + font-weight: 700 !important; + font-size: 1.05rem !important; +} + +/* Kanban Group Headers */ +.o_kanban_view .o_kanban_group_header { + background: transparent !important; +} + +/* --------------------------------------------------------- + BUTTONS & FORM ELEMENTS + --------------------------------------------------------- */ + +/* Gradient Buttons */ +.btn-primary, +.o_form_button_save, +.o_form_button_create { + background: var(--gradient-primary) !important; + border: none !important; + border-radius: 10px !important; + padding: 8px 20px !important; + font-weight: 600 !important; + letter-spacing: 0.3px; + box-shadow: 0 4px 12px rgba(214, 17, 30, 0.3) !important; + color: #fff !important; +} + +.btn-primary:hover, +.o_form_button_save:hover, +.o_form_button_create:hover { + background: var(--gradient-primary-hover) !important; + transform: translateY(-2px); + box-shadow: 0 8px 15px rgba(214, 17, 30, 0.4) !important; +} + +/* Secondary Buttons */ +.btn-secondary { + background: #fff !important; + border: 1px solid #e2e8f0 !important; + color: #4a5568 !important; + border-radius: 10px !important; +} + +.btn-secondary:hover { + background: #f7fafc !important; + color: var(--secondary-color) !important; + border-color: var(--secondary-color) !important; +} + +/* Form Inputs */ +.o_form_view .o_input { + border-radius: 8px !important; + border: 1px solid #e2e8f0 !important; + padding: 8px 12px !important; + background-color: #fcfcfc !important; +} + +.o_form_view .o_input:focus { + border-color: var(--secondary-color) !important; + background-color: #fff !important; + box-shadow: 0 0 0 3px rgba(214, 17, 30, 0.15) !important; +} + +/* --------------------------------------------------------- + COMPONENT OVERRIDES + --------------------------------------------------------- */ + +/* Primary Buttons */ +.btn-primary, +.o_form_button_save, +.o_form_button_create, +.o_button_import { + background: var(--gradient-primary) !important; + border: none !important; + border-radius: 10px !important; + padding: 8px 20px !important; + font-weight: 600 !important; + letter-spacing: 0.3px; + box-shadow: 0 4px 12px rgba(214, 17, 30, 0.3) !important; +} + +.btn-primary:hover, +.o_form_button_save:hover, +.o_form_button_create:hover, +.o_button_import:hover { + background: var(--gradient-primary-hover) !important; + transform: translateY(-2px); + box-shadow: 0 8px 15px rgba(214, 17, 30, 0.4) !important; +} + +/* --------------------------------------------------------- + DASHBOARD SPECIFIC UI UPDATES (But safer for global) + --------------------------------------------------------- */ + +/* Top Menu Tabs */ +.o_main_navbar .o_menu_sections .dropdown-item, +.o_main_navbar .o_menu_sections .o_nav_entry { + background: transparent !important; + color: var(--primary-color) !important; + font-weight: 600 !important; + border-radius: 8px !important; + margin: 0 4px !important; +} + +/* Active Tab / Hover State */ +.o_main_navbar .o_menu_sections .o_nav_entry:hover, +.o_main_navbar .o_menu_sections .show .dropdown-toggle { + background-color: #ffffff !important; + /* Yellow Active Background */ + color: #d6111e !important; + border-bottom: 2px solid #d6111e !important; +} + +/* Dashboard Sidebar (Finance/Logistics) */ +.o_notebook .nav.nav-tabs, +.o_dashboard_view .o_group_selector { + background: rgba(255, 255, 255, 0.6) !important; + backdrop-filter: blur(10px); + border-right: 1px solid rgba(0, 0, 0, 0.05); + padding: 15px !important; +} + +.o_notebook .nav-link, +.o_dashboard_view .o_group_selector li { + color: var(--secondary-color) !important; + /* Teal Text */ + border: none !important; + border-radius: 10px !important; + margin-bottom: 8px !important; + padding: 10px 15px !important; + font-weight: 500 !important; + transition: all 0.2s ease; +} + +.o_notebook .nav-link.active, +.o_dashboard_view .o_group_selector li.selected { + background: var(--gradient-primary) !important; + color: #fff !important; + box-shadow: 0 4px 10px rgba(214, 17, 30, 0.3) !important; +} + +/* Dashboard KPI Cards (Invoiced, Average Invoice, DSO) */ +.o_dashboard_view .o_group .o_aggregate, +.o_group .o_group_col_6 { + background: #fff !important; + border-radius: 15px !important; + padding: 20px !important; + box-shadow: 0 5px 20px rgba(0, 0, 0, 0.05) !important; + border: 1px solid rgba(0, 0, 0, 0.02) !important; + transition: transform 0.3s ease !important; + margin-bottom: 20px !important; +} + +.o_dashboard_view .o_group .o_aggregate:hover { + transform: translateY(-5px) !important; + box-shadow: 0 10px 25px rgba(214, 17, 30, 0.15) !important; + border-color: var(--secondary-color) !important; +} + +/* Value Text in Cards */ +.o_dashboard_view .o_group .o_aggregate .o_value { + color: var(--secondary-color) !important; + font-weight: 800 !important; + font-size: 2rem !important; +} + +.o_dashboard_view .o_group .o_aggregate label { + color: #718096 !important; + font-size: 0.9rem !important; + text-transform: uppercase; + letter-spacing: 1px; +} + +/* Charts Background */ +.o_graph_view { + background: transparent !important; +} + +.o_graph_canvas_container canvas { + background: rgba(255, 255, 255, 0.5) !important; + border-radius: 15px; + padding: 10px; +} + +/* Links */ +a, +.o_form_uri, +.oe_link, +.btn-link { + color: var(--secondary-color) !important; + /* Switched to teal for better readibility on white */ +} + +a:hover, +.o_form_uri:hover, +.oe_link:hover, +.btn-link:hover { + color: var(--primary-dark) !important; +} + +/* Nav & Menu Active States */ +.nav-link.active, +.o_menu_sections .dropdown-item.active, +.o_menu_brand:hover { + color: var(--secondary-color) !important; + border-bottom-color: var(--secondary-color) !important; +} + +/* Badges */ +.badge-primary, +.o_tag { + background-color: var(--primary-color) !important; + color: #fff !important; +} + +/* Progress bars */ +.progress-bar { + background-color: var(--secondary-color) !important; +} + +/* Checkboxes and Radios */ +.custom-control-input:checked~.custom-control-label::before, +.o_checkbox input:checked+label::before { + background-color: var(--secondary-color) !important; + border-color: var(--secondary-color) !important; +} + +/* Form Controls - Focus using Secondary (Teal) like Login Page */ +.form-control:focus, +.o_input:focus, +.o_searchview_input:focus { + border-color: var(--secondary-color) !important; + box-shadow: 0 0 0 0.2rem rgba(214, 17, 30, 0.25) !important; +} + +/* Loading Bar */ +.o_loading { + background-color: var(--primary-color) !important; +} + +/* Backend View Headers */ +.o_list_view .o_list_table thead, +.o_kanban_view .o_kanban_group_header { + background-color: #f8f9fa; + border-bottom: 2px solid var(--primary-color) !important; + color: #4a5568 !important; + font-weight: 600 !important; + padding: 10px !important; +} + +/* Kanban Records Selected */ +.o_kanban_record.o_kanban_record_selected { + box-shadow: 0 0 0 2px var(--primary-color) !important; +} + +/* Pagination */ +.page-item.active .page-link { + background-color: var(--secondary-color) !important; + border-color: var(--secondary-color) !important; +} + +/* Switcher/Toggle */ +.o_switch.custom-switch .custom-control-input:checked~.custom-control-label::before { + background-color: var(--secondary-color) !important; +} + +/* Calendar Today */ +.fc-day-today { + background-color: rgba(254, 205, 79, 0.1) !important; +} + +/* --------------------------------------------------------- + HEADER SUBMENU DROPDOWN NAVIGATION + --------------------------------------------------------- */ + +/* Dropdown Container */ +.dropdown-menu { + background: #ffffff !important; + border: 1px solid #e2e8f0 !important; + border-radius: 12px !important; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.15) !important; + padding: 8px !important; + margin-top: 5px !important; + z-index: 10000 !important; + /* Raised to be above most backend elements */ +} + +/* Fix for Dropdowns being hidden in List Views (Editable rows) */ +.o_list_view .o_list_table { + overflow: visible !important; +} + +.o_list_view .o_list_table tbody tr, +.o_list_view .o_list_table tbody td { + overflow: visible !important; +} + +/* Ensure Autocomplete stays in front */ +.ui-autocomplete, +.o_autocomplete_dropdown, +.dropdown-menu.show { + z-index: 10001 !important; +} + + +.o_main_navbar .o_menu_sections .o_nav_entry.active, +.o_main_navbar .o_menu_sections .dropdown-toggle { + background-color: none !important; +} + + +.o_main_navbar .o_menu_sections .o_nav_entry.active, +.o_main_navbar .o_menu_sections .dropdown-toggle.active { + background-color: var(--primary-color) !important; +} + +.o_main_navbar .o_menu_brand, +.o_main_navbar .o_navbar_apps_menu .dropdown-toggle, +.o_main_navbar .o_nav_entry, +.o_main_navbar .dropdown-toggle { + color: var(--secondary-color) !important; + font-size: 1.1rem !important; + transition: all 0.2s ease !important; +} + +/* Navbar Icons */ +.o_main_navbar .o_menu_systray .o_nav_entry i, +.o_main_navbar .o_menu_apps .o_app_drawer i, +.o_main_navbar .o_menu_sections .fa { + color: var(--secondary-color) !important; + font-size: 1.1rem !important; + transition: all 0.2s ease !important; +} + +.o_main_navbar .o_menu_systray .o_nav_entry:hover i, +.o_main_navbar .o_menu_apps .o_app_drawer:hover i { + color: var(--primary-dark) !important; + transform: scale(1.1); + text-shadow: 0 2px 10px rgba(214, 17, 30, 0.5); +} + +/* Dropdown Items */ +.dropdown-item { + color: #4a5568 !important; + padding: 10px 15px !important; + border-radius: 8px !important; + font-weight: 500 !important; + transition: all 0.2s ease !important; + margin-bottom: 2px !important; + background: transparent; +} + +/* Dropdown Item Hover */ +.dropdown-item:hover, +.dropdown-item:focus { + background: var(--gradient-primary) !important; + color: #fff !important; + box-shadow: 0 4px 10px rgba(214, 17, 30, 0.2); +} + +/* Dropdown Header/Divider */ +.dropdown-divider { + border-top: 1px solid rgba(0, 0, 0, 0.06) !important; + margin: 8px 0 !important; +} + +.dropdown-header { + color: var(--secondary-color) !important; + font-weight: 700 !important; + text-transform: uppercase; + font-size: 0.75rem !important; + letter-spacing: 0.05em; + padding: 8px 15px !important; + background: transparent !important; +} + +/* Active Dropdown Item */ +.dropdown-item.active, +.dropdown-item:active { + background: var(--gradient-primary) !important; + color: #fff !important; +} + +.o_main_navbar .o_menu_sections .o_nav_entry, +.o_main_navbar .o_menu_sections .dropdown-toggle { + background: #ffffff; + border-bottom: 2px solid #d6111e; +} + +.list-group-item.active { + z-index: 2; + color: #fff; + background-color: #d6111e; + border-color: #d6111e; +} \ No newline at end of file diff --git a/addons/dine360_dashboard/static/src/css/website_style.css b/addons/dine360_dashboard/static/src/css/website_style.css new file mode 100644 index 0000000..44ddfa3 --- /dev/null +++ b/addons/dine360_dashboard/static/src/css/website_style.css @@ -0,0 +1,138 @@ +/* Custom Website Header Styling */ + +/* Force Header Background to Black */ +header#top, +body .o_header_standard, +body header.o_header_standard, +body .navbar, +body #o_main_nav { + background-color: #111 !important; + /* background: #171422 !important; */ + border-bottom: none; +} + +/* Navbar Links / Menu Items */ +header#top .navbar-nav .nav-link, +header#top .nav-link, +body .o_header_standard .nav-link, +body .navbar .nav-link, +body #o_main_nav .nav-link { + color: #ffffff !important; + font-weight: 500; +} + +/* Navbar Icons (Search, Cart, User, etc.) */ +header#top i, +header#top .fa, +body .o_header_standard i, +body .o_header_standard .fa, +body .navbar i, +body .navbar .fa { + color: #ffffff !important; +} + +/* Brand / Logo Text (if text logo) */ +header#top .navbar-brand, +body .o_header_standard .navbar-brand { + color: #ffffff !important; +} + +/* Hover States - Using Theme Teal */ +header#top .nav-link:hover, +body .o_header_standard .nav-link:hover, +header#top i:hover, +body .o_header_standard i:hover { + color: #2BB1A5 !important; +} + +/* Active States - Using Theme Gold */ +header#top .nav-link.active, +body .o_header_standard .nav-link.active { + color: #2BB1A5 !important; +} + +/* Dropdown Menu overrides (ensure visibility) */ +header#top .dropdown-menu, +body .o_header_standard .dropdown-menu { + background-color: #171422 !important; + border: 1px solid rgba(43, 177, 165, 0.2); +} + +header#top .dropdown-item, +body .o_header_standard .dropdown-item { + color: #ffffff !important; +} + +header#top .dropdown-item:hover, +body .o_header_standard .dropdown-item:hover { + background-color: #2BB1A5 !important; + color: #ffffff !important; +} + +/* Header Cart & Search Icon Adjustments */ +.o_wsale_my_cart, +.o_nav_item_search, +.o_nav_item_cart, +header .navbar-nav .nav-item a[href="/shop/cart"], +header .navbar-nav .nav-item a.o_navlink_background_hover { + transition: all 0.3s ease !important; +} + +/* Hover background for Search and Cart icons */ +.o_wsale_my_cart:hover, +.o_nav_item_search:hover, +.o_nav_item_cart:hover, +header .navbar-nav .nav-item a[href="/shop/cart"]:hover, +header .o_search_button:hover { + background-color: #FECD4F !important; + border-radius: 50px !important; +} + +/* Ensure icons turn dark on yellow background hover */ +.o_wsale_my_cart:hover i, +.o_nav_item_search:hover i, +.o_nav_item_cart:hover i, +header .navbar-nav .nav-item a[href="/shop/cart"]:hover i, +header .o_search_button:hover i { + color: #111 !important; +} + +/* Cart Count Badge Color */ +.o_wsale_my_cart .badge, +.o_wsale_my_cart .o_wsale_cart_quantity, +header .navbar-nav .nav-item .badge-pill, +header .navbar-nav .nav-item .badge { + background-color: #2BB1A5 !important; + color: #ffffff !important; + border: none !important; +} + +/* Header Action Buttons */ +header .btn, +header .btn-primary, +header .o_header_cta_btn, +header .btn-contact, +header a.btn[href="/contactus"], +header a.btn[href*="contact"] { + background-color: #FECD4F !important; + color: #04121D !important; + border: none !important; + border-radius: 8px !important; + padding: 10px 25px !important; + font-weight: 700 !important; + text-transform: uppercase !important; + transition: all 0.3s ease !important; + box-shadow: 0 4px 10px rgba(254, 205, 79, 0.3) !important; + text-decoration: none !important; +} + +header .btn:hover, +header .btn-primary:hover, +header .o_header_cta_btn:hover, +header .btn-contact:hover, +header a.btn[href="/contactus"]:hover, +header a.btn[href*="contact"]:hover { + background-color: #FECD4F !important; + color: #FECD4F !important; + transform: translateY(-2px) !important; +} \ No newline at end of file diff --git a/addons/dine360_dashboard/static/src/img/dashboard_bg.png b/addons/dine360_dashboard/static/src/img/dashboard_bg.png new file mode 100644 index 0000000..a393fb6 Binary files /dev/null and b/addons/dine360_dashboard/static/src/img/dashboard_bg.png differ diff --git a/addons/dine360_dashboard/static/src/img/icons/apps.svg b/addons/dine360_dashboard/static/src/img/icons/apps.svg new file mode 100644 index 0000000..bac6c27 --- /dev/null +++ b/addons/dine360_dashboard/static/src/img/icons/apps.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/dine360_dashboard/static/src/img/icons/calendar.svg b/addons/dine360_dashboard/static/src/img/icons/calendar.svg new file mode 100644 index 0000000..4b3e89c --- /dev/null +++ b/addons/dine360_dashboard/static/src/img/icons/calendar.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + 31 + + + + + + + + + + diff --git a/addons/dine360_dashboard/static/src/img/icons/contacts.svg b/addons/dine360_dashboard/static/src/img/icons/contacts.svg new file mode 100644 index 0000000..01b115e --- /dev/null +++ b/addons/dine360_dashboard/static/src/img/icons/contacts.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/addons/dine360_dashboard/static/src/img/icons/crm.svg b/addons/dine360_dashboard/static/src/img/icons/crm.svg new file mode 100644 index 0000000..c658baa --- /dev/null +++ b/addons/dine360_dashboard/static/src/img/icons/crm.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/addons/dine360_dashboard/static/src/img/icons/dashboards.svg b/addons/dine360_dashboard/static/src/img/icons/dashboards.svg new file mode 100644 index 0000000..c226833 --- /dev/null +++ b/addons/dine360_dashboard/static/src/img/icons/dashboards.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/dine360_dashboard/static/src/img/icons/discuss.svg b/addons/dine360_dashboard/static/src/img/icons/discuss.svg new file mode 100644 index 0000000..66a4f4c --- /dev/null +++ b/addons/dine360_dashboard/static/src/img/icons/discuss.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/dine360_dashboard/static/src/img/icons/employees.svg b/addons/dine360_dashboard/static/src/img/icons/employees.svg new file mode 100644 index 0000000..3e89511 --- /dev/null +++ b/addons/dine360_dashboard/static/src/img/icons/employees.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/addons/dine360_dashboard/static/src/img/icons/inventory.svg b/addons/dine360_dashboard/static/src/img/icons/inventory.svg new file mode 100644 index 0000000..d1531cd --- /dev/null +++ b/addons/dine360_dashboard/static/src/img/icons/inventory.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/dine360_dashboard/static/src/img/icons/invoicing.svg b/addons/dine360_dashboard/static/src/img/icons/invoicing.svg new file mode 100644 index 0000000..1983140 --- /dev/null +++ b/addons/dine360_dashboard/static/src/img/icons/invoicing.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + $ 128.00 + + + diff --git a/addons/dine360_dashboard/static/src/img/icons/kitchen_kds.svg b/addons/dine360_dashboard/static/src/img/icons/kitchen_kds.svg new file mode 100644 index 0000000..78c9a2c --- /dev/null +++ b/addons/dine360_dashboard/static/src/img/icons/kitchen_kds.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + D360 + + + diff --git a/addons/dine360_dashboard/static/src/img/icons/point_of_sale.svg b/addons/dine360_dashboard/static/src/img/icons/point_of_sale.svg new file mode 100644 index 0000000..880a668 --- /dev/null +++ b/addons/dine360_dashboard/static/src/img/icons/point_of_sale.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/dine360_dashboard/static/src/img/icons/purchase.svg b/addons/dine360_dashboard/static/src/img/icons/purchase.svg new file mode 100644 index 0000000..7d913e2 --- /dev/null +++ b/addons/dine360_dashboard/static/src/img/icons/purchase.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + PO + diff --git a/addons/dine360_dashboard/static/src/img/icons/sales.svg b/addons/dine360_dashboard/static/src/img/icons/sales.svg new file mode 100644 index 0000000..dd88f3f --- /dev/null +++ b/addons/dine360_dashboard/static/src/img/icons/sales.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/addons/dine360_dashboard/static/src/img/icons/settings.svg b/addons/dine360_dashboard/static/src/img/icons/settings.svg new file mode 100644 index 0000000..6489267 --- /dev/null +++ b/addons/dine360_dashboard/static/src/img/icons/settings.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/dine360_dashboard/static/src/img/icons/table_reservation.svg b/addons/dine360_dashboard/static/src/img/icons/table_reservation.svg new file mode 100644 index 0000000..624922b --- /dev/null +++ b/addons/dine360_dashboard/static/src/img/icons/table_reservation.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + RESERVED + + + + diff --git a/addons/dine360_dashboard/static/src/img/icons/uber_integration.svg b/addons/dine360_dashboard/static/src/img/icons/uber_integration.svg new file mode 100644 index 0000000..28fa7e2 --- /dev/null +++ b/addons/dine360_dashboard/static/src/img/icons/uber_integration.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + U + + + + + + diff --git a/addons/dine360_dashboard/static/src/img/icons/website.svg b/addons/dine360_dashboard/static/src/img/icons/website.svg new file mode 100644 index 0000000..cb8abbc --- /dev/null +++ b/addons/dine360_dashboard/static/src/img/icons/website.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + D360 + diff --git a/addons/dine360_dashboard/static/src/img/login_bg.png b/addons/dine360_dashboard/static/src/img/login_bg.png new file mode 100644 index 0000000..0c4c67c Binary files /dev/null and b/addons/dine360_dashboard/static/src/img/login_bg.png differ diff --git a/addons/dine360_dashboard/static/src/js/chennora_title.js b/addons/dine360_dashboard/static/src/js/chennora_title.js new file mode 100644 index 0000000..cb26828 --- /dev/null +++ b/addons/dine360_dashboard/static/src/js/chennora_title.js @@ -0,0 +1,31 @@ +/** @odoo-module **/ + +import { WebClient } from "@web/webclient/webclient"; +import { patch } from "@web/core/utils/patch"; +import { onMounted } from "@odoo/owl"; + +patch(WebClient.prototype, { + setup() { + super.setup(); + try { + this.title.setParts({ brand: "Shivasakthi" }); + } catch (e) { + // title service not ready yet, ignore + } + + onMounted(() => { + try { + // Only redirect to home dashboard if the URL has no meaningful hash + // (i.e., user just typed /web or /web# directly) + const hash = window.location.hash; + const path = window.location.pathname; + if (path === "/web" && (!hash || hash === "#" || hash === "#home")) { + window.location.href = "/"; + } + } catch (e) { + // ignore redirect errors + } + }); + } +}); + diff --git a/addons/dine360_dashboard/static/src/xml/navbar_extension.xml b/addons/dine360_dashboard/static/src/xml/navbar_extension.xml new file mode 100644 index 0000000..d1f5bf0 --- /dev/null +++ b/addons/dine360_dashboard/static/src/xml/navbar_extension.xml @@ -0,0 +1,14 @@ + + + + + + + Dashboard + + + + + diff --git a/addons/dine360_dashboard/views/home_template.xml b/addons/dine360_dashboard/views/home_template.xml new file mode 100644 index 0000000..31d018b --- /dev/null +++ b/addons/dine360_dashboard/views/home_template.xml @@ -0,0 +1,102 @@ + + + + diff --git a/addons/dine360_dashboard/views/login_templates.xml b/addons/dine360_dashboard/views/login_templates.xml new file mode 100644 index 0000000..7cd7b27 --- /dev/null +++ b/addons/dine360_dashboard/views/login_templates.xml @@ -0,0 +1,45 @@ + + + diff --git a/addons/dine360_dashboard/views/shop_template.xml b/addons/dine360_dashboard/views/shop_template.xml new file mode 100644 index 0000000..9b3cc82 --- /dev/null +++ b/addons/dine360_dashboard/views/shop_template.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/addons/dine360_dashboard/views/web_title_template.xml b/addons/dine360_dashboard/views/web_title_template.xml new file mode 100644 index 0000000..821ac33 --- /dev/null +++ b/addons/dine360_dashboard/views/web_title_template.xml @@ -0,0 +1,25 @@ + + + diff --git a/addons/dine360_dashboard/views/website_logo.xml b/addons/dine360_dashboard/views/website_logo.xml new file mode 100644 index 0000000..db046c7 --- /dev/null +++ b/addons/dine360_dashboard/views/website_logo.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/addons/dine360_kds/__init__.py b/addons/dine360_kds/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/addons/dine360_kds/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/addons/dine360_kds/__manifest__.py b/addons/dine360_kds/__manifest__.py new file mode 100644 index 0000000..293211a --- /dev/null +++ b/addons/dine360_kds/__manifest__.py @@ -0,0 +1,38 @@ +{ + 'name': 'Dine360 Kitchen Display System', + 'version': '1.0', + 'category': 'Sales/Point of Sale', + 'summary': 'Dedicated KDS for Restaurant Kitchen', + 'description': """ + Professional Kitchen Display System: + - Real-time order tracking + - Preparation status management + - Cooking time tracking + - Kanban dashboard for chefs + - Floor/Table based organization + """, + 'author': 'Dine360', + 'depends': ['dine360_restaurant', 'point_of_sale', 'pos_restaurant', 'sale_management', 'website_sale', 'dine360_order_channels'], + 'data': [ + 'security/ir.model.access.csv', + 'views/pos_order_line_views.xml', + 'views/product_views.xml', + 'views/kds_menus.xml', + ], + 'assets': { + 'web.assets_backend': [ + 'dine360_kds/static/src/css/kds_style.css', + 'dine360_kds/static/src/js/kds_backend.js', + ], + 'point_of_sale._assets_pos': [ + 'dine360_kds/static/src/css/pos_kds.css', + 'dine360_kds/static/src/js/pos_kds.js', + # 'dine360_kds/static/src/xml/pos_kds.xml', # Temporarily disabled + ], + + }, + 'installable': True, + 'application': True, + 'icon': '/dine360_kds/static/description/icon.png', + 'license': 'LGPL-3', +} diff --git a/addons/dine360_kds/models/__init__.py b/addons/dine360_kds/models/__init__.py new file mode 100644 index 0000000..35435c5 --- /dev/null +++ b/addons/dine360_kds/models/__init__.py @@ -0,0 +1,4 @@ +from . import pos_order_line +from . import product +from . import pos_session +from . import website_sale_integration diff --git a/addons/dine360_kds/models/pos_order_line.py b/addons/dine360_kds/models/pos_order_line.py new file mode 100644 index 0000000..fa2ead5 --- /dev/null +++ b/addons/dine360_kds/models/pos_order_line.py @@ -0,0 +1,180 @@ +from odoo import models, fields, api, _ +import logging + +_logger = logging.getLogger(__name__) + +class PosOrderLine(models.Model): + _inherit = 'pos.order.line' + + preparation_status = fields.Selection([ + ('waiting', 'Waiting'), + ('preparing', 'Preparing'), + ('ready', 'Ready'), + ('served', 'Served'), + ('cancelled', 'Cancelled') + ], string='Preparation Status', default='waiting', tracking=True, group_expand='_read_group_preparation_status') + + @api.model + def _read_group_preparation_status(self, stages, domain, order): + return ['waiting', 'preparing', 'ready', 'served'] + + color = fields.Integer(string='Color', default=0) + preparation_time_start = fields.Datetime(string='Start Time') + preparation_time_end = fields.Datetime(string='Ready Time') + cooking_time = fields.Integer(string='Cooking Time (min)', compute='_compute_cooking_time', store=True) + + table_id = fields.Many2one('restaurant.table', related='order_id.table_id', string='Table', store=True) + floor_id = fields.Many2one('restaurant.floor', related='order_id.table_id.floor_id', string='Floor', store=True) + order_source = fields.Selection([ + ('walk_in', 'Walk-In (Standard POS)'), + ('phone', 'Telephone Order'), + ('online', 'Online / eCommerce'), + ('whatsapp', 'WhatsApp'), + ('social_media', 'Social Media'), + ('platform', 'Third-Party Platform'), + ('kiosk', 'Self-Order Kiosk'), + ('qr', 'QR Table Order'), + ], related='order_id.order_source', string='Order Source', store=True) + fulfilment_type = fields.Selection([ + ('dine_in', 'Dine-In'), + ('pickup', 'Pickup'), + ('delivery', 'Delivery'), + ], related='order_id.fulfilment_type', string='Fulfilment Type', store=True) + + @api.depends('preparation_time_start', 'preparation_time_end') + def _compute_cooking_time(self): + for line in self: + if line.preparation_time_start and line.preparation_time_end: + diff = line.preparation_time_end - line.preparation_time_start + line.cooking_time = int(diff.total_seconds() / 60) + else: + line.cooking_time = 0 + + def _notify_pos(self): + """Send notification to POS when order line status changes""" + _logger.info("=== _notify_pos called for %s lines ===" % len(self)) + for line in self: + _logger.info(f"Processing line {line.id}, order: {line.order_id.name}, config: {line.order_id.config_id}") + if line.order_id.config_id: + channel_name = "pos_config_Channel_%s" % line.order_id.config_id.id + payload = { + 'line_id': line.id, + 'order_id': line.order_id.id, + 'status': line.preparation_status, + 'status_label': dict(self._fields['preparation_status']._description_selection(self.env)).get(line.preparation_status), + 'order_uid': line.order_id.pos_reference, + 'product_id': line.product_id.id, + 'qty': line.qty, + } + _logger.info(f"KDS NOTIFICATION: Sending update for Line {line.id} Status {line.preparation_status} to {channel_name}") + self.env['bus.bus']._sendone(channel_name, 'kds_update', payload) + else: + _logger.warning(f"Line {line.id} has no config_id - cannot send notification") + + def _notify_kds(self): + """Send notification to KDS backend when new order lines are created""" + _logger.info("=== _notify_kds called for %s lines ===" % len(self)) + for line in self: + # Only notify for kitchen items + if line.product_id.is_kitchen_item and line.product_id.name != 'Water': + # Send to global KDS channel + kds_channel = "kds_channel" + payload = { + 'line_id': line.id, + 'order_id': line.order_id.id, + 'product_name': line.product_id.name, + 'qty': line.qty, + 'table_name': line.table_id.name if line.table_id else '', + 'floor_name': line.floor_id.name if line.floor_id else '', + 'customer_note': line.customer_note or '', + 'preparation_status': line.preparation_status, + 'create_date': line.create_date.isoformat() if line.create_date else '', + } + _logger.info(f"KDS BACKEND NOTIFICATION: New order line {line.id} for {line.product_id.name}") + self.env['bus.bus']._sendone(kds_channel, 'kds_new_order', payload) + + @api.model_create_multi + def create(self, vals_list): + """Override create to send notifications to KDS when new orders are added""" + lines = super(PosOrderLine, self).create(vals_list) + # Skip KDS notification if flagged (online orders wait for cashier confirmation) + if not self.env.context.get('skip_kds_notify'): + # Send notification to KDS backend only for new items (waiting status) + waiting_lines = lines.filtered(lambda l: l.preparation_status == 'waiting') + if waiting_lines: + waiting_lines._notify_kds() + return lines + + def write(self, vals): + """Prevent resetting 'served' or 'ready' status back to 'waiting' during POS sync""" + if 'preparation_status' in vals and vals['preparation_status'] == 'waiting': + for line in self: + if line.preparation_status in ['served', 'ready', 'preparing']: + # Keep the current status if it's already being processed or served + actual_vals = vals.copy() + actual_vals['preparation_status'] = line.preparation_status + super(PosOrderLine, line).write(actual_vals) + + # Handle lines that are actually allowed to be updated to waiting + remaining_lines = self.filtered(lambda l: l.preparation_status not in ['served', 'ready', 'preparing']) + if remaining_lines: + res = super(PosOrderLine, remaining_lines).write(vals) + # If quantity changed or status is waiting, notify KDS to refresh + remaining_lines._notify_kds() + return res + return True + + res = super(PosOrderLine, self).write(vals) + # Notify KDS for quantity changes on active items + if 'qty' in vals: + active_lines = self.filtered(lambda l: l.preparation_status in ['waiting', 'preparing']) + if active_lines: + active_lines._notify_kds() + return res + + def action_start_preparing(self): + self.write({ + 'preparation_status': 'preparing', + 'preparation_time_start': fields.Datetime.now() + }) + self._notify_pos() + + def action_mark_ready(self): + self.write({ + 'preparation_status': 'ready', + 'preparation_time_end': fields.Datetime.now() + }) + self._notify_pos() + + def action_mark_served(self): + self.write({ + 'preparation_status': 'served' + }) + self._notify_pos() + +class PosOrder(models.Model): + _inherit = 'pos.order' + + order_source = fields.Selection([ + ('walk_in', 'Walk-In (Standard POS)'), + ('phone', 'Telephone Order'), + ('online', 'Online / eCommerce'), + ('whatsapp', 'WhatsApp'), + ('social_media', 'Social Media'), + ('platform', 'Third-Party Platform'), + ('kiosk', 'Self-Order Kiosk'), + ('qr', 'QR Table Order'), + ], string='Order Source', default='walk_in') + + fulfilment_type = fields.Selection([ + ('dine_in', 'Dine-In'), + ('pickup', 'Pickup'), + ('delivery', 'Delivery'), + ], string='Fulfilment Type', default='dine_in') + + @api.model + def _prepare_order_line_vals(self, line, session_id=None): + res = super()._prepare_order_line_vals(line, session_id) + if 'preparation_status' in line: + res['preparation_status'] = line['preparation_status'] + return res diff --git a/addons/dine360_kds/models/pos_session.py b/addons/dine360_kds/models/pos_session.py new file mode 100644 index 0000000..1c89941 --- /dev/null +++ b/addons/dine360_kds/models/pos_session.py @@ -0,0 +1,9 @@ +from odoo import models + +class PosSession(models.Model): + _inherit = 'pos.session' + + def _loader_params_pos_order_line(self): + params = super()._loader_params_pos_order_line() + params['search_params']['fields'].extend(['preparation_status', 'preparation_time_start', 'preparation_time_end']) + return params diff --git a/addons/dine360_kds/models/product.py b/addons/dine360_kds/models/product.py new file mode 100644 index 0000000..20e152b --- /dev/null +++ b/addons/dine360_kds/models/product.py @@ -0,0 +1,10 @@ +from odoo import models, fields + +class ProductTemplate(models.Model): + _inherit = 'product.template' + + is_kitchen_item = fields.Boolean( + string='Show in KDS', + default=False, + help="If checked, this product will appear in the Kitchen Display System when ordered." + ) diff --git a/addons/dine360_kds/models/website_sale_integration.py b/addons/dine360_kds/models/website_sale_integration.py new file mode 100644 index 0000000..da29b71 --- /dev/null +++ b/addons/dine360_kds/models/website_sale_integration.py @@ -0,0 +1,105 @@ +from odoo import models, fields, api, _ +import logging + +_logger = logging.getLogger(__name__) + +class SaleOrder(models.Model): + _inherit = 'sale.order' + + def action_confirm(self): + """ + Override to create a POS Order for KDS when a Website Order is confirmed. + """ + res = super(SaleOrder, self).action_confirm() + + for order in self: + # Check if it's a website order (usually has website_id) + if order.website_id: + try: + self._create_pos_order_for_kds(order) + except Exception as e: + _logger.error(f"Failed to create POS order for Website Order {order.name}: {str(e)}") + + return res + + def _create_pos_order_for_kds(self, sale_order): + """Create a POS Order based on the Sale Order details""" + # Use a savepoint so that if KDS creation fails, the main Sale Order confirmation succeeds + with self.env.cr.savepoint(): + PosOrder = self.env['pos.order'] + PosSession = self.env['pos.session'] + PosConfig = self.env['pos.config'] + + # 1. Find a suitable POS Config (e.g., 'Website' or first available restaurant) + config = PosConfig.search([('module_pos_restaurant', '=', True), ('active', '=', True)], limit=1) + if not config: + _logger.warning("No active POS Restaurant configuration found. Skipping KDS creation.") + return + + # 2. Find or Open a Session + session = PosSession.search([ + ('config_id', '=', config.id), + ('state', '=', 'opened') + ], limit=1) + + if not session: + _logger.warning(f"No open POS session found for config {config.name}. Cannot send to KDS.") + return + + # 3. Create POS Order Lines + lines_data = [] + for line in sale_order.order_line: + if not line.product_id: + continue + + qty = line.product_uom_qty + if qty <= 0: + continue + + # Skip non-kitchen items, but allow delivery lines for accurate total matching + is_delivery_line = getattr(line, 'is_delivery', False) + if not is_delivery_line and (not line.product_id.is_kitchen_item or line.product_id.type == 'service'): + continue + + lines_data.append((0, 0, { + 'product_id': line.product_id.id, + 'qty': qty, + 'price_unit': line.price_unit, + 'price_subtotal': line.price_subtotal, + 'price_subtotal_incl': line.price_total, + 'full_product_name': line.name, + 'tax_ids': [(6, 0, line.tax_id.ids)], + # Online orders: hold for cashier confirmation before sending to KDS + 'preparation_status': False, + 'customer_note': 'Web Order', + })) + + if not lines_data: + return + + # Generate proper POS reference matching Odoo's regex pattern '([0-9-]){14,}' + # Odoo's _export_for_ui expects this to exist otherwise it crashes + import datetime + uid = f"{datetime.datetime.now().strftime('%Y%m%d%H%M%S')}-{session.id}-{sale_order.id}" + pos_reference = f"Order {uid}" + + # 4. Create POS Order (in Draft/New state to avoid double accounting) + # Use skip_kds_notify context to prevent immediate KDS notification + # Online orders will be sent to KDS only after cashier confirmation + pos_order = PosOrder.with_context(skip_kds_notify=True).create({ + 'session_id': session.id, + 'company_id': sale_order.company_id.id, + 'partner_id': sale_order.partner_id.id, + 'pricelist_id': sale_order.pricelist_id.id or session.config_id.pricelist_id.id, + 'pos_reference': pos_reference, + 'lines': lines_data, + 'amount_total': sale_order.amount_total, + 'amount_tax': sale_order.amount_tax, + 'amount_paid': 0.0, # Not processing payment in POS to avoid duplication + 'amount_return': 0.0, + 'note': f"From Website Order {sale_order.name}", + # 'state': 'draft', # Default is draft + }) + + # Notification to KDS is deferred until cashier confirms via dine360_online_orders + _logger.info(f"Created POS Order {pos_order.name} from Website Order {sale_order.name} (pending cashier confirmation).") diff --git a/addons/dine360_kds/security/ir.model.access.csv b/addons/dine360_kds/security/ir.model.access.csv new file mode 100644 index 0000000..358099f --- /dev/null +++ b/addons/dine360_kds/security/ir.model.access.csv @@ -0,0 +1,6 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_kds_order_line_kitchen,pos.order.line.kitchen,point_of_sale.model_pos_order_line,dine360_restaurant.group_restaurant_kitchen,1,1,0,0 +access_kds_order_line_manager,pos.order.line.manager,point_of_sale.model_pos_order_line,dine360_restaurant.group_restaurant_manager,1,1,1,1 +access_kds_order_line_user,pos.order.line.user,point_of_sale.model_pos_order_line,base.group_user,1,1,1,0 +access_kds_pos_session_kitchen,pos.session.kitchen,point_of_sale.model_pos_session,dine360_restaurant.group_restaurant_kitchen,1,0,0,0 +access_kds_pos_category_kitchen,pos.category.kitchen,point_of_sale.model_pos_category,dine360_restaurant.group_restaurant_kitchen,1,0,0,0 diff --git a/addons/dine360_kds/static/description/icon.png b/addons/dine360_kds/static/description/icon.png new file mode 100644 index 0000000..187a841 Binary files /dev/null and b/addons/dine360_kds/static/description/icon.png differ diff --git a/addons/dine360_kds/static/src/css/kds_style.css b/addons/dine360_kds/static/src/css/kds_style.css new file mode 100644 index 0000000..c9d5f32 --- /dev/null +++ b/addons/dine360_kds/static/src/css/kds_style.css @@ -0,0 +1,49 @@ +.role_kitchen_card { + transition: transform 0.2s ease, box-shadow 0.2s ease; + cursor: default !important; +} + +.role_kitchen_card:hover { + transform: translateY(-2px); + box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1) !important; +} + +.o_kanban_group { + background-color: #f4f7fa !important; + border-radius: 15px !important; + margin: 10px !important; + padding: 10px !important; +} + +.o_kanban_header { + background: transparent !important; + padding: 15px 10px !important; +} + +.o_kanban_header_title { + font-weight: 700 !important; + text-transform: uppercase !important; + letter-spacing: 1px !important; +} + +/* Specific colors for status columns */ +.o_kanban_group[data-id="waiting"] .o_kanban_header_title { + color: #f0ad4e; +} + +.o_kanban_group[data-id="preparing"] .o_kanban_header_title { + color: #5bc0de; +} + +.o_kanban_group[data-id="ready"] .o_kanban_header_title { + color: #5cb85c; +} + +.o_kanban_group[data-id="served"] .o_kanban_header_title { + color: #777; +} + +.badge.rounded-pill { + padding: 0.5em 1em; + font-weight: 600; +} \ No newline at end of file diff --git a/addons/dine360_kds/static/src/css/pos_kds.css b/addons/dine360_kds/static/src/css/pos_kds.css new file mode 100644 index 0000000..375c0e1 --- /dev/null +++ b/addons/dine360_kds/static/src/css/pos_kds.css @@ -0,0 +1,29 @@ +.pos .badge { + padding: 2px 6px; + border-radius: 4px; + font-size: 0.8rem; + font-weight: bold; + color: white; + margin-left: 5px; + vertical-align: middle; +} + +.pos .badge-waiting { + background-color: #f0ad4e; +} + +.pos .badge-preparing { + background-color: #5bc0de; +} + +.pos .badge-ready { + background-color: #5cb85c; +} + +.pos .badge-served { + background-color: #777; +} + +.pos .badge-cancelled { + background-color: #d9534f; +} \ No newline at end of file diff --git a/addons/dine360_kds/static/src/js/kds_backend.js b/addons/dine360_kds/static/src/js/kds_backend.js new file mode 100644 index 0000000..5088a89 --- /dev/null +++ b/addons/dine360_kds/static/src/js/kds_backend.js @@ -0,0 +1,91 @@ +/** @odoo-module */ + +import { registry } from "@web/core/registry"; +import { KanbanController } from "@web/views/kanban/kanban_controller"; +import { kanbanView } from "@web/views/kanban/kanban_view"; +import { onWillUnmount } from "@odoo/owl"; +import { useService } from "@web/core/utils/hooks"; + +export class KdsKanbanController extends KanbanController { + setup() { + super.setup(); + console.log("[KDS Controller] Setup"); + + // Direct access to services to avoid 'methods is not iterable' error in Owl lifecycle + this.busService = this.env.services.bus_service; + this.notification = this.env.services.notification; + + const kdsChannel = "kds_channel"; + + if (this.busService) { + console.log(`[KDS Controller] Subscribing to channel: ${kdsChannel}`); + this.busService.addChannel(kdsChannel); + + const handler = this._onKdsNotification.bind(this); + this.busService.addEventListener("notification", handler); + + onWillUnmount(() => { + if (this.busService) { + this.busService.removeEventListener("notification", handler); + } + }); + } + } + + _onKdsNotification(event) { + // console.log("[KDS Controller] Notification received:", event); + + const notifications = event.detail || []; + let shouldReload = false; + + for (const notif of notifications) { + // console.log("[KDS Controller] Processing notification:", notif); + + if (notif.type === "kds_new_order") { + console.log("[KDS Controller] New order notification:", notif.payload); + + // Show notification to user + if (this.notification) { + const payload = notif.payload; + this.notification.add( + `New Order: ${payload.qty}x ${payload.product_name} - ${payload.table_name || 'Web/Takeaway'}`, + { + title: "Kitchen Display", + type: "info", + } + ); + } + + shouldReload = true; + } + } + + if (shouldReload) { + // Reload the view to show the new order + // Adding a small delay to ensure the transaction is fully committed and indexed + console.log("[KDS Controller] New order received. Reloading view in 1.5s..."); + setTimeout(async () => { + try { + await this.model.load(); + console.log("[KDS Controller] View reloaded. Current record count:", this.model.root.count); + + // Force a re-render if the view still thinks it's empty + if (this.model.root.count > 0) { + this.render(); + } + } catch (error) { + console.error("[KDS Controller] Error during reload:", error); + } + }, 1500); + } + } +} + +export const kdsKanbanView = { + ...kanbanView, + Controller: KdsKanbanController, +}; + +registry.category("views").add("kds_kanban", kdsKanbanView); + +console.log("[KDS Backend] kds_backend.js loaded (JS Class mode - manual services)"); diff --git a/addons/dine360_kds/static/src/js/pos_kds.js b/addons/dine360_kds/static/src/js/pos_kds.js new file mode 100644 index 0000000..02f2c5e --- /dev/null +++ b/addons/dine360_kds/static/src/js/pos_kds.js @@ -0,0 +1,133 @@ +/** @odoo-module */ + +// Immediate console log to verify file is loaded +console.log("=============================================="); +console.log("[KDS] pos_kds.js FILE IS LOADING!"); +console.log("=============================================="); + +import { Orderline } from "@point_of_sale/app/store/models"; +import { PosStore } from "@point_of_sale/app/store/pos_store"; +import { patch } from "@web/core/utils/patch"; + +console.log("[KDS] Imports successful"); + +// Patch Orderline model +patch(Orderline.prototype, { + setup(attr) { + super.setup(...arguments); + if (attr && attr.preparation_status) { + this.preparation_status = attr.preparation_status; + } else if (!this.preparation_status) { + this.preparation_status = 'waiting'; + } + }, + + init_from_JSON(json) { + super.init_from_JSON(...arguments); + if (json.preparation_status) { + this.preparation_status = json.preparation_status; + } + }, + + export_as_JSON() { + const json = super.export_as_JSON(...arguments); + json.preparation_status = this.preparation_status; + return json; + }, + + set_preparation_status(status) { + this.preparation_status = status; + }, + + get_preparation_status() { + return this.preparation_status; + }, + + get_preparation_status_label() { + const labels = { + 'waiting': 'Waiting', + 'preparing': 'Preparing', + 'ready': 'Ready', + 'served': 'Served', + 'cancelled': 'Cancelled' + }; + return labels[this.preparation_status] || this.preparation_status; + }, + + can_be_merged_with(line) { + // Prevent merging if the existing line is already being prepared or served + if (this.preparation_status && this.preparation_status !== 'waiting') { + return false; + } + return super.can_be_merged_with(...arguments); + } +}); + +console.log("[KDS] Orderline patched successfully"); + +// Patch PosStore +patch(PosStore.prototype, { + async _processData(loadedData) { + console.log("[KDS] _processData called!"); + await super._processData(...arguments); + + const channel = `pos_config_Channel_${this.config.id}`; + console.log(`[KDS] Setting up channel: ${channel}`); + + try { + const busService = this.env.services.bus_service; + busService.addChannel(channel); + console.log("[KDS] Channel added successfully"); + + busService.addEventListener("notification", (event) => { + console.log("[KDS] *** NOTIFICATION RECEIVED ***", event); + const notifications = event.detail || []; + + for (const notif of notifications) { + if (notif.type === 'kds_update') { + console.log("[KDS] *** KDS UPDATE ***", notif.payload); + this._handleKdsUpdate(notif.payload); + } + } + }); + + console.log("[KDS] Listener registered successfully"); + } catch (error) { + console.error("[KDS] ERROR:", error); + } + }, + + _handleKdsUpdate(payload) { + console.log("[KDS] Handling update:", payload); + + try { + const { order_uid, product_id, status, status_label, line_id, order_id } = payload; + const orders = this.get_order_list(); + + let order = orders.find(o => o.server_id === order_id); + if (!order) { + order = orders.find(o => o.name === order_uid || o.pos_reference === order_uid); + } + + if (order) { + let line = order.get_orderlines().find(l => l.server_id === line_id); + if (!line) { + line = order.get_orderlines().find(l => l.product.id === product_id); + } + + if (line) { + line.set_preparation_status(status); + this.env.services.notification.add( + `${line.product.display_name} is ${status_label}`, + { title: "Kitchen Update", type: "info" } + ); + } + } + } catch (error) { + console.error("[KDS] Error in handler:", error); + } + } +}); + +console.log("[KDS] PosStore patched successfully"); +console.log("[KDS] MODULE FULLY LOADED!"); diff --git a/addons/dine360_kds/static/src/xml/pos_kds.xml b/addons/dine360_kds/static/src/xml/pos_kds.xml new file mode 100644 index 0000000..07ac8a8 --- /dev/null +++ b/addons/dine360_kds/static/src/xml/pos_kds.xml @@ -0,0 +1,12 @@ + + + + +
  • + + + +
  • +
    +
    +
    diff --git a/addons/dine360_kds/views/kds_menus.xml b/addons/dine360_kds/views/kds_menus.xml new file mode 100644 index 0000000..61df6bf --- /dev/null +++ b/addons/dine360_kds/views/kds_menus.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/addons/dine360_kds/views/pos_order_line_views.xml b/addons/dine360_kds/views/pos_order_line_views.xml new file mode 100644 index 0000000..bb8c86d --- /dev/null +++ b/addons/dine360_kds/views/pos_order_line_views.xml @@ -0,0 +1,150 @@ + + + + pos.order.line.kds.kanban + pos.order.line + + + + + + + + + + + + + + + +
    +
    +
    +
    + + x + +
    +
    + + + +
    +
    + + +
    + Note: +
    +
    + +
    +
    + + +
    +
    + + + + + + +
    +
    + +
    +
    + +
    +
    + + + +
    +
    +
    +
    +
    +
    +
    +
    +
    + + + + pos.order.line.kds.tree + pos.order.line + + + + + + + + + + + + + + + + + + + pos.order.line.kds.search + pos.order.line + + + + + + + + + + + + + + + + + + + + + + + + + Kitchen Display System + pos.order.line + kanban,tree,form + [('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')] + + {'search_default_today': 1} + +

    + Welcome to the Kitchen! +

    +

    + Orders sent from the POS will appear here for preparation. +

    +
    +
    +
    diff --git a/addons/dine360_kds/views/product_views.xml b/addons/dine360_kds/views/product_views.xml new file mode 100644 index 0000000..0566568 --- /dev/null +++ b/addons/dine360_kds/views/product_views.xml @@ -0,0 +1,13 @@ + + + + product.template.form.kds + product.template + + + + + + + + diff --git a/addons/dine360_online_orders/__init__.py b/addons/dine360_online_orders/__init__.py new file mode 100644 index 0000000..f7209b1 --- /dev/null +++ b/addons/dine360_online_orders/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import controllers diff --git a/addons/dine360_online_orders/__manifest__.py b/addons/dine360_online_orders/__manifest__.py new file mode 100644 index 0000000..c26da6e --- /dev/null +++ b/addons/dine360_online_orders/__manifest__.py @@ -0,0 +1,32 @@ +{ + 'name': 'Dine360 Online Orders in POS', + 'version': '17.0.1.0', + 'category': 'Sales/Point of Sale', + 'summary': 'Receive website shop orders on POS screen with KDS integration', + 'description': """ + Online Orders Integration for POS: + - Website shop orders appear in POS as a new 'Online Orders' tab + - Cashier can confirm or reject online orders + - Confirmed orders are sent to Kitchen Display System (KDS) + - Real-time notifications via bus service + """, + 'author': 'Dine360', + 'depends': ['point_of_sale', 'pos_restaurant', 'dine360_kds', 'website_sale', 'sale_management', 'dine360_order_channels'], + 'data': [ + 'security/ir.model.access.csv', + 'views/pos_order_views.xml', + 'views/kds_override_views.xml', + 'views/pos_config_views.xml', + 'views/website_sale_templates.xml', + 'views/backend_online_orders.xml', + ], + 'assets': { + 'web.assets_frontend': [ + 'dine360_online_orders/static/src/css/service_mode.css', + 'dine360_online_orders/static/src/js/service_mode.js', + ], + }, + 'installable': True, + 'application': False, + 'license': 'LGPL-3', +} diff --git a/addons/dine360_online_orders/controllers/__init__.py b/addons/dine360_online_orders/controllers/__init__.py new file mode 100644 index 0000000..12a7e52 --- /dev/null +++ b/addons/dine360_online_orders/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/addons/dine360_online_orders/controllers/main.py b/addons/dine360_online_orders/controllers/main.py new file mode 100644 index 0000000..b0eb327 --- /dev/null +++ b/addons/dine360_online_orders/controllers/main.py @@ -0,0 +1,65 @@ +from odoo import http +from odoo.http import request +from odoo.addons.website_sale.controllers.main import WebsiteSale + +class Dine360OnlineOrders(http.Controller): + + @http.route('/shop/update_service_mode', type='json', auth="public", website=True) + def update_service_mode(self, service_mode, **post): + order = request.website.sale_get_order() + if order and service_mode in ['pickup', 'delivery', 'dine_in']: + order.sudo().write({ + 'fulfilment_type': service_mode, + 'order_source': 'online' + }) + return True + +class Dine360WebsiteSaleOnline(WebsiteSale): + @http.route(['/shop/payment'], type='http', auth="public", website=True, sitemap=False) + def shop_payment(self, **post): + order = request.website.sale_get_order() + if order and not order.carrier_id: + # Bypass delivery validation by assigning the first available delivery carrier + carriers = order._get_delivery_methods() + if not carriers: + carriers = request.env['delivery.carrier'].sudo().search([('is_published', '=', True)], limit=1) + + if carriers: + order.carrier_id = carriers[0].id + price = carriers[0].rate_shipment(order)['price'] if hasattr(carriers[0], 'rate_shipment') and order._get_delivery_methods() else 0.0 + order.set_delivery_line(carriers[0], price) + + return super(Dine360WebsiteSaleOnline, self).shop_payment(**post) + + @http.route('/shop/payment/transaction/', type='json', auth="public", website=True) + def shop_payment_transaction(self, order_id, access_token=None, **kwargs): + # Force bypass "No shipping method is selected" error during payment processing + order = request.env['sale.order'].sudo().browse(order_id) + if order and not order.carrier_id: + carriers = request.env['delivery.carrier'].sudo().search([('is_published', '=', True)], limit=1) + if carriers: + order.carrier_id = carriers[0].id + order.set_delivery_line(carriers[0], 0.0) + return super(Dine360WebsiteSaleOnline, self).shop_payment_transaction(order_id, access_token=access_token, **kwargs) + + @http.route(['/shop/address'], type='http', methods=['GET', 'POST'], auth="public", website=True, sitemap=False) + def address(self, **post): + # Override to inject dummy address values for pickup orders before Odoo validates them + if post.get('submitted') and post.get('fulfilment_type') == 'pickup': + if not post.get('street'): post['street'] = 'Store Pickup' + if not post.get('city'): post['city'] = 'Local' + if not post.get('zip'): post['zip'] = '00000' + if not post.get('country_id'): + country = request.env['res.country'].sudo().search([], limit=1) + post['country_id'] = str(country.id) if country else '' + + # Request.params must also be updated for Odoo 17 validation + for k, v in post.items(): + if k in ['street', 'city', 'zip', 'country_id']: + if hasattr(request.params, 'update'): + try: + request.params[k] = v + except TypeError: + pass + + return super(Dine360WebsiteSaleOnline, self).address(**post) diff --git a/addons/dine360_online_orders/models/__init__.py b/addons/dine360_online_orders/models/__init__.py new file mode 100644 index 0000000..8a88826 --- /dev/null +++ b/addons/dine360_online_orders/models/__init__.py @@ -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 diff --git a/addons/dine360_online_orders/models/pos_config.py b/addons/dine360_online_orders/models/pos_config.py new file mode 100644 index 0000000..3a413ef --- /dev/null +++ b/addons/dine360_online_orders/models/pos_config.py @@ -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') diff --git a/addons/dine360_online_orders/models/pos_order.py b/addons/dine360_online_orders/models/pos_order.py new file mode 100644 index 0000000..b539d25 --- /dev/null +++ b/addons/dine360_online_orders/models/pos_order.py @@ -0,0 +1,206 @@ +from odoo import models, fields, api, _ +import logging + +_logger = logging.getLogger(__name__) + + +class PosOrder(models.Model): + _inherit = 'pos.order' + + is_online_order = fields.Boolean( + string='Online Order', + default=False, + help='Indicates this order came from the website shop' + ) + online_order_status = fields.Selection([ + ('pending', 'Pending Kitchen'), + ('confirmed', 'Confirmed'), + ('rejected', 'Rejected'), + ], string='Online Order Status', default='pending') + + sale_order_id = fields.Many2one( + 'sale.order', string='Source Sale Order', + help='The website sale order that generated this POS order' + ) + online_customer_name = fields.Char( + string='Online Customer', + compute='_compute_online_customer_name', store=True + ) + online_order_date = fields.Datetime( + string='Online Order Date', + default=fields.Datetime.now + ) +# delivery_time = fields.Datetime(string='Requested Delivery Time') + + # Note: order_source and fulfilment_type fields are defined in dine360_order_channels + # dine360_online_orders just uses these fields + + @api.depends('partner_id', 'partner_id.name') + def _compute_online_customer_name(self): + for order in self: + order.online_customer_name = order.partner_id.name or 'Guest' + + def action_confirm_online_order(self): + """Cashier confirms the online order → sends to KDS and marks as paid if already paid online""" + self.ensure_one() + self.write({'online_order_status': 'confirmed'}) + + # If it's an online order with an online payment option or Stripe txn, mark as paid in POS to avoid confusion + has_paid_transaction = False + if self.sale_order_id: + has_paid_transaction = any(t.state in ['authorized', 'done'] for t in self.sale_order_id.transaction_ids) + + if self.is_online_order and self.sale_order_id and (self.sale_order_id.payment_option == 'online_gateway' or has_paid_transaction): + # Check if it needs payment (not yet paid in POS) + if self.state == 'draft' and self.amount_total > 0 and self.amount_paid < self.amount_total: + # Find a suitable payment method (Online Payment or Stripe) + # We prioritize methods linked to the current POS config + payment_method = self._get_online_payment_method() + + if payment_method: + _logger.info("Automatically adding online payment from Stripe gateway for order %s using method %s", self.name, payment_method.name) + + # Use add_payment if it exists, otherwise manual creation + payment_data = { + 'amount': self.amount_total, + 'payment_date': fields.Datetime.now(), + 'payment_method_id': payment_method.id, + 'pos_order_id': self.id, + } + if hasattr(self, 'add_payment'): + self.add_payment(payment_data) + else: + self.env['pos.payment'].create(payment_data) + + # Force recomputation of amount_paid + self.env.flush_all() + self.invalidate_recordset(['payment_ids', 'amount_paid']) + + # Instead of relying strictly on action_pos_order_paid which throws UserError on cache lag, + # we force the state logic directly if we just paid the exact full amount. + self.write({'state': 'paid'}) + try: + self._create_order_picking() + except AttributeError: + _logger.warning("No _create_order_picking method found on POS order") + except Exception as e: + _logger.error("Error creating picking for online POS order: %s", str(e)) + else: + _logger.warning("Could not find a suitable POS Payment Method for online order %s", self.name) + + # Set all order lines to 'waiting' so KDS picks them up + for line in self.lines: + if line.product_id.is_kitchen_item and line.product_id.type != 'service': + line.write({ + 'preparation_status': 'waiting', + }) + + # Notify KDS + self.lines.filtered( + lambda l: l.product_id.is_kitchen_item and l.product_id.type != 'service' + )._notify_kds() + + # Notify POS that order was confirmed + if self.config_id: + channel = "online_orders_%s" % self.config_id.id + self.env['bus.bus']._sendone(channel, 'online_order_confirmed', { + 'order_id': self.id, + 'order_name': self.pos_reference or self.name, + }) + + _logger.info("Online order %s confirmed and sent to KDS", self.name) + return True + + def action_reject_online_order(self): + """Cashier rejects the online order""" + self.ensure_one() + self.write({'online_order_status': 'rejected'}) + + # Notify POS + if self.config_id: + channel = "online_orders_%s" % self.config_id.id + self.env['bus.bus']._sendone(channel, 'online_order_rejected', { + 'order_id': self.id, + 'order_name': self.pos_reference or self.name, + }) + + _logger.info("Online order %s rejected", self.name) + return True + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if 'session_id' in vals: + session = self.env['pos.session'].browse(vals['session_id']) + if session.config_id.is_kiosk: + vals['order_source'] = 'kiosk' + vals['fulfilment_type'] = session.config_id.kiosk_service_mode or 'pickup' + return super().create(vals_list) + + def _get_online_payment_method(self): + """Find a suitable POS payment method for online/stripe payments""" + # 1. Look for methods in the current config first + if self.config_id: + for method in self.config_id.payment_method_ids: + if 'online' in method.name.lower() or 'stripe' in method.name.lower(): + return method + + # Fallback to any non-cash method in config + for method in self.config_id.payment_method_ids: + if not method.is_cash_count: + return method + + # 2. Global search if config search fails + method = self.env['pos.payment.method'].search([ + ('name', 'ilike', 'Online'), + ], limit=1) + if not method: + method = self.env['pos.payment.method'].search([ + ('name', 'ilike', 'Stripe'), + ], limit=1) + if not method: + method = self.env['pos.payment.method'].search([ + ('is_cash_count', '=', False) + ], limit=1) + + return method + + @api.model + def get_online_orders(self, config_id): + """Fetch pending online orders for a specific POS config""" + domain = [ + ('is_online_order', '=', True), + ('online_order_status', '=', 'pending'), + ('config_id', '=', config_id), + ] + orders = self.search(domain, order='online_order_date desc') + + result = [] + for order in orders: + lines = [] + for line in order.lines: + lines.append({ + 'id': line.id, + 'product_name': line.full_product_name or line.product_id.name, + 'qty': line.qty, + 'price_unit': line.price_unit, + 'price_subtotal_incl': line.price_subtotal_incl, + 'customer_note': line.customer_note or '', + 'is_kitchen_item': line.product_id.is_kitchen_item, + }) + + result.append({ + 'id': order.id, + 'name': order.pos_reference or order.name, + 'partner_name': order.partner_id.name or 'Guest', + 'partner_phone': order.partner_id.phone or order.partner_id.mobile or '', + 'amount_total': order.amount_total, + 'date_order': order.date_order.isoformat() if order.date_order else '', + 'sale_order_name': order.sale_order_id.name if order.sale_order_id else '', + 'service_mode': order.fulfilment_type, + 'order_source': order.order_source, + 'note': order.note or '', + 'lines': lines, + }) + + return result diff --git a/addons/dine360_online_orders/models/pos_order_line.py b/addons/dine360_online_orders/models/pos_order_line.py new file mode 100644 index 0000000..561f558 --- /dev/null +++ b/addons/dine360_online_orders/models/pos_order_line.py @@ -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 diff --git a/addons/dine360_online_orders/models/res_config_settings.py b/addons/dine360_online_orders/models/res_config_settings.py new file mode 100644 index 0000000..3c49767 --- /dev/null +++ b/addons/dine360_online_orders/models/res_config_settings.py @@ -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) diff --git a/addons/dine360_online_orders/models/sale_order.py b/addons/dine360_online_orders/models/sale_order.py new file mode 100644 index 0000000..96c9be3 --- /dev/null +++ b/addons/dine360_online_orders/models/sale_order.py @@ -0,0 +1,247 @@ +from odoo import models, fields, api, _ +import logging + +_logger = logging.getLogger(__name__) + + +class SaleOrderOnline(models.Model): + _inherit = 'sale.order' + + pos_order_id = fields.Many2one( + 'pos.order', string='POS Order', + help='The POS order created from this website sale order' + ) + + # order_source is now canonical field from dine360_order_channels (pos.order) + # We add it to sale.order for tracking which channel the web sale originated from + order_source = fields.Selection([ + ('online', 'Online'), + ('phone', 'Phone'), + ('whatsapp', 'WhatsApp'), + ('social_media', 'Social Media'), + ('in_person', 'In-Person (Walk-in/Dine-in)'), + ('kiosk', 'Store Self-Order (Kiosk)'), + ('party_order', 'Party Order'), + ('platform_integration', 'Platform Integration (3rd Party)'), + ], string='Order Source', default='online', tracking=True) + + fulfilment_type = fields.Selection([ + ('pickup', 'Pickup'), + ('delivery', 'Delivery'), + ('dine_in', 'Dine-In'), + ('walk_in', 'Walk-In'), + ], string='Fulfillment Type', default='pickup', tracking=True) + + def _has_deliverable_products(self): + """ Bypass Odoo's native delivery carrier validation for store pickup orders """ + res = super(SaleOrderOnline, self)._has_deliverable_products() + if self.fulfilment_type == 'pickup': + return False + return res + + online_order_status = fields.Selection([ + ('pending', 'Pending Kitchen'), + ('confirmed', 'Confirmed'), + ('rejected', 'Rejected'), + ], string='Online Order Status', compute='_compute_online_order_status', store=True) + + is_online_order_accepted_manually = fields.Boolean(string='Manually Accepted', default=False) + + @api.depends('pos_order_id.online_order_status', 'state', 'order_source', 'is_online_order_accepted_manually') + def _compute_online_order_status(self): + for order in self: + if order.pos_order_id: + order.online_order_status = order.pos_order_id.online_order_status + elif order.state == 'cancel': + order.online_order_status = 'rejected' + elif order.order_source == 'online' and order.state in ['draft', 'sent', 'sale']: + if order.is_online_order_accepted_manually: + order.online_order_status = 'confirmed' + else: + order.online_order_status = 'pending' + else: + order.online_order_status = False + + def action_accept_online_order(self): + for order in self: + if order.state in ['draft', 'sent']: + order.action_confirm() + + if not order.pos_order_id and order.state == 'sale': + try: + if hasattr(order, '_create_pos_order_for_kds'): + order._create_pos_order_for_kds(order) + except Exception as e: + _logger.error("Failed to create POS order during accept: %s", str(e)) + + if order.pos_order_id: + order.pos_order_id.action_confirm_online_order() + else: + order.is_online_order_accepted_manually = True + + order._compute_online_order_status() + return {'type': 'ir.actions.client', 'tag': 'reload'} + + def action_reject_online_order(self): + for order in self: + if order.state in ['draft', 'sent', 'sale']: + order._action_cancel() + + if order.pos_order_id: + order.pos_order_id.action_reject_online_order() + + order._compute_online_order_status() + return {'type': 'ir.actions.client', 'tag': 'reload'} + + def _check_cart_is_ready_to_be_paid(self): + try: + return super(SaleOrderOnline, self)._check_cart_is_ready_to_be_paid() + except Exception as e: + if self.fulfilment_type == 'pickup' and type(e).__name__ == 'ValidationError': + err_str = str(e).lower() + if 'shipping method' in err_str or 'delivery' in err_str: + return True + raise e + + payment_option = fields.Selection([ + ('in_store', 'In Store'), + ('terminal_in_store', 'Payment Terminal (In Store)'), + ('terminal_customer', 'Payment Terminal (Customer Place)'), + ('online_gateway', 'Online Payment Gateway'), + ('cash', 'Cash'), + ('interac', 'Interac'), + ], string='Payment Option', tracking=True) + +# delivery_time = fields.Datetime(string='Requested Delivery Time', tracking=True) + + telephone_number = fields.Char('Telephone Number') + + reservation_source = fields.Selection([ + ('online', 'Online'), + ('phone', 'Phone'), + ('staff', 'Staff'), + ], string='Reservation Source', tracking=True) + + reservation_status = fields.Selection([ + ('draft', 'Request Received'), + ('confirmed', 'Confirmed'), + ('arrived', 'Arrived'), + ('seated', 'Seated'), + ('cancelled', 'Cancelled'), + ], string='Reservation Status', default='draft', tracking=True) + + def _is_shippable_order(self): + """ + Treat pickup, delivery and other types as non-shippable for Odoo's standard validation. + This enables 'Billing-only' checkout which is more reliable for payment providers. + The Uber delivery line is protected by our _remove_delivery_line override. + """ + self.ensure_one() + if self.fulfilment_type in ['pickup', 'delivery', 'dine_in', 'walk_in']: + return False + return super()._is_shippable_order() + + def _check_carrier_quotation(self, force_carrier_id=None, **kwargs): + """Allow proceeding to payment if we already have a carrier (Uber) or don't need one""" + self.ensure_one() + _logger.info("Checking carrier quotation for order %s (fulfilment: %s, carrier: %s)", self.name, self.fulfilment_type, self.carrier_id.name if self.carrier_id else 'None') + if self.fulfilment_type in ['pickup', 'delivery', 'dine_in', 'walk_in']: + return True + # If we have a carrier set by our Uber integration, trust it and skip standard re-validation + if self.carrier_id and 'Uber' in (self.carrier_id.name or ''): + return True + return super()._check_carrier_quotation(force_carrier_id=force_carrier_id, **kwargs) + + def _remove_delivery_line(self): + """ + Prevent Odoo from removing the delivery line if its an Uber order. + Odoo often tries to clean up delivery lines on page transitions if it + thinks the shipping method is no longer valid. + """ + self.ensure_one() + if self.carrier_id and 'Uber' in (self.carrier_id.name or ''): + _logger.info("Protecting Uber delivery line from removal on order %s", self.name) + return True + return super()._remove_delivery_line() + + def _create_pos_order_for_kds(self, sale_order): + """ + Override from dine360_kds to also mark the POS order as an online order. + This method is called by dine360_kds.website_sale_integration when a + website sale order is confirmed. + """ + # Let the parent create the POS order + super(SaleOrderOnline, self)._create_pos_order_for_kds(sale_order) + + # Now find the POS order that was just created and mark it + # We look for the most recent POS order linked to this sale order's partner + # with the note containing the sale order name + PosOrder = self.env['pos.order'] + pos_order = PosOrder.search([ + ('note', 'like', sale_order.name), + ], order='id desc', limit=1) + + if pos_order: + pos_order.write({ + 'is_online_order': True, + 'online_order_status': 'pending', + 'sale_order_id': sale_order.id, + 'online_order_date': fields.Datetime.now(), + 'order_source': sale_order.order_source or 'online', + 'fulfilment_type': sale_order.fulfilment_type or 'pickup', +# 'delivery_time': sale_order.delivery_time, +# 'uber_eta': sale_order.delivery_time, + }) + + # Link back to sale order + sale_order.write({'pos_order_id': pos_order.id}) + + # Check if paid via gateway (custom field) or standard Odoo transaction (Stripe, etc.) + has_paid_transaction = any(t.state in ['authorized', 'done'] for t in sale_order.transaction_ids) + if (sale_order.payment_option == 'online_gateway' or has_paid_transaction) and sale_order.amount_total > 0: + payment_method = pos_order._get_online_payment_method() + if payment_method: + _logger.info("Recording online payment for POS order %s from Sale Order %s", pos_order.name, sale_order.name) + payment_data = { + 'amount': sale_order.amount_total, + 'payment_date': fields.Datetime.now(), + 'payment_method_id': payment_method.id, + 'pos_order_id': pos_order.id, + } + if hasattr(pos_order, 'add_payment'): + pos_order.add_payment(payment_data) + else: + pos_order.env['pos.payment'].create(payment_data) + + pos_order.env.flush_all() + pos_order.invalidate_recordset(['payment_ids', 'amount_paid']) + + # Process as paid so the state changes and payment button disappears safely + pos_order.write({'state': 'paid'}) + try: + pos_order._create_order_picking() + except AttributeError: + _logger.warning("No _create_order_picking method found on POS order") + except Exception as e: + _logger.error("Error creating picking for online POS order: %s", str(e)) + + # Set all lines to a "hold" state - they will go to KDS only when cashier confirms + for line in pos_order.lines: + if line.product_id.is_kitchen_item: + line.write({'preparation_status': 'waiting'}) + + # Send bus notification to POS + if pos_order.config_id: + channel = "online_orders_%s" % pos_order.config_id.id + self.env['bus.bus']._sendone(channel, 'new_online_order', { + 'order_id': pos_order.id, + 'order_name': pos_order.pos_reference or pos_order.name, + 'customer_name': sale_order.partner_id.name or 'Guest', + 'amount_total': pos_order.amount_total, + 'items_count': len(pos_order.lines), + }) + + _logger.info( + "Marked POS Order %s as online order from Sale Order %s", + pos_order.name, sale_order.name + ) diff --git a/addons/dine360_online_orders/security/ir.model.access.csv b/addons/dine360_online_orders/security/ir.model.access.csv new file mode 100644 index 0000000..e22fb7a --- /dev/null +++ b/addons/dine360_online_orders/security/ir.model.access.csv @@ -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 diff --git a/addons/dine360_online_orders/static/description/icon.png b/addons/dine360_online_orders/static/description/icon.png new file mode 100644 index 0000000..187a841 Binary files /dev/null and b/addons/dine360_online_orders/static/description/icon.png differ diff --git a/addons/dine360_online_orders/static/src/css/online_orders.css b/addons/dine360_online_orders/static/src/css/online_orders.css new file mode 100644 index 0000000..9cf2294 --- /dev/null +++ b/addons/dine360_online_orders/static/src/css/online_orders.css @@ -0,0 +1,320 @@ +/* ============================================ */ +/* Dine360 Online Orders Screen - POS */ +/* ============================================ */ + +.online-orders-screen { + background: #171422; + color: #eee; + font-family: 'Inter', 'Segoe UI', sans-serif; +} + +/* Header */ +.online-orders-header { + background: #171422; + border-bottom: 2px solid rgba(255, 255, 255, 0.1); + min-height: 60px; +} + +.online-orders-title { + color: #fff; + font-size: 1.4rem; +} + +.order-count-badge { + font-size: 0.8rem; + padding: 4px 10px; + border-radius: 20px; + animation: pulse-badge 2s infinite; +} + +@keyframes pulse-badge { + + 0%, + 100% { + transform: scale(1); + } + + 50% { + transform: scale(1.15); + } +} + +.btn-back { + border-radius: 10px; + font-weight: 600; +} + +.btn-refresh { + border-radius: 10px; + font-weight: 600; + border-color: rgba(255, 255, 255, 0.3); + color: rgba(255, 255, 255, 0.8); +} + +.btn-refresh:hover { + background: rgba(255, 255, 255, 0.1); + color: #fff; +} + +/* Body */ +.online-orders-body { + background: #171422; +} + +/* Left Panel - Orders List */ +.online-orders-list { + width: 380px; + min-width: 380px; + overflow-y: auto; + border-right: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(0, 0, 0, 0.15); +} + +/* Order Card */ +.order-card { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 14px; + cursor: pointer; + transition: all 0.25s ease; +} + +.order-card:hover { + background: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.2); + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3); +} + +.order-card.selected { + background: rgba(214, 17, 30, 0.1); + border-color: #d6111e; + box-shadow: 0 0 15px rgba(214, 17, 30, 0.2); +} + +.order-ref { + color: #fff; + font-size: 0.95rem; +} + +.order-total { + color: #53cf8a; + font-size: 1.1rem; +} + +.order-customer { + color: #ccc; + font-size: 0.9rem; +} + +.order-date { + font-size: 0.8rem; +} + +/* Action Buttons in Card */ +.btn-confirm-order { + border-radius: 8px; + font-weight: 600; + background: #27ae60; + border: none; + padding: 8px 0; +} + +.btn-confirm-order:hover { + background: #2ecc71; +} + +.btn-reject-order { + border-radius: 8px; + font-weight: 600; + background: transparent; + border: 1px solid #e74c3c; + color: #e74c3c; + padding: 8px 0; +} + +.btn-reject-order:hover { + background: #e74c3c; + color: #fff; +} + +/* Right Panel - Order Detail */ +.online-order-detail { + overflow-y: auto; +} + +.detail-card { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 16px; + height: 100%; +} + +.detail-section { + padding-bottom: 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.detail-section:last-of-type { + border-bottom: none; +} + +/* Customer Avatar */ +.customer-avatar { + width: 44px; + height: 44px; + border-radius: 50%; + background: linear-gradient(135deg, #d6111e, #1a1d23); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.2rem; + color: #fff; +} + +/* Note Box */ +.note-box { + background: rgba(255, 193, 7, 0.1); + border: 1px solid rgba(255, 193, 7, 0.2); + color: #ffc107; + font-style: italic; +} + +/* Order Lines Table */ +.order-lines-table { + color: #ddd; +} + +.order-lines-table thead th { + border-bottom: 2px solid rgba(255, 255, 255, 0.15); + color: #aaa; + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 10px 8px; +} + +.order-lines-table tbody td { + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + padding: 12px 8px; + vertical-align: middle; +} + +.order-lines-table tbody tr:hover { + background: rgba(255, 255, 255, 0.05); +} + +.total-row td { + border-top: 2px solid rgba(255, 255, 255, 0.2) !important; + padding-top: 14px; + font-size: 1.15rem; +} + +.order-total-amount { + color: #53cf8a; + font-size: 1.3rem !important; +} + +/* Kitchen Badge */ +.kitchen-badge { + width: 24px; + height: 24px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.7rem; + background: rgba(255, 255, 255, 0.1); + color: #888; +} + +.kitchen-badge.active { + background: rgba(214, 17, 30, 0.2); + color: #d6111e; +} + +/* Detail Action Buttons */ +.btn-confirm-detail { + border-radius: 12px; + font-weight: 700; + font-size: 1.05rem; + background: linear-gradient(135deg, #27ae60, #2ecc71); + border: none; + padding: 14px 24px; + box-shadow: 0 4px 15px rgba(46, 204, 113, 0.3); +} + +.btn-confirm-detail:hover { + background: linear-gradient(135deg, #2ecc71, #27ae60); + transform: translateY(-1px); + box-shadow: 0 6px 20px rgba(46, 204, 113, 0.4); +} + +.btn-reject-detail { + border-radius: 12px; + font-weight: 600; + padding: 14px 24px; +} + +/* Empty State */ +.empty-state .empty-icon { + font-size: 5rem; + color: rgba(255, 255, 255, 0.15); + display: block; +} + +/* Navbar Button */ +.online-orders-nav-btn { + background: rgba(214, 17, 30, 0.15); + border: 1px solid rgba(214, 17, 30, 0.3); + color: #d6111e; + border-radius: 10px; + padding: 6px 14px; + font-weight: 600; + font-size: 0.85rem; + transition: all 0.25s ease; +} + +.online-orders-nav-btn:hover { + background: #d6111e; + color: #fff; + border-color: #d6111e; +} + +/* Scrollbar */ +.online-orders-list::-webkit-scrollbar, +.online-order-detail::-webkit-scrollbar { + width: 6px; +} + +.online-orders-list::-webkit-scrollbar-thumb, +.online-order-detail::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.15); + border-radius: 3px; +} + +.online-orders-list::-webkit-scrollbar-track, +.online-order-detail::-webkit-scrollbar-track { + background: transparent; +} + +/* Spinner */ +.spinner-border { + color: #d6111e !important; +} + +/* Responsive */ +@media (max-width: 768px) { + .online-orders-list { + width: 100%; + min-width: 100%; + } + + .online-order-detail { + display: none; + } + + .order-card.selected+.online-order-detail { + display: block; + } +} \ No newline at end of file diff --git a/addons/dine360_online_orders/static/src/css/service_mode.css b/addons/dine360_online_orders/static/src/css/service_mode.css new file mode 100644 index 0000000..4f10ffb --- /dev/null +++ b/addons/dine360_online_orders/static/src/css/service_mode.css @@ -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); + } +} \ No newline at end of file diff --git a/addons/dine360_online_orders/static/src/js/online_orders_navbar.js b/addons/dine360_online_orders/static/src/js/online_orders_navbar.js new file mode 100644 index 0000000..ca6c5dd --- /dev/null +++ b/addons/dine360_online_orders/static/src/js/online_orders_navbar.js @@ -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!"); diff --git a/addons/dine360_online_orders/static/src/js/online_orders_screen.js b/addons/dine360_online_orders/static/src/js/online_orders_screen.js new file mode 100644 index 0000000..1a9efc6 --- /dev/null +++ b/addons/dine360_online_orders/static/src/js/online_orders_screen.js @@ -0,0 +1,177 @@ +/** @odoo-module */ + +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; +import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl"; +import { usePos } from "@point_of_sale/app/store/pos_hook"; + +console.log("[OnlineOrders] Module loading..."); + +export class OnlineOrdersScreen extends Component { + static template = "dine360_online_orders.OnlineOrdersScreen"; + + setup() { + try { + console.log("[OnlineOrders] Setup starting..."); + this.pos = usePos(); + + // Direct access to services to avoid 'methods is not iterable' error + this.orm = this.env.services.orm; + this.notification = this.env.services.pos_notification; + this.busService = this.env.services.bus_service; + + this.state = useState({ + orders: [], + loading: true, + selectedOrder: null, + confirmingId: null, + error: null, + }); + + console.log("[OnlineOrders] Services obtained:", { + hasOrm: !!this.orm, + hasNotif: !!this.notification, + hasBus: !!this.busService + }); + + // Subscribe to bus notifications for real-time updates + const channel = `online_orders_${this.pos.config.id}`; + console.log("[OnlineOrders] Subscribing to channel:", channel); + + if (this.busService) { + this.busService.addChannel(channel); + this._notifHandler = this._onNotification.bind(this); + this.busService.addEventListener("notification", this._notifHandler); + } + } catch (err) { + console.error("[OnlineOrders] Setup Error:", err); + } + + onMounted(() => { + this.loadOnlineOrders(); + // Auto-refresh every 30 seconds + this._refreshInterval = setInterval(() => { + this.loadOnlineOrders(); + }, 30000); + }); + + onWillUnmount(() => { + if (this._refreshInterval) { + clearInterval(this._refreshInterval); + } + if (this.busService && this._notifHandler) { + this.busService.removeEventListener("notification", this._notifHandler); + } + }); + } + + _onNotification(event) { + const notifications = event.detail || []; + for (const notif of notifications) { + if (notif.type === "new_online_order") { + console.log("[OnlineOrders] New order received!", notif.payload); + this.notification.add( + `New Online Order from ${notif.payload.customer_name} - ${this.env.utils.formatCurrency(notif.payload.amount_total)}` + ); + this.loadOnlineOrders(); + } + if (notif.type === "online_order_confirmed" || notif.type === "online_order_rejected") { + this.loadOnlineOrders(); + } + } + } + + async loadOnlineOrders() { + try { + this.state.loading = true; + this.state.error = null; + const orders = await this.orm.call( + "pos.order", + "get_online_orders", + [this.pos.config.id] + ); + this.state.orders = orders; + this.state.loading = false; + console.log("[OnlineOrders] Loaded", orders.length, "orders"); + } catch (error) { + console.error("[OnlineOrders] Error loading orders:", error); + this.state.loading = false; + this.state.error = "Failed to load orders. Please check your connection."; + } + } + + selectOrder(order) { + this.state.selectedOrder = order; + } + + async confirmOrder(orderId) { + try { + this.state.confirmingId = orderId; + this.state.error = null; + await this.orm.call( + "pos.order", + "action_confirm_online_order", + [[orderId]] + ); + this.notification.add("Order confirmed and sent to kitchen! 🍳"); + // Remove from list + this.state.orders = this.state.orders.filter(o => o.id !== orderId); + if (this.state.selectedOrder && this.state.selectedOrder.id === orderId) { + this.state.selectedOrder = null; + } + this.state.confirmingId = null; + } catch (error) { + console.error("[OnlineOrders] Confirm error:", error); + this.notification.add("Failed to confirm order"); + this.state.error = "Failed to confirm order. It might have been modified."; + this.state.confirmingId = null; + } + } + + async rejectOrder(orderId) { + try { + this.state.error = null; + await this.orm.call( + "pos.order", + "action_reject_online_order", + [[orderId]] + ); + this.notification.add("Order has been rejected"); + this.state.orders = this.state.orders.filter(o => o.id !== orderId); + if (this.state.selectedOrder && this.state.selectedOrder.id === orderId) { + this.state.selectedOrder = null; + } + } catch (error) { + console.error("[OnlineOrders] Reject error:", error); + this.notification.add("Failed to reject order"); + this.state.error = "Failed to reject order."; + } + } + + formatDate(isoDate) { + if (!isoDate) return ""; + const d = new Date(isoDate); + return d.toLocaleString(); + } + + formatCurrency(amount) { + return this.env.utils.formatCurrency(amount); + } + + get orderCount() { + return this.state.orders.length; + } + + back() { + if (this.pos.config.module_pos_restaurant && !this.pos.get_order()) { + this.pos.showScreen("FloorScreen"); + } else { + this.pos.showScreen("ProductScreen"); + } + } +} + +// Register the screen +registry.category("pos_screens").add("OnlineOrdersScreen", OnlineOrdersScreen); + +console.log("[OnlineOrders] Screen registered!"); diff --git a/addons/dine360_online_orders/static/src/js/service_mode.js b/addons/dine360_online_orders/static/src/js/service_mode.js new file mode 100644 index 0000000..3c2a465 --- /dev/null +++ b/addons/dine360_online_orders/static/src/js/service_mode.js @@ -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); + } + } + } +}); diff --git a/addons/dine360_online_orders/static/src/xml/online_orders_screen.xml b/addons/dine360_online_orders/static/src/xml/online_orders_screen.xml new file mode 100644 index 0000000..6422c99 --- /dev/null +++ b/addons/dine360_online_orders/static/src/xml/online_orders_screen.xml @@ -0,0 +1,261 @@ + + + + + +
    + +
    +
    + +

    + + Online Orders + + + +

    +
    + +
    + + +
    + + +
    +
    +
    + + +
    + +
    +
    + + +
    +
    +
    +

    Loading online orders...

    +
    +
    + + +
    +
    + +

    No Pending Orders

    +

    New website orders will appear here automatically

    +
    +
    + + + + +
    + +
    + +
    +
    + + + + + ONLINE +
    + + + +
    + + +
    + + + + + + + + +
    + + +
    + items + + + + + + +
    + + +
    + + +
    + + +
    + + +
    +
    +
    +
    + + +
    + +
    +

    + + Order Details: +

    + + +
    +
    Customer
    +
    +
    + +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    Note
    +
    + +
    +
    + + +
    +
    Items
    + + + + + + + + + + + + + + + + + + + + + + + + + +
    ItemQtyPriceTotal
    +
    + + + + +
    +
    + + +
    +
    + + + + + +
    Total + +
    +
    + + +
    + + +
    +
    + + +
    +
    + +

    Select an order to view details

    +
    +
    +
    +
    +
    +
    +
    + + + + + + + + + + diff --git a/addons/dine360_online_orders/views/backend_online_orders.xml b/addons/dine360_online_orders/views/backend_online_orders.xml new file mode 100644 index 0000000..ce98e19 --- /dev/null +++ b/addons/dine360_online_orders/views/backend_online_orders.xml @@ -0,0 +1,66 @@ + + + + + + + sale.order.online.tree + sale.order + + + + + + + + +
    + +
    + + +
    +
    ORDER SOURCE
    +
    + + + +
    +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    +
    FULFILMENT TYPE
    +
    + + + +
    +
    + + +
    +
    +
    Delivery Address
    + +
    + + +
    + +
    Searching...
    +
    + +
    + — + + + +
    +
    +
    +
    + + +
    + +
    + + +
    + + +
    + + +
    + +
    +
    + +
    +
    + + + + +
    +
    + +
    +
    + + + + + + + + + diff --git a/addons/dine360_theme_shivasakthi/views/faq_page.xml b/addons/dine360_theme_shivasakthi/views/faq_page.xml new file mode 100644 index 0000000..a0ccf82 --- /dev/null +++ b/addons/dine360_theme_shivasakthi/views/faq_page.xml @@ -0,0 +1,214 @@ + + + + + + + + + diff --git a/addons/dine360_theme_shivasakthi/views/layout.xml b/addons/dine360_theme_shivasakthi/views/layout.xml new file mode 100644 index 0000000..14e4322 --- /dev/null +++ b/addons/dine360_theme_shivasakthi/views/layout.xml @@ -0,0 +1,203 @@ + + + + + + + + + + + + + + + + diff --git a/addons/dine360_theme_shivasakthi/views/options.xml b/addons/dine360_theme_shivasakthi/views/options.xml new file mode 100644 index 0000000..d61c3a7 --- /dev/null +++ b/addons/dine360_theme_shivasakthi/views/options.xml @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/addons/dine360_theme_shivasakthi/views/pages.xml b/addons/dine360_theme_shivasakthi/views/pages.xml new file mode 100644 index 0000000..e8f7887 --- /dev/null +++ b/addons/dine360_theme_shivasakthi/views/pages.xml @@ -0,0 +1,796 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/dine360_theme_shivasakthi/views/product_details_page.xml b/addons/dine360_theme_shivasakthi/views/product_details_page.xml new file mode 100644 index 0000000..6bde2f6 --- /dev/null +++ b/addons/dine360_theme_shivasakthi/views/product_details_page.xml @@ -0,0 +1,62 @@ + + + + + + + + + + diff --git a/addons/dine360_theme_shivasakthi/views/product_views.xml b/addons/dine360_theme_shivasakthi/views/product_views.xml new file mode 100644 index 0000000..ee5b97a --- /dev/null +++ b/addons/dine360_theme_shivasakthi/views/product_views.xml @@ -0,0 +1,21 @@ + + + + + product.template.form.inherit.deals + product.template + + + + + + + + + + + + + + diff --git a/addons/dine360_theme_shivasakthi/views/res_config_settings_views.xml b/addons/dine360_theme_shivasakthi/views/res_config_settings_views.xml new file mode 100644 index 0000000..7a1f127 --- /dev/null +++ b/addons/dine360_theme_shivasakthi/views/res_config_settings_views.xml @@ -0,0 +1,18 @@ + + + + res.config.settings.view.form.inherit.Shivasakthi + res.config.settings + + + + + + + + + + + + + diff --git a/addons/dine360_theme_shivasakthi/views/shop_page.xml b/addons/dine360_theme_shivasakthi/views/shop_page.xml new file mode 100644 index 0000000..22a56dd --- /dev/null +++ b/addons/dine360_theme_shivasakthi/views/shop_page.xml @@ -0,0 +1,125 @@ + + + + + + + + + + + + + diff --git a/addons/dine360_theme_shivasakthi/views/snippets.xml b/addons/dine360_theme_shivasakthi/views/snippets.xml new file mode 100644 index 0000000..ffdf7a2 --- /dev/null +++ b/addons/dine360_theme_shivasakthi/views/snippets.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + diff --git a/addons/dine360_uber/__init__.py b/addons/dine360_uber/__init__.py new file mode 100644 index 0000000..f7209b1 --- /dev/null +++ b/addons/dine360_uber/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import controllers diff --git a/addons/dine360_uber/__manifest__.py b/addons/dine360_uber/__manifest__.py new file mode 100644 index 0000000..6674227 --- /dev/null +++ b/addons/dine360_uber/__manifest__.py @@ -0,0 +1,32 @@ +{ + 'name': 'Dine360 Uber Integration', + 'version': '1.0', + 'category': 'Sales/Point of Sale', + 'summary': 'Integrate Uber Eats and Uber Direct with Odoo POS', + 'description': """ + Uber Integration for Dine360: + - Sync Uber Eats orders to POS + - Request Uber Direct delivery for POS orders + - Real-time status updates between Odoo and Uber + """, + 'author': 'Dine360', + 'depends': ['point_of_sale', 'dine360_restaurant', 'dine360_kds', 'website_sale'], + 'data': [ + 'security/ir.model.access.csv', + 'data/uber_cron_data.xml', + 'views/uber_config_views.xml', + 'views/pos_order_views.xml', + ], + 'assets': { + 'point_of_sale._assets_pos': [ + 'dine360_uber/static/src/js/uber_pos.js', + 'dine360_uber/static/src/xml/uber_pos.xml', + ], + 'web.assets_backend': [ + 'dine360_uber/static/src/js/uber_backend.js', + ], + }, + 'installable': True, + 'application': True, + 'license': 'LGPL-3', +} diff --git a/addons/dine360_uber/controllers/__init__.py b/addons/dine360_uber/controllers/__init__.py new file mode 100644 index 0000000..12a7e52 --- /dev/null +++ b/addons/dine360_uber/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/addons/dine360_uber/controllers/main.py b/addons/dine360_uber/controllers/main.py new file mode 100644 index 0000000..38253ec --- /dev/null +++ b/addons/dine360_uber/controllers/main.py @@ -0,0 +1,98 @@ +from odoo import http +from odoo.http import request +import json +import logging + +_logger = logging.getLogger(__name__) + +class UberWebhookController(http.Controller): + + @http.route('/uber/webhook/delivery', type='json', auth='none', methods=['POST'], csrf=False) + def uber_delivery_webhook(self, **post): + """Handle status updates from Uber Direct""" + data = json.loads(request.httprequest.data) + _logger.info("Uber Webhook Received: %s", json.dumps(data, indent=2)) + + uber_delivery_id = data.get('delivery_id') + status = data.get('status') # e.g., 'picked_up', 'delivered' + + if uber_delivery_id: + order = request.env['pos.order'].sudo().search([('uber_delivery_id', '=', uber_delivery_id)], limit=1) + if order: + # Map Uber status to Odoo status + status_map = { + 'pickup': 'pickup', + 'pickup_completed': 'delivering', + 'dropoff_completed': 'delivered', + 'cancelled': 'cancelled' + } + order.uber_status = status_map.get(status, order.uber_status) + return {'status': 'success'} + + return {'status': 'ignored'} + + + +class UberDeliveryController(http.Controller): + + @http.route('/shop/uber/quote', type='json', auth='public', website=True, csrf=False) + def uber_quote(self, address_data, **post): + """Get Uber quote for a website address with cleaned address formatting""" + order = request.website.sale_get_order() + if not order: + return {'success': False, 'error': 'No active order'} + + config = request.env['uber.config'].sudo().search([('active', '=', True)], limit=1) + if not config: + return {'success': False, 'error': 'Uber not configured'} + + company = request.website.company_id + + # Build STRUCTURED pickup address (Object) mapping POS exactly + pickup_address = { + "street_address": [company.street or ""], + "city": company.city or "", + "state": company.state_id.code if company.state_id else "", + "zip_code": company.zip or "", + "country": company.country_id.code or "CA" + } + + # User entered address fields + street = (address_data.get('street') or '').strip() + street2 = (address_data.get('street2') or '').strip() + full_street = f"{street}, {street2}" if street2 else street + + state_input = (address_data.get('state') or '').split('(')[0].strip() + state_record = request.env['res.country.state'].sudo().search([ + ('country_id.code', '=', 'CA'), + '|', ('name', '=ilike', state_input), ('code', '=ilike', state_input) + ], limit=1) + state_code = state_record.code if state_record else "ON" + + # Build STRUCTURED dropoff address (Object) + dropoff_address = { + "street_address": [full_street], + "city": address_data.get('city', '').strip(), + "state": state_code, + "zip_code": address_data.get('zip', '').strip(), + "country": "CA" + } + + # For logging, still create strings + p_str = f"{pickup_address['street_address'][0]}, {pickup_address['city']} {pickup_address['state']}" + d_str = f"{dropoff_address['street_address'][0]}, {dropoff_address['city']} {dropoff_address['state']}" + _logger.info("WEBSITE UBER QUOTE (STRUCTURED) -\nPickup: [%s]\nDropoff: [%s]", p_str, d_str) + + # POS ENCODING: The POS sends these as JSON-encoded STRINGS + result = config.get_uber_quote(json.dumps(pickup_address), json.dumps(dropoff_address)) + + if result.get('success'): + order.sudo()._add_uber_delivery_fee(result['fee_amount']) + return { + 'success': True, + 'fee': result['fee_amount'], + 'eta': result.get('estimated_arrival'), + } + else: + _logger.warning("Uber Quote Failed: %s", result.get('error')) + return result diff --git a/addons/dine360_uber/data/uber_cron_data.xml b/addons/dine360_uber/data/uber_cron_data.xml new file mode 100644 index 0000000..4516752 --- /dev/null +++ b/addons/dine360_uber/data/uber_cron_data.xml @@ -0,0 +1,15 @@ + + + + + Uber: Check Driver Assignment Timeout + + code + model.cron_check_uber_driver_assignment() + 1 + minutes + -1 + + + + diff --git a/addons/dine360_uber/models/__init__.py b/addons/dine360_uber/models/__init__.py new file mode 100644 index 0000000..45ad1a0 --- /dev/null +++ b/addons/dine360_uber/models/__init__.py @@ -0,0 +1,4 @@ +from . import uber_config +from . import pos_order +from . import pos_order_line +from . import sale_order diff --git a/addons/dine360_uber/models/pos_order.py b/addons/dine360_uber/models/pos_order.py new file mode 100644 index 0000000..c0386f4 --- /dev/null +++ b/addons/dine360_uber/models/pos_order.py @@ -0,0 +1,298 @@ +from odoo import models, fields, api, _ +from odoo.exceptions import UserError +import datetime +import logging + +_logger = logging.getLogger(__name__) + +class PosOrder(models.Model): + _inherit = 'pos.order' + + is_uber_order = fields.Boolean(string='Is Uber Order', default=False) + uber_order_id = fields.Char(string='Uber Order ID') + uber_delivery_id = fields.Char(string='Uber Delivery ID') + uber_status = fields.Selection([ + ('pending', 'Pending Uber Pickup'), + ('pickup', 'Uber Driver Picked Up'), + ('delivering', 'In Transit'), + ('delivered', 'Delivered'), + ('cancelled', 'Cancelled') + ], string='Uber Delivery Status') + + delivery_type = fields.Selection([ + ('none', 'None'), + ('dine_in', 'Dine In'), + ('takeaway', 'Takeaway'), + ('uber', 'Uber Direct') + ], string='Delivery Type', default='none') + + # Advanced Features Fields + uber_tracking_url = fields.Char(string='Driver Tracking Link') + uber_eta = fields.Datetime(string='Predicted Delivery Time') + uber_delivery_fee = fields.Float(string='Uber Delivery Fee', readonly=True) + uber_request_time = fields.Datetime(string='Uber Request Time') + uber_alert_triggered = fields.Boolean(string='Driver Timeout Alert Sent', default=False) + + def _check_all_lines_ready(self): + """Check if all kitchen items in the order are ready or served""" + self.ensure_one() + kitchen_lines = self.lines.filtered(lambda l: l.product_id.is_kitchen_item) + if not kitchen_lines: + return False + return all(line.preparation_status in ['ready', 'served'] for line in kitchen_lines) + + def action_request_uber_delivery(self): + """Trigger Uber Direct delivery request via API""" + # Ensure imports are available inside method if not global (but better global) + # Adding imports here for safety, though cleaner at top + import requests + import json + + for order in self: + if order.is_uber_order and order.uber_status and order.uber_status != 'cancelled': + continue + + # 1. Get Configuration + config = self.env['uber.config'].search([('active', '=', True)], limit=1) + if not config: + raise UserError(_("Uber Integration is not configured. Please check Settings.")) + + customer_id = config.customer_id + if not customer_id: + raise UserError(_("Uber Customer ID is missing in configuration.")) + + # 2. Get Partner (Customer) + partner = order.partner_id + if not partner: + raise UserError(_("Customer is required for Uber delivery.")) + if not partner.street or not partner.city or not partner.zip: + raise UserError(_("Customer address is incomplete (Street, City, Zip required).")) + + # 3. Authenticate + try: + access_token = config._get_access_token() + except Exception as e: + raise UserError(_("Authentication Failed: %s") % str(e)) + + # 4. Prepare Payload + company = order.company_id + # Pickup Location (Restaurant) + pickup_address = json.dumps({ + "street_address": [company.street], + "city": company.city, + "state": company.state_id.code or "", + "zip_code": company.zip, + "country": company.country_id.code or "US" + }) + + # Dropoff (Customer) + dropoff_address = json.dumps({ + "street_address": [partner.street], + "city": partner.city, + "state": partner.state_id.code or "", + "zip_code": partner.zip, + "country": partner.country_id.code or "US" + }) + + items = [] + for line in order.lines: + if not line.product_id.is_kitchen_item: # Optional filter + continue + items.append({ + "name": line.full_product_name or line.product_id.name, + "quantity": int(line.qty), + "price": int(line.price_unit * 100), # Cents + "currency_code": order.currency_id.name + }) + + if not items: + # Fallback if no kitchen items found to at least send something + items.append({"name": "Food Order", "quantity": 1, "price": int(order.amount_total * 100), "currency_code": order.currency_id.name}) + + payload = { + "pickup_name": company.name, + "pickup_address": pickup_address, + "pickup_phone_number": company.phone or "+15555555555", + "dropoff_name": partner.name, + "dropoff_address": dropoff_address, + "dropoff_phone_number": partner.phone or partner.mobile or "+15555555555", + "manifest_items": items, + "test_specifications": {"robo_courier_specification": {"mode": "auto"}} if config.environment == 'sandbox' else None + } + + # 5. Call API + api_url = f"https://api.uber.com/v1/customers/{customer_id}/deliveries" + headers = { + 'Authorization': f'Bearer {access_token}', + 'Content-Type': 'application/json' + } + + try: + # Note: Sending the request directly to create delivery + response = requests.post(api_url, headers=headers, json=payload) + response.raise_for_status() + data = response.json() + + # 6. Process Success + # Uber API returns fee as integer (cents) usually? Need to check. + # Docs say 'fee' object with 'amount' + # Assuming 'fee' field in response is float or int. + # Careful: Uber often returns amounts in minor units or currency formatted. + # Standard response has `fee` integer? Let's assume standard float from JSON if parsed, or check specific field. + # Actually, check `fee` in response. + + delivery_fee = 0.0 + if 'fee' in data: + # Fee is in cents (minor units), convert to major units + delivery_fee = float(data['fee']) / 100.0 + + order.write({ + 'uber_status': 'pending', + 'is_uber_order': True, + 'uber_delivery_id': data.get('id'), + 'uber_request_time': fields.Datetime.now(), + 'uber_delivery_fee': delivery_fee, + 'uber_tracking_url': data.get('tracking_url'), + 'uber_eta': fields.Datetime.now() + datetime.timedelta(minutes=30) # Ideally parse `estimated_dropoff_time` + }) + + # Add charge to bill + if delivery_fee > 0: + order._add_uber_delivery_fee(delivery_fee) + + except requests.exceptions.HTTPError as e: + # Log the raw response so we can see which parameter is invalid + _logger.error("Uber Direct Raw Error Response (%s): %s", e.response.status_code, e.response.text) + # Try to parse the error message if it's JSON + try: + err_data = e.response.json() + err_code = err_data.get('code', 'unknown_error') + err_msg = err_data.get('message', 'An error occurred with Uber API.') + + if err_code == 'address_undeliverable': + # Special handling for radius errors (most common issue) + details = err_data.get('metadata', {}).get('details', '') + raise UserError(_("Address Undeliverable: The drop-off location is outside Uber's delivery radius. \n\nDetails: %s") % details) + + raise UserError(_("Uber API Error (%s): %s") % (err_code, err_msg)) + except (ValueError, AttributeError): + # Fallback to default error text if not JSON + raise UserError(_("Uber API Error %s: %s") % (e.response.status_code, e.response.text)) + except Exception as e: + raise UserError(_("Failed to request delivery: %s") % str(e)) + + def _add_uber_delivery_fee(self, amount): + """Add the delivery fee as a line item if not already added""" + config = self.env['uber.config'].search([('active', '=', True)], limit=1) + if config and config.delivery_product_id: + # Check if fee line exists + fee_line = self.lines.filtered(lambda l: l.product_id == config.delivery_product_id) + if not fee_line: + taxes = config.delivery_product_id.taxes_id.compute_all(amount, self.pricelist_id.currency_id, 1, product=config.delivery_product_id, partner=self.partner_id) + self.write({'lines': [(0, 0, { + 'product_id': config.delivery_product_id.id, + 'full_product_name': config.delivery_product_id.name, + 'price_unit': amount, + 'qty': 1, + 'tax_ids': [(6, 0, config.delivery_product_id.taxes_id.ids)], + 'price_subtotal': taxes['total_excluded'], + 'price_subtotal_incl': taxes['total_included'], + })]}) + + def action_cancel_uber_delivery(self): + for order in self: + if not order.uber_delivery_id: + continue + order.write({ + 'uber_status': 'cancelled', + 'uber_delivery_id': False, + 'is_uber_order': False, + 'uber_tracking_url': False, + 'uber_eta': False + }) + # order.message_post(body="Uber Direct delivery request cancelled.") + + def action_sync_uber_status(self): + """Fetch latest status from Uber API and update POS order""" + import requests + config = self.env['uber.config'].search([('active', '=', True)], limit=1) + if not config or not config.customer_id: + return + + access_token = config._get_access_token() + headers = {'Authorization': f'Bearer {access_token}'} + + for order in self: + if not order.uber_delivery_id: + continue + + try: + api_url = f"https://api.uber.com/v1/customers/{config.customer_id}/deliveries/{order.uber_delivery_id}" + response = requests.get(api_url, headers=headers) + if response.status_code == 200: + data = response.json() + _logger.info("Uber Status Raw Data for %s: %s", order.name, data) + status_map = { + 'pending': 'pending', + 'pickup': 'pickup', + 'pickup_complete': 'delivering', + 'delivery_complete': 'delivered', + 'delivered': 'delivered', + 'cancelled': 'cancelled' + } + new_status = status_map.get(data.get('status'), order.uber_status) + + vals = {'uber_status': new_status} + # If status progressed beyond pending, HIDE the alert + if new_status != 'pending': + vals['uber_alert_triggered'] = False + + if new_status != order.uber_status: + # Send signal to UI to refresh the order form + self.env['bus.bus']._sendone('uber_status_updates', 'status_changed', { + 'order_id': order.id, + 'new_status': new_status + }) + + order.write(vals) + _logger.info("Uber Status Synced for %s: %s", order.name, new_status) + except Exception as e: + _logger.error("Failed to sync Uber status for %s: %s", order.name, str(e)) + + @api.model + def cron_check_uber_driver_assignment(self): + """Auto-alert and status update cron""" + config = self.env['uber.config'].search([('active', '=', True)], limit=1) + if not config: + return + + # 1. Sync status for all active orders + active_orders = self.search([('uber_status', 'in', ['pending', 'pickup', 'delivering'])]) + active_orders.action_sync_uber_status() + + # 2. Trigger alerts for those still stuck in pending + if config.timeout_minutes > 0: + timeout_threshold = fields.Datetime.now() - datetime.timedelta(minutes=config.timeout_minutes) + pending_orders = self.search([ + ('uber_status', '=', 'pending'), + ('uber_request_time', '<=', timeout_threshold), + ('uber_alert_triggered', '=', False) + ]) + + for order in pending_orders: + order.uber_alert_triggered = True + self.env['bus.bus']._sendone('pos_alerts', 'uber_timeout', { + 'order_name': order.name, + 'minutes': config.timeout_minutes + }) + + def action_view_uber_map(self): + """Open Uber Live Tracking Link""" + self.ensure_one() + if not self.uber_tracking_url: + raise UserError(_("No tracking link available yet.")) + return { + 'type': 'ir.actions.act_url', + 'url': self.uber_tracking_url, + 'target': 'new', + } diff --git a/addons/dine360_uber/models/pos_order_line.py b/addons/dine360_uber/models/pos_order_line.py new file mode 100644 index 0000000..19a185f --- /dev/null +++ b/addons/dine360_uber/models/pos_order_line.py @@ -0,0 +1,17 @@ +from odoo import models, fields, api + +class PosOrderLine(models.Model): + _inherit = 'pos.order.line' + + def action_mark_ready(self): + """Override to check if we should request Uber delivery when items are ready""" + res = super(PosOrderLine, self).action_mark_ready() + + for line in self: + order = line.order_id + # Only auto-request if it's marked as an Uber delivery type and not yet requested + if order.delivery_type == 'uber' and not order.uber_delivery_id: + if order._check_all_lines_ready(): + order.action_request_uber_delivery() + + return res diff --git a/addons/dine360_uber/models/sale_order.py b/addons/dine360_uber/models/sale_order.py new file mode 100644 index 0000000..322e21f --- /dev/null +++ b/addons/dine360_uber/models/sale_order.py @@ -0,0 +1,91 @@ +from odoo import models, fields, api, _ +import logging + +_logger = logging.getLogger(__name__) + +class SaleOrder(models.Model): + _inherit = 'sale.order' + + def _get_available_carriers(self, **kwargs): + """Force Include the Uber carrier in the available list to bypass Odoo's address/website filters""" + carriers = super()._get_available_carriers(**kwargs) + if self.carrier_id and 'Uber' in (self.carrier_id.name or ''): + if self.carrier_id not in carriers: + carriers |= self.carrier_id + return carriers + + def _add_uber_delivery_fee(self, amount): + """Add the delivery fee using Odoo's standard delivery system to satisfy checkout validation""" + _logger.info("Uber: Syncing delivery fee %s to order %s", amount, self.name) + + # 1. Ensure fulfillment type is set + if hasattr(self, 'fulfilment_type'): + self.write({ + 'fulfilment_type': 'delivery', + 'partner_shipping_id': self.partner_id.id + }) + else: + self.write({'partner_shipping_id': self.partner_id.id}) + + # 2. Find or Create the Uber Delivery carrier + Carrier = self.env['delivery.carrier'].sudo() + config = self.env['uber.config'].sudo().search([('active', '=', True)], limit=1) + + # Search for any carrier linked to the Uber product or with Uber in the name + carrier = Carrier.search(['|', ('name', 'ilike', 'Uber'), ('product_id', '=', config.delivery_product_id.id if config.delivery_product_id else 0)], limit=1) + + if not carrier and config: + # Fallback product if one isn't set in config + product = config.delivery_product_id + if not product: + product = self.env['product.product'].sudo().search([('name', 'ilike', 'Delivery')], limit=1) + if not product: + product = self.env['product.product'].sudo().search([], limit=1) # Last resort + + _logger.info("Uber: Creating new Uber Delivery carrier using product %s", product.name) + carrier = Carrier.create({ + 'name': 'Uber Direct Delivery', + 'delivery_type': 'fixed', + 'product_id': product.id, + 'website_published': True, + 'fixed_price': 0.0, + 'active': True, + }) + + if carrier: + # Force carrier to be published and global to avoid "No shipping method" error + carrier.sudo().write({ + 'website_published': True, + 'active': True, + 'country_ids': [(6, 0, [])], + 'state_ids': [(6, 0, [])], + 'zip_prefix_ids': [(6, 0, [])] if hasattr(Carrier, 'zip_prefix_ids') else False + }) + + # 3. Use Odoo's standard method to set delivery + _logger.info("Uber: Setting carrier %s with fee %s", carrier.name, amount) + + # Lock the price on the carrier record itself temporarily (SUDO) to satisfy Odoo's check_carrier + carrier.sudo().write({'fixed_price': amount}) + + # Remove any existing delivery lines first to prevent accumulation + # We do this manually because our protection in online_orders blocks the automatic removal + self.order_line.filtered(lambda l: l.is_delivery).sudo().unlink() + + # Apply to order + self.sudo().set_delivery_line(carrier, amount) + self.sudo().write({'carrier_id': carrier.id}) + + # Force the line name and price one more time to be sure + delivery_line = self.order_line.filtered(lambda l: l.is_delivery) + if delivery_line: + delivery_line.sudo().write({ + 'price_unit': amount, + 'name': f"Uber Direct Delivery ({self.partner_id.city or 'Local'})" + }) + else: + _logger.warning("Uber: No delivery carrier found to apply fee") + + # Save everything to DB immediately + self.env.cr.commit() + return True diff --git a/addons/dine360_uber/models/uber_config.py b/addons/dine360_uber/models/uber_config.py new file mode 100644 index 0000000..fdb9519 --- /dev/null +++ b/addons/dine360_uber/models/uber_config.py @@ -0,0 +1,233 @@ +from odoo import models, fields, api, _ +from odoo.exceptions import UserError +import requests +import json +import datetime +import logging + +_logger = logging.getLogger(__name__) + +class UberConfig(models.Model): + _name = 'uber.config' + _description = 'Uber Integration Configuration' + + name = fields.Char(string='Config Name', required=True, default='Uber Eats / Direct') + client_id = fields.Char(string='Client ID', required=True) + client_secret = fields.Char(string='Client Secret', required=True) + customer_id = fields.Char(string='Customer ID (Uber Direct)') + environment = fields.Selection([ + ('sandbox', 'Sandbox / Testing'), + ('production', 'Production / Live') + ], string='Environment', default='sandbox', required=True) + scope = fields.Char(string='OAuth Scope', default='delivery', help="Space-separated list of scopes, e.g., 'eats.deliveries' or 'delivery'. check your Uber Dashboard.") + + timeout_minutes = fields.Integer(string='Driver Assignment Alert Timeout (min)', default=15) + delivery_product_id = fields.Many2one('product.product', string='Uber Delivery Fee Product', + help="Service product used to add Uber charges to the bill.") + + access_token = fields.Char(string='Current Access Token') + token_expiry = fields.Datetime(string='Token Expiry') + + active = fields.Boolean(default=True) + + def _get_api_base_url(self): + """Return the API base URL based on environment""" + self.ensure_one() + # Uber Direct API v1 + return "https://api.uber.com/v1" + + def _get_access_token(self): + """Get or refresh OAuth 2.0 access token""" + self.ensure_one() + now = fields.Datetime.now() + + # Return existing valid token + if self.access_token and self.token_expiry and self.token_expiry > now: + return self.access_token + + # Clean credentials + client_id = self.client_id.strip() if self.client_id else '' + client_secret = self.client_secret.strip() if self.client_secret else '' + scope = self.scope.strip() if self.scope else 'delivery' + + # Request new token + token_url = "https://login.uber.com/oauth/v2/token" + payload = { + 'client_id': client_id, + 'client_secret': client_secret, + 'grant_type': 'client_credentials', + 'scope': scope # Required scope for Uber Direct + } + + try: + response = requests.post(token_url, data=payload) + response.raise_for_status() + data = response.json() + + access_token = data.get('access_token') + expires_in = data.get('expires_in', 2592000) # Default 30 days + + # Save token + self.write({ + 'access_token': access_token, + 'token_expiry': now + datetime.timedelta(seconds=expires_in - 60) # Buffer + }) + return access_token + + except requests.exceptions.RequestException as e: + error_msg = str(e) + if e.response is not None: + try: + error_data = e.response.json() + if 'error' in error_data: + error_msg = f"{error_data.get('error')}: {error_data.get('error_description', '')}" + except ValueError: + error_msg = e.response.text + raise UserError(_("Authentication Failed: %s") % error_msg) + + def action_test_connection(self): + """Test connection and auto-detect correct scope if 'invalid_scope' error occurs""" + self.ensure_one() + + # 1. Try with current configured scope first + try: + token = self._get_access_token() + message = f"Connection Successful! Token retrieved using scope: {self.scope}" + msg_type = "success" + return self._return_notification(message, msg_type) + except UserError as e: + # Only attempt auto-fix if error is related to scope + if "invalid_scope" not in str(e) and "scope" not in str(e).lower(): + return self._return_notification(f"Connection Failed: {str(e)}", "danger") + + # 2. Auto-Discovery: Try known Uber Direct scopes + potential_scopes = ['delivery', 'eats.deliveries', 'direct.organizations', 'guest.deliveries'] + + # Remove current scope from list to avoid redundant check + current = self.scope.strip() if self.scope else '' + if current in potential_scopes: + potential_scopes.remove(current) + + working_scope = None + + for trial_scope in potential_scopes: + try: + # Temporarily set scope to test + self._auth_with_scope(trial_scope) + working_scope = trial_scope + break # Found one! + except Exception: + continue # Try next + + # 3. Handle Result + if working_scope: + self.write({'scope': working_scope}) + self._get_access_token() # Refresh token storage + message = f"Success! We found the correct scope '{working_scope}' and updated your settings." + msg_type = "success" + else: + message = "Connection Failed. Your Client ID does not appear to have ANY Uber Direct permissions (eats.deliveries, delivery, etc). Please enabling the 'Uber Direct' product in your Uber Dashboard." + msg_type = "danger" + + return self._return_notification(message, msg_type) + + def _auth_with_scope(self, scope_to_test): + """Helper to test a specific scope without saving""" + client_id = self.client_id.strip() if self.client_id else '' + client_secret = self.client_secret.strip() if self.client_secret else '' + + token_url = "https://login.uber.com/oauth/v2/token" + payload = { + 'client_id': client_id, + 'client_secret': client_secret, + 'grant_type': 'client_credentials', + 'scope': scope_to_test + } + + response = requests.post(token_url, data=payload) + response.raise_for_status() # Will raise error if scope invalid + return True + + def get_uber_quote(self, pickup_address, dropoff_address, items=None): + """Get delivery quote from Uber API""" + self.ensure_one() + access_token = self._get_access_token() + customer_id = self.customer_id + if not customer_id: + raise UserError(_("Uber Customer ID is missing in configuration.")) + + api_url = f"https://api.uber.com/v1/customers/{customer_id}/delivery_quotes" + headers = { + 'Authorization': f'Bearer {access_token}', + 'Content-Type': 'application/json' + } + + # Ensure at least one dummy item if none provided (Uber Direct sometimes requires this) + if not items: + items = [{ + "name": "Food Delivery", + "quantity": 1, + "size": "small" + }] + + payload = { + "pickup_address": pickup_address, + "dropoff_address": dropoff_address, + "manifest_items": items + } + + _logger.info("Uber Direct Payload: %s", json.dumps(payload, indent=2)) + + try: + response = requests.post(api_url, headers=headers, json=payload) + _logger.info("Uber Direct Raw Response (%s): %s", response.status_code, response.text) + + if response.status_code != 200: + # Log detailed error for debugging + _logger.error("Uber Quote Error: %s - %s", response.status_code, response.text) + data = {} + try: + data = response.json() + except: + pass + + # Construct descriptive error message + msg = data.get('message', 'Uber API Error') + if data.get('errors'): + details = " ".join([e.get('message', '') for e in data['errors']]) + if details: + msg = f"{msg} {details}" + + return { + 'success': False, + 'error': msg, + 'code': data.get('code', 'unknown'), + 'raw_error': data + } + + data = response.json() + # Standard fee is in cents + fee_cents = data.get('fee', 0) + return { + 'success': True, + 'quote_id': data.get('id'), + 'fee_amount': float(fee_cents) / 100.0, + 'currency': data.get('currency_code', 'USD'), + 'estimated_arrival': data.get('estimated_arrival'), + 'raw': data + } + except Exception as e: + _logger.exception("Uber Quote API Exception") + return {'success': False, 'error': str(e)} + + def _return_notification(self, message, msg_type): + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': 'Connection Test', + 'message': message, + 'type': msg_type, + 'sticky': False if msg_type == 'success' else True, + } + } diff --git a/addons/dine360_uber/security/ir.model.access.csv b/addons/dine360_uber/security/ir.model.access.csv new file mode 100644 index 0000000..eceef70 --- /dev/null +++ b/addons/dine360_uber/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_uber_config_manager,uber.config manager,model_uber_config,point_of_sale.group_pos_manager,1,1,1,1 +access_uber_config_user,uber.config user,model_uber_config,point_of_sale.group_pos_user,1,0,0,0 diff --git a/addons/dine360_uber/static/description/icon.png b/addons/dine360_uber/static/description/icon.png new file mode 100644 index 0000000..187a841 Binary files /dev/null and b/addons/dine360_uber/static/description/icon.png differ diff --git a/addons/dine360_uber/static/src/js/uber_backend.js b/addons/dine360_uber/static/src/js/uber_backend.js new file mode 100644 index 0000000..87edd2a --- /dev/null +++ b/addons/dine360_uber/static/src/js/uber_backend.js @@ -0,0 +1,27 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; + +const UberStatusService = { + dependencies: ["bus_service", "action"], + start(env, { bus_service, action }) { + // Odoo 17 Bus Service uses addChannel and subscribe + bus_service.addChannel("uber_status_updates"); + bus_service.subscribe("notification", (notifications) => { + for (const { type, payload } of notifications) { + if (type === "uber_status_updates") { + const currentController = action.currentController; + if (currentController && + currentController.props.resModel === "pos.order" && + currentController.props.resId === payload.order_id) { + + console.log("Uber Status Update Received. Refreshing Form..."); + action.restore(); + } + } + } + }); + }, +}; + +registry.category("services").add("uber_status_service", UberStatusService); diff --git a/addons/dine360_uber/static/src/js/uber_pos.js b/addons/dine360_uber/static/src/js/uber_pos.js new file mode 100644 index 0000000..20cdca5 --- /dev/null +++ b/addons/dine360_uber/static/src/js/uber_pos.js @@ -0,0 +1,40 @@ +/** @odoo-module */ + +import { ReceiptScreen } from "@point_of_sale/app/screens/receipt_screen/receipt_screen"; +import { patch } from "@web/core/utils/patch"; +import { useService } from "@web/core/utils/hooks"; + +patch(ReceiptScreen.prototype, { + setup() { + super.setup(...arguments); + this.orm = useService("orm"); + this.notification = useService("notification"); + }, + async requestUber() { + const order = this.props.order; + const serverId = order.server_id; + + if (!serverId) { + this.notification.add("Wait! This order hasn't been sent to the server yet. Please wait a second.", { + title: "Uber Integration", + type: "warning", + }); + return; + } + + try { + await this.orm.call("pos.order", "action_request_uber_delivery", [[serverId]]); + this.notification.add("Uber Direct delivery requested successfully!", { + title: "Uber Integration", + type: "success", + }); + // Disable the button or change text if needed + } catch (error) { + const message = error.message?.data?.message || "Check server logs for details."; + this.notification.add("Failed to request Uber: " + message, { + title: "Uber Error", + type: "danger", + }); + } + } +}); diff --git a/addons/dine360_uber/static/src/xml/uber_pos.xml b/addons/dine360_uber/static/src/xml/uber_pos.xml new file mode 100644 index 0000000..a88b7be --- /dev/null +++ b/addons/dine360_uber/static/src/xml/uber_pos.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/addons/dine360_uber/views/pos_order_views.xml b/addons/dine360_uber/views/pos_order_views.xml new file mode 100644 index 0000000..26fba37 --- /dev/null +++ b/addons/dine360_uber/views/pos_order_views.xml @@ -0,0 +1,89 @@ + + + + pos.order.form.inherit.uber + pos.order + + + + + + + +