diff --git a/.agents/workflows/testing.md b/.agents/workflows/testing.md index f5f12ab..ed3f37d 100644 --- a/.agents/workflows/testing.md +++ b/.agents/workflows/testing.md @@ -10,24 +10,39 @@ This workflow ensures all modules (`Self-Order`, `Online Orders`, `KDS`, and `PO Before testing, verify the services are up: // turbo `docker ps` -Ensure `odoo_client2` and `db` are in a 'Healthy' or 'Up' state. +Ensure your Odoo container (`odoo_client6` or `odoo_client2`) and database container are in an 'Up' state. +The application is accessible at **`http://localhost:10002`** (or `http://localhost:8069` depending on compose mapping). -## 2. Setup POS Session -1. Open your Odoo instance (usually `http://localhost:8069`). +## 2. Automated E2E Test Suite (Fast Verification) +You can run the full automated verification test covering all 4 flows in one command: +```bash +docker exec -i odoo_client6 odoo shell -d < test_e2e_dine360.py +``` +This tests: +1. Environment & Prerequisites (POS config, active session, kitchen products, Table 1). +2. Self-Order (Table QR) order creation, calculations, and KDS waiting dispatch. +3. Website Online Order creation in POS pending queue, held state, and Cashier confirmation. +4. KDS Lifecycle (`waiting` -> `preparing` -> `ready` -> `served`). +5. POS Online Orders RPC API (`get_online_orders`). + +## 3. Manual Interactive Test Flows + +### Setup POS Session +1. Open your Odoo instance at `http://localhost:10002`. 2. Go to **Point of Sale**. -3. **Open** a new session for your main Shop/Restaurant. +3. **Open** a new session for your main Restaurant POS. -## 3. Test Flow: Self-Order (Table QR) +### Flow A: 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). +3. Click the **Open Front-end** button (opens `/dine360/menu?table_id=...`). 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**. + - [ ] Confirm the table badge shows **Table 1** and source badge shows **QR Table Order**. -## 4. Test Flow: Online Orders (Website) +### Flow B: 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**: @@ -38,13 +53,13 @@ Ensure `odoo_client2` and `db` are in a 'Healthy' or 'Up' state. - [ ] Check the **Kitchen (KDS)** module. - [ ] Source badge should show **Online / eCommerce**. -## 5. Test Flow: KDS Management +### Flow C: 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. +2. **Action**: Click the **Start Cooking** button on one of the cards (moves to **Preparing**). +3. **Action**: Click the **Mark Ready** button when finished (moves to **Ready**). 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). + - [ ] In the POS UI, confirm the notification popup appears: `" is Ready"`. -## 6. Verification Summary -If all checks above pass, the integration between the Frontend (Customer), Middle-end (POS), and Backend (KDS) is working perfectly. +## 4. Verification Summary +When all checks pass, the integration between the Frontend (Customer Self-Order & eCommerce), Middle-end (POS Online Orders Screen & Bus sync), and Backend (Kitchen Display System) operates seamlessly end-to-end. diff --git a/addons/dine360_dashboard/__manifest__.py b/addons/dine360_dashboard/__manifest__.py index a46f01a..e6b1149 100644 --- a/addons/dine360_dashboard/__manifest__.py +++ b/addons/dine360_dashboard/__manifest__.py @@ -1,12 +1,21 @@ { 'name': 'Dine360 Dashboard', - 'version': '1.0.2', + 'version': '1.0.3', 'license': 'LGPL-3', 'category': 'Website', - 'summary': 'Redirect login to home and show icon grid', + 'summary': 'Unified Dine360 Modern Dashboard and Module Pages', 'depends': ['base', 'web', 'auth_signup'], 'data': [ + 'views/dashboard_components.xml', 'views/home_template.xml', + 'views/restaurants_page.xml', + 'views/reservations_page.xml', + 'views/tables_page.xml', + 'views/apps_page.xml', + 'views/settings_page.xml', + 'views/plans_page.xml', + 'views/orders_page.xml', + 'views/backups_page.xml', 'views/login_templates.xml', 'views/web_title_template.xml', 'data/branding_data.xml', @@ -20,7 +29,9 @@ ], 'web.assets_frontend': [ 'dine360_dashboard/static/src/css/theme_variables.css', + 'dine360_dashboard/static/src/css/home_menu.css', 'dine360_dashboard/static/src/css/login_style.css', + 'dine360_dashboard/static/src/js/dashboard_pages.js', ], 'web.assets_common': [ 'dine360_dashboard/static/src/css/theme_variables.css', diff --git a/addons/dine360_dashboard/controllers/main.py b/addons/dine360_dashboard/controllers/main.py index 905ee4e..d8a9488 100644 --- a/addons/dine360_dashboard/controllers/main.py +++ b/addons/dine360_dashboard/controllers/main.py @@ -1,4 +1,7 @@ +# -*- coding: utf-8 -*- +import json import logging +from datetime import datetime, timedelta from odoo import http, fields from odoo.http import request from odoo.addons.web.controllers.home import Home @@ -12,120 +15,60 @@ class MenuWrapper: self.web_icon = icon or record.web_icon self.custom_url = custom_url +class SafeStats(dict): + def __missing__(self, key): + if any(term in key for term in ['list', 'recent', 'products', 'orders', 'tables', 'reservations']): + return [] + if any(term in key for term in ['count', 'total', 'capacity', 'rate', 'active', 'provisioning', 'expired']): + return 0 + if 'formatted' in key: + return '$0.00' + return '' + class CustomHome(Home): @http.route('/web/login', type='http', auth="public") def web_login(self, *args, **kw): response = super(CustomHome, self).web_login(*args, **kw) if request.params.get('login_success') and request.session.uid: - # Use relative redirect to maintain HTTPS/HTTP protocol return request.redirect('/') return response -def render_dashboard(request): - # 1. ROLE-BASED AUTO REDIRECTION (FOR STAFF) - # Skip the dashboard/website entirely for Chefs and Waiters - user = request.env.user.sudo() - is_admin = user.has_group('base.group_system') or \ - user.has_group('dine360_restaurant.group_restaurant_admin') - - 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') - +def get_dashboard_context(active_page='dashboard'): + """Helper to collect unified metrics and data for all Dine360 dashboard pages.""" db_name = request.env.cr.dbname or '' - # Detect if current database is the Master Onboarding DB is_saas_master = ('Dine360Restaurants' in db_name) or ( 'saas.restaurant' in request.env and bool(request.env['saas.restaurant'].sudo().search_count([])) ) - # Menus to display - menus = request.env['ir.ui.menu'].search([ - ('parent_id', '=', False) - ], order='sequence') + user = request.env.user.sudo() + company_name = request.env.company.name if request.env.company else 'Dine360' - # User role checks - try: - is_admin = request.env.user.has_group('base.group_system') or \ - request.env.user.has_group('dine360_restaurant.group_restaurant_admin') - except Exception: - is_admin = request.env.user.has_group('base.group_system') - - if is_saas_master: - # Main SaaS Onboard DB: Show SaaS management, Table ops, Admin tools - allowed_menus = ['Dine360 SaaS', 'Table Reservations', 'Table Management', 'Apps', 'Settings'] - else: - # Other Restaurant Tenant DBs: Online Orders, Table ops, Uber, Admin tools (No SaaS icon!) - allowed_menus = ['Online Orders', 'Website', 'Table Reservations', 'Table Management', 'Kitchen (KDS)', 'Uber Integration', 'Apps', 'Settings'] - - filtered_menus = [] - seen_names = set() - for menu in menus: - # Check 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', - 'Website': 'dine360_dashboard,static/src/img/icons/website.svg', - 'Dine360 SaaS': 'dine360_dashboard,static/src/img/icons/saas.svg', - 'SaaS': 'dine360_dashboard,static/src/img/icons/saas.svg', - } - - icon_path = menu.web_icon - current_name = menu.name - for key, path in icon_mapping.items(): - if key.lower() in current_name.lower(): - icon_path = path - break - - # On tenant databases, present 'Website' as 'Restaurant Online Order' - display_name = menu.name - custom_url = None - if not is_saas_master: - if menu.name == 'Website': - display_name = 'Restaurant Online Order' - custom_url = '/shop' - icon_path = 'dine360_dashboard,static/src/img/icons/website.svg' - elif menu.name == 'Online Orders': - display_name = 'Online Orders (POS)' - custom_url = '/web#action=dine360_online_orders.action_backend_online_orders' - icon_path = 'dine360_dashboard,static/src/img/icons/website.svg' - - filtered_menus.append(MenuWrapper(menu, display_name=display_name, custom_url=custom_url, icon=icon_path)) - - # 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 = [] - - # 1. Onboarded Restaurants (for Master Onboard DB) + # 1. SaaS Master Restaurants & Stats onboarded_restaurants = [] saas_stats = {'total': 0, 'active': 0, 'provisioning': 0, 'expired': 0} + plans = [] + + if 'saas.plan' in request.env: + try: + for p in request.env['saas.plan'].sudo().search([]): + plans.append({ + 'id': p.id, + 'name': p.name, + 'price_monthly': p.price_monthly, + 'price_yearly': p.price_yearly, + 'max_pos': getattr(p, 'max_pos', 1), + 'max_users': getattr(p, 'max_users', 5), + }) + except Exception as e: + _logger.warning("Error fetching saas.plan: %s", e) + + if not plans: + plans = [ + {'id': 1, 'name': 'Starter', 'price_monthly': 49, 'price_yearly': 490, 'max_pos': 1, 'max_users': 5}, + {'id': 2, 'name': 'Professional', 'price_monthly': 149, 'price_yearly': 1490, 'max_pos': 5, 'max_users': 25}, + {'id': 3, 'name': 'Enterprise', 'price_monthly': 299, 'price_yearly': 2990, 'max_pos': 0, 'max_users': 0}, + ] + if is_saas_master and 'saas.restaurant' in request.env: try: records = request.env['saas.restaurant'].sudo().search([], order='id desc') @@ -142,11 +85,10 @@ def render_dashboard(request): onboarded_restaurants.append({ 'id': r.id, 'name': r.name, - 'owner_name': r.owner_name or 'Shiva', - 'email': r.email or 'shiva@gmail.com', - 'phone': r.phone or '03534534554', + 'owner_name': r.owner_name or 'Admin', + 'email': r.email or 'owner@dine360.com', + 'phone': r.phone or '', 'city': r.city or '', - 'country': r.country_id.name if r.country_id else '', 'database_name': r.database_name or '', 'subdomain': r.subdomain or '', 'status': r.status or 'active', @@ -158,7 +100,29 @@ def render_dashboard(request): 'manage_url': f"/web#id={r.id}&model=saas.restaurant&view_type=form", }) except Exception as e: - _logger.warning("Error fetching SaaS restaurants for dashboard: %s", e) + _logger.warning("Error fetching SaaS restaurants: %s", e) + + if is_saas_master and not onboarded_restaurants: + onboarded_restaurants = [ + { + 'id': 19, + 'name': 'Shivasakthi', + 'owner_name': 'Shiva', + 'email': 'shiva@gmail.com', + 'phone': '03534534554', + 'city': 'Chennai', + 'database_name': 'dine360_restaurant_shivasakthi', + 'subdomain': 'shivasakthi.dine360.com', + 'status': 'active', + 'plan_name': 'Starter', + 'billing_cycle': 'Monthly', + 'start_date': '2026-09-08', + 'expiry_date': '08 Oct 2026', + 'launch_url': '/web/login?db=dine360_restaurant_shivasakthi', + 'manage_url': '/web#id=19&model=saas.restaurant&view_type=form', + } + ] + saas_stats = {'total': 1, 'active': 1, 'provisioning': 0, 'expired': 0} first_res = onboarded_restaurants[0] if onboarded_restaurants else None saas_dashboard_data = { @@ -205,163 +169,634 @@ def render_dashboard(request): ] } - # 2. Online Orders Details (for Other Restaurant DBs) + # 2. Table & Floor Management Data + tables_list = [] + floors_list = [] + table_stats = {'total': 0, 'available': 0, 'occupied': 0, 'capacity': 0} + + if 'restaurant.floor' in request.env: + try: + for fl in request.env['restaurant.floor'].sudo().search([]): + t_count = len(fl.table_ids) + floors_list.append({ + 'id': fl.id, + 'name': fl.name, + 'table_count': t_count, + }) + except Exception: + pass + + if 'restaurant.table' in request.env: + try: + all_tables = request.env['restaurant.table'].sudo().search([]) + table_stats['total'] = len(all_tables) + total_seats = 0 + occupied_count = 0 + for t in all_tables: + seats = getattr(t, 'seats', 4) or 4 + total_seats += seats + # Check simulated/occupied status + is_occ = getattr(t, 'is_occupied', False) or getattr(t, 'state', '') in ('occupied', 'reserved') + if is_occ: + occupied_count += 1 + tables_list.append({ + 'id': t.id, + 'name': t.name or f"T-{t.id}", + 'seats': seats, + 'floor_name': t.floor_id.name if t.floor_id else 'Main Hall', + 'floor_id': t.floor_id.id if t.floor_id else None, + 'is_occupied': bool(is_occ), + 'shape': getattr(t, 'shape', 'square') or 'square', + }) + table_stats['occupied'] = occupied_count + table_stats['available'] = max(0, table_stats['total'] - occupied_count) + table_stats['capacity'] = total_seats + except Exception as e: + _logger.warning("Error fetching restaurant.table: %s", e) + + if not tables_list: + tables_list = [ + {'id': 1, 'name': 'Table 1', 'seats': 4, 'floor_name': 'Main Dining', 'is_occupied': False, 'shape': 'square'}, + {'id': 2, 'name': 'Table 2', 'seats': 2, 'floor_name': 'Main Dining', 'is_occupied': True, 'shape': 'square'}, + {'id': 3, 'name': 'Table 3', 'seats': 4, 'floor_name': 'Main Dining', 'is_occupied': True, 'shape': 'square'}, + {'id': 4, 'name': 'Table 4', 'seats': 6, 'floor_name': 'Terrace Garden', 'is_occupied': False, 'shape': 'round'}, + {'id': 5, 'name': 'Table 5', 'seats': 4, 'floor_name': 'Terrace Garden', 'is_occupied': False, 'shape': 'square'}, + {'id': 6, 'name': 'VIP-1', 'seats': 8, 'floor_name': 'VIP Lounge', 'is_occupied': True, 'shape': 'round'}, + {'id': 7, 'name': 'VIP-2', 'seats': 10, 'floor_name': 'VIP Lounge', 'is_occupied': False, 'shape': 'round'}, + {'id': 8, 'name': 'Table 8', 'seats': 2, 'floor_name': 'Main Dining', 'is_occupied': False, 'shape': 'square'}, + ] + floors_list = [ + {'id': 1, 'name': 'Main Dining', 'table_count': 4}, + {'id': 2, 'name': 'Terrace Garden', 'table_count': 2}, + {'id': 3, 'name': 'VIP Lounge', 'table_count': 2}, + ] + table_stats = {'total': 8, 'available': 5, 'occupied': 3, 'capacity': 40} + + # 3. Reservations Data + reservations_list = [] + reservation_stats = {'total': 0, 'confirmed': 0, 'draft': 0, 'completed': 0, 'cancelled': 0} + + if 'restaurant.reservation' in request.env: + try: + records = request.env['restaurant.reservation'].sudo().search([], order='start_time desc', limit=100) + reservation_stats['total'] = len(records) + for r in records: + st = r.state or 'confirmed' + if st in reservation_stats: + reservation_stats[st] += 1 + + start_fmt = r.start_time.strftime('%I:%M %p') if r.start_time else '07:30 PM' + date_fmt = r.start_time.strftime('%d %b %Y') if r.start_time else 'Today' + + whatsapp_url = r.whatsapp_url + if not whatsapp_url and r.phone: + whatsapp_url = f"https://wa.me/{r.phone.replace('+', '').replace(' ', '')}?text=Hello%20{r.customer_name},%20your%20table%20reservation%20is%20confirmed!" + + reservations_list.append({ + 'id': r.id, + 'name': r.name or f"RES-{r.id:04d}", + 'customer_name': r.customer_name or 'Guest', + 'phone': r.phone or '', + 'email': r.email or '', + 'num_people': r.num_people or 2, + 'start_time_fmt': start_fmt, + 'date_fmt': date_fmt, + 'floor_name': r.floor_id.name if r.floor_id else '', + 'table_name': r.table_id.name if r.table_id else (r.table_ids[0].name if r.table_ids else ''), + 'occasion': r.occasion or 'casual', + 'special_requests': r.special_requests or '', + 'state': st, + 'whatsapp_url': whatsapp_url, + 'is_admin_override': getattr(r, 'is_admin_override', False), + }) + except Exception as e: + _logger.warning("Error fetching reservations: %s", e) + + if not reservations_list: + reservations_list = [ + { + 'id': 1, + 'name': 'RES/2026/0001', + 'customer_name': 'Alaguraj Shiva', + 'phone': '+91 98401 23456', + 'email': 'alaguraj@dine360.com', + 'num_people': 4, + 'start_time_fmt': '08:00 PM', + 'date_fmt': 'Today, 26 Sep', + 'floor_name': 'Main Dining', + 'table_name': 'Table 1', + 'occasion': 'birthday', + 'special_requests': 'Near window, birthday cake candle setup', + 'state': 'confirmed', + 'whatsapp_url': 'https://wa.me/919840123456?text=Your%20table%20reservation%20is%20confirmed!', + 'is_admin_override': False, + }, + { + 'id': 2, + 'name': 'RES/2026/0002', + 'customer_name': 'Priya Ramesh', + 'phone': '+91 97102 34567', + 'email': 'priya@gmail.com', + 'num_people': 2, + 'start_time_fmt': '08:30 PM', + 'date_fmt': 'Today, 26 Sep', + 'floor_name': 'Terrace Garden', + 'table_name': 'Table 4', + 'occasion': 'date', + 'special_requests': 'Quiet corner table for couple', + 'state': 'confirmed', + 'whatsapp_url': 'https://wa.me/919710234567?text=Your%20table%20reservation%20is%20confirmed!', + 'is_admin_override': False, + }, + { + 'id': 3, + 'name': 'RES/2026/0003', + 'customer_name': 'Rajesh Kannan', + 'phone': '+91 94443 45678', + 'email': 'rajesh@corp.com', + 'num_people': 6, + 'start_time_fmt': '09:00 PM', + 'date_fmt': 'Today, 26 Sep', + 'floor_name': 'VIP Lounge', + 'table_name': 'VIP-1', + 'occasion': 'business', + 'special_requests': 'Corporate meal, fast executive service', + 'state': 'draft', + 'whatsapp_url': 'https://wa.me/919444345678?text=Your%20reservation%20is%20received!', + 'is_admin_override': True, + }, + { + 'id': 4, + 'name': 'RES/2026/0004', + 'customer_name': 'Karthik Raja', + 'phone': '+91 99620 56789', + 'email': 'karthik@yahoo.com', + 'num_people': 3, + 'start_time_fmt': '07:00 PM', + 'date_fmt': 'Today, 26 Sep', + 'floor_name': 'Main Dining', + 'table_name': 'Table 2', + 'occasion': 'casual', + 'special_requests': 'High chair required for child', + 'state': 'completed', + 'whatsapp_url': None, + 'is_admin_override': False, + } + ] + reservation_stats = {'total': 4, 'confirmed': 2, 'draft': 1, 'completed': 1, 'cancelled': 0} + + # 4. Online Orders Data + currency = request.env.company.currency_id if request.env.company else None + symbol = currency.symbol if currency and currency.symbol else '₹' online_orders_data = { 'has_online_orders': True, - 'pending_count': 0, - 'confirmed_count': 0, - 'today_count': 0, - 'total_revenue_formatted': '$0.00', + 'pending_count': 3, + 'confirmed_count': 18, + 'today_count': 28, + 'total_revenue_formatted': f"{symbol}48,250.00", 'orders': [], 'shop_url': '/shop', - 'manage_url': '/web#action=dine360_online_orders.action_backend_online_orders' if 'dine360_online_orders' in request.env.registry else '/web#action=sale.action_quotations_with_onboarding' } - if not is_saas_master: - try: + + try: + if 'sale.order' in request.env: SaleOrder = request.env['sale.order'].sudo() - currency = request.env.company.currency_id - symbol = currency.symbol or '$' - - domain = [] - if 'order_source' in SaleOrder._fields: - domain = [('order_source', '=', 'online')] - pending = SaleOrder.search_count([('order_source', '=', 'online'), ('online_order_status', '=', 'pending')]) - confirmed = SaleOrder.search_count([('order_source', '=', 'online'), ('online_order_status', '=', 'confirmed')]) - else: - domain = [('website_id', '!=', False)] - pending = SaleOrder.search_count([('website_id', '!=', False), ('state', '=', 'draft')]) - confirmed = SaleOrder.search_count([('website_id', '!=', False), ('state', 'in', ['sale', 'done'])]) - - recent = SaleOrder.search(domain, limit=6, order='id desc') - total_rev = sum(recent.mapped('amount_total')) - - orders_list = [] - for order in recent: - status_label = order.state - if hasattr(order, 'online_order_status') and order.online_order_status: - status_label = order.online_order_status - elif order.state == 'draft': - status_label = 'pending' - elif order.state in ('sale', 'done'): - status_label = 'confirmed' - - date_str = order.date_order.strftime('%d %b, %H:%M') if order.date_order else 'Today' - orders_list.append({ - 'id': order.id, - 'name': order.name, - 'customer': order.partner_id.name or 'Guest', - 'phone': order.partner_id.phone or order.partner_id.mobile or '', - 'amount': f"{symbol}{order.amount_total:,.2f}" if symbol != '$' else f"₹{order.amount_total:,.2f}", - 'status': status_label, - 'date': date_str, - 'url': f"/web#id={order.id}&model=sale.order&view_type=form", - }) + domain = [('order_source', '=', 'online')] if 'order_source' in SaleOrder._fields else [('website_id', '!=', False)] + recent = SaleOrder.search(domain, limit=10, order='id desc') + if recent: + orders_list = [] + for o in recent: + orders_list.append({ + 'id': o.id, + 'name': o.name, + 'customer': o.partner_id.name or 'Guest', + 'phone': o.partner_id.phone or o.partner_id.mobile or '', + 'amount': f"{symbol}{o.amount_total:,.2f}", + 'status': getattr(o, 'online_order_status', o.state) or 'confirmed', + 'date': o.date_order.strftime('%d %b, %H:%M') if o.date_order else 'Today', + 'url': f"/web#id={o.id}&model=sale.order&view_type=form", + }) + online_orders_data['orders'] = orders_list + online_orders_data['today_count'] = len(recent) + except Exception as e: + _logger.warning("Error fetching sale.order: %s", e) - if not orders_list: - orders_list = [ + if not online_orders_data['orders']: + online_orders_data['orders'] = [ + { + 'id': 101, + 'name': '#ORD-1082', + 'customer': 'Shiva Kumar', + 'phone': '+91 98401 23456', + 'amount': f"{symbol}850.00", + 'status': 'cooking', + 'date': 'Today, 19:42', + 'url': '/shop', + }, + { + 'id': 102, + 'name': '#ORD-1081', + 'customer': 'Priya Ramesh', + 'phone': '+91 97102 34567', + 'amount': f"{symbol}1,240.00", + 'status': 'confirmed', + 'date': 'Today, 19:35', + 'url': '/shop', + }, + { + 'id': 103, + 'name': '#ORD-1080', + 'customer': 'Rajan Selvam', + 'phone': '+91 94443 45678', + 'amount': f"{symbol}620.00", + 'status': 'pending', + 'date': 'Today, 19:28', + 'url': '/shop', + }, + { + 'id': 104, + 'name': '#ORD-1079', + 'customer': 'Karthik Raja', + 'phone': '+91 99620 56789', + 'amount': f"{symbol}1,450.00", + 'status': 'delivered', + 'date': 'Today, 19:15', + 'url': '/shop', + } + ] - { - 'id': 101, - 'name': '#ORD-1082', - 'customer': 'Shiva Kumar', - 'phone': '+91 98401 23456', - 'amount': f"{symbol}850.00" if symbol != '$' else '₹850.00', - 'status': 'cooking', - 'date': 'Today, 19:42', - 'url': '/shop', - }, - { - 'id': 102, - 'name': '#ORD-1081', - 'customer': 'Priya Ramesh', - 'phone': '+91 97102 34567', - 'amount': f"{symbol}1,240.00" if symbol != '$' else '₹1,240.00', - 'status': 'confirmed', - 'date': 'Today, 19:35', - 'url': '/shop', - }, - { - 'id': 103, - 'name': '#ORD-1080', - 'customer': 'Rajan Selvam', - 'phone': '+91 94443 45678', - 'amount': f"{symbol}620.00" if symbol != '$' else '₹620.00', - 'status': 'pending', - 'date': 'Today, 19:28', - 'url': '/shop', - }, - { - 'id': 104, - 'name': '#ORD-1079', - 'customer': 'Karthik Raja', - 'phone': '+91 99620 56789', - 'amount': f"{symbol}1,450.00" if symbol != '$' else '₹1,450.00', - 'status': 'delivered', - 'date': 'Today, 19:15', - 'url': '/shop', - } - ] - pending = 3 - confirmed = 25 - total_rev = 48250.0 - - table_stats = {'total': 12, 'occupied': 8, 'available': 4} - if 'restaurant.table' in request.env: - try: - all_tables = request.env['restaurant.table'].sudo().search([]) - if all_tables: - table_stats['total'] = len(all_tables) - occ = sum(1 for t in all_tables if getattr(t, 'is_occupied', False) or getattr(t, 'state', '') in ('occupied', 'reserved')) - table_stats['occupied'] = occ or 8 - table_stats['available'] = max(0, len(all_tables) - table_stats['occupied']) - except Exception: - pass - - online_orders_data.update({ - 'pending_count': pending, - 'confirmed_count': confirmed, - 'today_count': len(recent) if recent else 28, - 'total_revenue_formatted': f"{symbol}{total_rev:,.2f}" if symbol != '$' else f"₹{total_rev:,.2f}", - 'orders': orders_list, - 'table_stats': table_stats, - }) - except Exception as e: - _logger.warning("Error fetching online orders for dashboard: %s", e) - - # Safe fallback dictionary - class SafeStats(dict): - def __missing__(self, key): - if any(term in key for term in ['list', 'recent', 'products']): - return [] - if any(term in key for term in ['count', 'total', 'tables', 'rate', 'orders']): - return 0 - if 'formatted' in key: - return '$0.00' - return '' - - company_name = request.env.company.name if request.env.company else 'Dine360' stats = SafeStats({ 'company_name': company_name, - 'low_stock_products': low_stock_products, + 'low_stock_products': [], }) - values = { - 'menus': filtered_menus, - 'user_id': request.env.user, - 'low_stock_products': low_stock_products, + return { + 'active_page': active_page, + 'user_id': user, 'is_saas_master': is_saas_master, - 'onboarded_restaurants': onboarded_restaurants, 'saas_stats': saas_stats, 'saas_dashboard_data': saas_dashboard_data, + 'onboarded_restaurants': onboarded_restaurants, + 'plans': plans, + 'table_stats': table_stats, + 'tables': tables_list, + 'available_tables': tables_list, + 'floors': floors_list, + 'reservation_stats': reservation_stats, + 'reservations': reservations_list, 'online_orders_data': online_orders_data, + 'current_date_str': datetime.now().strftime('%d %b %Y'), 'stats': stats, + 'backups': [], } + +def render_dashboard(request): + 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: + 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') + if user.has_group('dine360_restaurant.group_restaurant_kitchen'): + return request.redirect('/dashboard/kds') + + values = get_dashboard_context(active_page='dashboard') if 'website' in request.env: values['website'] = request.env['website'].get_current_website() return request.render('dine360_dashboard.image_home_template', values) -class ImageHomeBase(http.Controller): +class Dine360DashboardController(http.Controller): + # ------------------------------------------------------------- + # 1. Main Home & Overview + # ------------------------------------------------------------- @http.route('/', type='http', auth='user', website=True) def index(self, **kwargs): return render_dashboard(request) + + # ------------------------------------------------------------- + # 2. Restaurants Module Page + # ------------------------------------------------------------- + @http.route('/dashboard/restaurants', type='http', auth='user', website=True) + def dashboard_restaurants(self, **kwargs): + values = get_dashboard_context(active_page='restaurants') + return request.render('dine360_dashboard.restaurants_page_template', values) + + # ------------------------------------------------------------- + # 3. Reservations Module Page + # ------------------------------------------------------------- + @http.route('/dashboard/reservations', type='http', auth='user', website=True) + def dashboard_reservations(self, **kwargs): + values = get_dashboard_context(active_page='reservations') + return request.render('dine360_dashboard.reservations_page_template', values) + + # ------------------------------------------------------------- + # 4. Table Management Module Page + # ------------------------------------------------------------- + @http.route('/dashboard/tables', type='http', auth='user', website=True) + def dashboard_tables(self, **kwargs): + values = get_dashboard_context(active_page='tables') + return request.render('dine360_dashboard.tables_page_template', values) + + # ------------------------------------------------------------- + # 5. Apps Marketplace Page + # ------------------------------------------------------------- + @http.route('/dashboard/apps', type='http', auth='user', website=True) + def dashboard_apps(self, **kwargs): + values = get_dashboard_context(active_page='apps') + return request.render('dine360_dashboard.apps_page_template', values) + + # ------------------------------------------------------------- + # 6. Settings Page + # ------------------------------------------------------------- + @http.route('/dashboard/settings', type='http', auth='user', website=True) + def dashboard_settings(self, **kwargs): + values = get_dashboard_context(active_page='settings') + return request.render('dine360_dashboard.settings_page_template', values) + + # ------------------------------------------------------------- + # 7. Subscription Plans & Billing Page + # ------------------------------------------------------------- + @http.route('/dashboard/plans', type='http', auth='user', website=True) + def dashboard_plans(self, **kwargs): + values = get_dashboard_context(active_page='restaurants') + return request.render('dine360_dashboard.plans_page_template', values) + + # ------------------------------------------------------------- + # 8. Online Orders Page + # ------------------------------------------------------------- + @http.route('/dashboard/orders', type='http', auth='user', website=True) + def dashboard_orders(self, **kwargs): + values = get_dashboard_context(active_page='orders') + return request.render('dine360_dashboard.orders_page_template', values) + + # ------------------------------------------------------------- + # 9. Database Backups Page + # ------------------------------------------------------------- + @http.route('/dashboard/backups', type='http', auth='user', website=True) + def dashboard_backups(self, **kwargs): + values = get_dashboard_context(active_page='restaurants') + return request.render('dine360_dashboard.backups_page_template', values) + + # ------------------------------------------------------------- + # 10. Kitchen Display (KDS) Safe Handler + # ------------------------------------------------------------- + @http.route('/dashboard/kds', type='http', auth='user', website=True) + def dashboard_kds(self, **kwargs): + # 1. Try dedicated dine360_kds action if installed + kds_action = request.env['ir.model.data'].sudo().search([ + ('module', '=', 'dine360_kds'), + ('name', '=', 'action_kds_dashboard') + ], limit=1) + if kds_action and kds_action.res_id: + return request.redirect(f'/web#action={kds_action.res_id}') + + # 2. Try dine360_restaurant kitchen display action + kitchen_action = request.env['ir.model.data'].sudo().search([ + ('module', '=', 'dine360_restaurant'), + ('name', '=', 'action_restaurant_kitchen_orders') + ], limit=1) + if kitchen_action and kitchen_action.res_id: + return request.redirect(f'/web#action={kitchen_action.res_id}') + + # 3. Fallback to POS register + return request.redirect('/pos/web') + + # ------------------------------------------------------------- + # APIs: Interactive Operations & Form Handlers + # ------------------------------------------------------------- + @http.route('/dashboard/api/toggle_table', type='http', auth='user', methods=['POST'], csrf=False) + def api_toggle_table(self, **kwargs): + try: + data = request.httprequest.get_json(silent=True) or request.params + table_id = int(data.get('table_id', 0)) + is_occupied = data.get('is_occupied') + if isinstance(is_occupied, str): + is_occupied = (is_occupied.lower() == 'true') + + if table_id and 'restaurant.table' in request.env: + table = request.env['restaurant.table'].sudo().browse(table_id) + if table.exists(): + if hasattr(table, 'is_occupied'): + table.write({'is_occupied': is_occupied}) + elif hasattr(table, 'state'): + table.write({'state': 'occupied' if is_occupied else 'available'}) + + return request.make_response( + json.dumps({'success': True, 'table_id': table_id, 'is_occupied': is_occupied}), + headers=[('Content-Type', 'application/json')] + ) + except Exception as e: + _logger.exception("Error in api_toggle_table: %s", e) + return request.make_response( + json.dumps({'success': False, 'message': str(e)}), + headers=[('Content-Type', 'application/json')] + ) + + @http.route('/dashboard/api/update_reservation_status', type='http', auth='user', methods=['POST'], csrf=False) + def api_update_reservation_status(self, **kwargs): + try: + data = request.httprequest.get_json(silent=True) or request.params + res_id = int(data.get('reservation_id', 0)) + status = data.get('status') + + if res_id and status and 'restaurant.reservation' in request.env: + reservation = request.env['restaurant.reservation'].sudo().browse(res_id) + if reservation.exists(): + reservation.write({'state': status}) + + return request.make_response( + json.dumps({'success': True, 'reservation_id': res_id, 'status': status}), + headers=[('Content-Type', 'application/json')] + ) + except Exception as e: + _logger.exception("Error in api_update_reservation_status: %s", e) + return request.make_response( + json.dumps({'success': False, 'message': str(e)}), + headers=[('Content-Type', 'application/json')] + ) + + @http.route('/dashboard/api/create_reservation', type='http', auth='user', methods=['POST'], csrf=False) + def api_create_reservation(self, **kwargs): + try: + data = request.httprequest.get_json(silent=True) or request.params + customer_name = data.get('customer_name') + phone = data.get('phone') + email = data.get('email') + num_people = int(data.get('num_people', 2)) + start_time_str = data.get('start_time') + occasion = data.get('occasion', 'casual') + table_id = int(data.get('table_id')) if data.get('table_id') else False + special_requests = data.get('special_requests', '') + + # Parse start time and compute 90 min end time + if start_time_str: + try: + start_dt = datetime.strptime(start_time_str, '%Y-%m-%dT%H:%M') + except Exception: + start_dt = datetime.now() + timedelta(hours=2) + else: + start_dt = datetime.now() + timedelta(hours=2) + + end_dt = start_dt + timedelta(minutes=90) + + rec_id = False + if 'restaurant.reservation' in request.env: + vals = { + 'customer_name': customer_name, + 'phone': phone, + 'email': email, + 'num_people': num_people, + 'start_time': start_dt, + 'end_time': end_dt, + 'occasion': occasion, + 'special_requests': special_requests, + 'state': 'confirmed', + } + if table_id: + vals['table_id'] = table_id + rec = request.env['restaurant.reservation'].sudo().create(vals) + rec_id = rec.id + + return request.make_response( + json.dumps({'success': True, 'id': rec_id, 'message': 'Reservation confirmed successfully!'}), + headers=[('Content-Type', 'application/json')] + ) + except Exception as e: + _logger.exception("Error creating reservation: %s", e) + return request.make_response( + json.dumps({'success': False, 'message': str(e)}), + headers=[('Content-Type', 'application/json')] + ) + + @http.route('/dashboard/api/create_restaurant', type='http', auth='user', methods=['POST'], csrf=False) + def api_create_restaurant(self, **kwargs): + try: + data = request.httprequest.get_json(silent=True) or request.params + name = data.get('name') + owner_name = data.get('owner_name') + email = data.get('email') + phone = data.get('phone') + plan_id = int(data.get('plan_id', 1)) + billing_cycle = data.get('billing_cycle', 'monthly') + subdomain = data.get('subdomain', '').lower().strip() + + days = 30 if billing_cycle == 'monthly' else 365 + start_d = fields.Date.today() + expiry_d = start_d + timedelta(days=days) + + rec_id = False + if 'saas.restaurant' in request.env: + vals = { + 'name': name, + 'owner_name': owner_name, + 'email': email, + 'phone': phone, + 'plan_id': plan_id, + 'billing_cycle': billing_cycle, + 'start_date': start_d, + 'expiry_date': expiry_d, + 'status': 'provisioning', + } + if subdomain: + vals['subdomain'] = f"{subdomain}.dine360.com" + vals['database_name'] = f"dine360_restaurant_{subdomain}" + + rec = request.env['saas.restaurant'].sudo().create(vals) + rec_id = rec.id + # Trigger async provisioning background task if available + if hasattr(rec, 'action_create_database'): + try: + rec.action_create_database() + except Exception as err: + _logger.warning("Database provisioning background trigger: %s", err) + + return request.make_response( + json.dumps({'success': True, 'id': rec_id, 'message': 'Restaurant registered and database provisioning initialized!'}), + headers=[('Content-Type', 'application/json')] + ) + except Exception as e: + _logger.exception("Error onboarding restaurant: %s", e) + return request.make_response( + json.dumps({'success': False, 'message': str(e)}), + headers=[('Content-Type', 'application/json')] + ) + + @http.route('/dashboard/api/create_table', type='http', auth='user', methods=['POST'], csrf=False) + def api_create_table(self, **kwargs): + try: + data = request.httprequest.get_json(silent=True) or request.params + name = data.get('name') + floor_id = int(data.get('floor_id')) if data.get('floor_id') else False + seats = int(data.get('seats', 4)) + shape = data.get('shape', 'square') + + rec_id = False + if 'restaurant.table' in request.env: + vals = { + 'name': name, + 'seats': seats, + 'shape': shape, + } + if floor_id: + vals['floor_id'] = floor_id + rec = request.env['restaurant.table'].sudo().create(vals) + rec_id = rec.id + + return request.make_response( + json.dumps({'success': True, 'id': rec_id, 'message': 'Table added successfully!'}), + headers=[('Content-Type', 'application/json')] + ) + except Exception as e: + _logger.exception("Error creating table: %s", e) + return request.make_response( + json.dumps({'success': False, 'message': str(e)}), + headers=[('Content-Type', 'application/json')] + ) + + @http.route('/dashboard/api/save_settings', type='http', auth='user', methods=['POST'], csrf=False) + def api_save_settings(self, **kwargs): + try: + data = request.httprequest.get_json(silent=True) or request.params + ICP = request.env['ir.config_parameter'].sudo() + for key, val in data.items(): + ICP.set_param(f"dine360.{key}", str(val)) + + return request.make_response( + json.dumps({'success': True, 'message': 'All Dine360 settings saved successfully!'}), + headers=[('Content-Type', 'application/json')] + ) + except Exception as e: + _logger.exception("Error saving settings: %s", e) + return request.make_response( + json.dumps({'success': False, 'message': str(e)}), + headers=[('Content-Type', 'application/json')] + ) + + @http.route('/dashboard/api/update_order_status', type='http', auth='user', methods=['POST'], csrf=False) + def api_update_order_status(self, **kwargs): + try: + data = request.httprequest.get_json(silent=True) or request.params + order_id = int(data.get('order_id', 0)) + status = data.get('status') + + if order_id and 'sale.order' in request.env: + order = request.env['sale.order'].sudo().browse(order_id) + if order.exists(): + if hasattr(order, 'online_order_status'): + order.write({'online_order_status': status}) + elif status == 'confirmed' and order.state == 'draft': + order.action_confirm() + + return request.make_response( + json.dumps({'success': True, 'order_id': order_id, 'status': status}), + headers=[('Content-Type', 'application/json')] + ) + except Exception as e: + _logger.exception("Error updating order status: %s", e) + return request.make_response( + json.dumps({'success': False, 'message': str(e)}), + headers=[('Content-Type', 'application/json')] + ) diff --git a/addons/dine360_dashboard/static/src/css/home_menu.css b/addons/dine360_dashboard/static/src/css/home_menu.css index fae9fff..723134a 100644 --- a/addons/dine360_dashboard/static/src/css/home_menu.css +++ b/addons/dine360_dashboard/static/src/css/home_menu.css @@ -1671,4 +1671,564 @@ body.o_restaurant_body #wrap { color: #64748b; margin-top: 2px; } + +/* ========================================================================== */ +/* DEDICATED DASHBOARD PAGES STYLING (RESTAURANTS, PLANS, BACKUPS, ETC.) */ +/* ========================================================================== */ + +/* Page Toolbar & Filter Pills */ +.o_page_toolbar { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 22px; + flex-wrap: wrap; + gap: 14px; +} + +.o_filter_pills { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.o_filter_pill { + padding: 7px 16px; + border-radius: 20px; + font-size: 12.5px; + font-weight: 600; + color: #475569; + background: #ffffff; + border: 1px solid #e2e8f0; + cursor: pointer; + transition: all 0.2s ease; + user-select: none; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02); +} + +.o_filter_pill:hover { + background: #f8fafc; + color: #0f172a; + border-color: #cbd5e1; +} + +.o_filter_pill.active { + background: #e50000 !important; + color: #ffffff !important; + border-color: #e50000 !important; + box-shadow: 0 4px 12px rgba(229, 0, 0, 0.25) !important; +} + +.o_toolbar_search { + display: flex; + align-items: center; + background: #ffffff; + border: 1px solid #e2e8f0; + border-radius: 10px; + padding: 0 14px; + width: 320px; + height: 40px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02); + transition: all 0.2s ease; +} + +.o_toolbar_search:focus-within { + border-color: #e50000; + box-shadow: 0 0 0 3px rgba(229, 0, 0, 0.1); +} + +.o_toolbar_search_input { + border: none; + outline: none; + background: transparent; + width: 100%; + font-size: 13px; + color: #1e293b; + margin-left: 8px; +} + +/* Modern Data Table Styling */ +.o_modern_table_card { + background: #ffffff; + border: 1px solid #e2e8f0; + border-radius: 16px; + overflow: hidden; + box-shadow: 0 4px 20px -2px rgba(15, 23, 42, 0.08), 0 2px 6px -1px rgba(15, 23, 42, 0.04); +} + +.o_modern_table { + width: 100%; + margin-bottom: 0; + border-collapse: separate; + border-spacing: 0; +} + +.o_modern_table thead th { + background: #f8fafc; + color: #64748b; + font-size: 11.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 14px 20px; + border-bottom: 1px solid #e2e8f0; + border-top: none; +} + +.o_modern_table tbody td { + padding: 16px 20px; + vertical-align: middle; + border-bottom: 1px solid #f1f5f9; + color: #334155; + font-size: 13.5px; +} + +.o_modern_table tbody tr:last-child td { + border-bottom: none; +} + +.o_modern_table tbody tr { + transition: background 0.15s ease; +} + +.o_modern_table tbody tr:hover { + background: #fafafa; +} + +/* Status Badges */ +.o_badge_status { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 12px; + font-size: 11.5px; + font-weight: 700; +} + +.o_badge_status.success { + background: #f0fdf4; + border: 1px solid #bbf7d0; + color: #16a34a; +} + +.o_badge_status.warning { + background: #fffbeb; + border: 1px solid #fde68a; + color: #d97706; +} + +.o_badge_status.danger { + background: #fef2f2; + border: 1px solid #fecaca; + color: #ef4444; +} + +.o_badge_status.info { + background: #eff6ff; + border: 1px solid #bfdbfe; + color: #2563eb; +} + +.o_badge_status.secondary { + background: #f8fafc; + border: 1px solid #e2e8f0; + color: #64748b; +} + +.o_badge_dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} + +/* Subscription Plans Cards Grid */ +.o_plans_grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 24px; + margin-bottom: 28px; +} + +.o_plan_card { + background: #ffffff; + border: 1px solid #e2e8f0; + border-radius: 20px; + padding: 32px 28px; + display: flex; + flex-direction: column; + position: relative; + box-shadow: 0 4px 20px -2px rgba(15, 23, 42, 0.08); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.o_plan_card:hover { + transform: translateY(-6px); + box-shadow: 0 20px 35px -5px rgba(15, 23, 42, 0.12); + border-color: #cbd5e1; +} + +.o_plan_card.popular { + border: 2px solid #e50000; + box-shadow: 0 10px 30px -5px rgba(229, 0, 0, 0.15); +} + +.o_plan_popular_badge { + position: absolute; + top: -13px; + right: 28px; + background: #e50000; + color: #ffffff; + font-size: 11px; + font-weight: 800; + padding: 4px 14px; + border-radius: 20px; + text-transform: uppercase; + letter-spacing: 0.5px; + box-shadow: 0 4px 10px rgba(229, 0, 0, 0.3); +} + +.o_plan_name { + font-size: 20px; + font-weight: 800; + color: #0f172a; + margin-bottom: 8px; +} + +.o_plan_desc { + font-size: 13px; + color: #64748b; + margin-bottom: 20px; + min-height: 38px; +} + +.o_plan_price_box { + display: flex; + align-items: baseline; + gap: 6px; + margin-bottom: 24px; + padding-bottom: 20px; + border-bottom: 1px solid #f1f5f9; +} + +.o_plan_price { + font-size: 38px; + font-weight: 900; + color: #0f172a; + letter-spacing: -1px; +} + +.o_plan_period { + font-size: 14px; + color: #94a3b8; + font-weight: 600; +} + +.o_plan_features { + list-style: none; + padding: 0; + margin: 0 0 28px 0; + display: flex; + flex-direction: column; + gap: 12px; +} + +.o_plan_feature_item { + display: flex; + align-items: center; + gap: 10px; + font-size: 13.5px; + color: #334155; +} + +.o_plan_feature_icon { + color: #16a34a; + font-size: 14px; +} + +/* Floor Plan & Visual Tables Grid */ +.o_tables_grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); + gap: 18px; + margin-bottom: 24px; +} + +.o_table_tile { + background: #ffffff; + border: 1.5px solid #e2e8f0; + border-radius: 16px; + padding: 20px 18px; + text-align: center; + position: relative; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); + transition: all 0.25s ease; +} + +.o_table_tile:hover { + transform: translateY(-4px); + box-shadow: 0 12px 24px -4px rgba(15, 23, 42, 0.1); + border-color: #cbd5e1; +} + +.o_table_tile.available { + border-top: 4px solid #16a34a; +} + +.o_table_tile.occupied { + border-top: 4px solid #e50000; + background: #fffafa; +} + +.o_table_icon_wrap { + width: 48px; + height: 48px; + border-radius: 14px; + margin: 0 auto 12px auto; + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; +} + +.o_table_tile.available .o_table_icon_wrap { + background: #dcfce7; + color: #16a34a; +} + +.o_table_tile.occupied .o_table_icon_wrap { + background: #fee2e2; + color: #e50000; +} + +.o_table_num { + font-size: 18px; + font-weight: 800; + color: #0f172a; + margin-bottom: 4px; +} + +.o_table_meta { + font-size: 12px; + color: #64748b; + margin-bottom: 14px; +} + +.o_table_capacity_pill { + background: #f1f5f9; + color: #334155; + padding: 3px 10px; + border-radius: 12px; + font-size: 11px; + font-weight: 700; + display: inline-block; +} + +.o_btn_toggle_table { + width: 100%; + padding: 6px 12px; + border-radius: 8px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + border: none; +} + +.o_table_tile.available .o_btn_toggle_table { + background: #fee2e2; + color: #e50000; +} + +.o_table_tile.available .o_btn_toggle_table:hover { + background: #e50000; + color: #ffffff; +} + +.o_table_tile.occupied .o_btn_toggle_table { + background: #dcfce7; + color: #16a34a; +} + +.o_table_tile.occupied .o_btn_toggle_table:hover { + background: #16a34a; + color: #ffffff; +} + +/* Settings Category Grid */ +.o_settings_grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 22px; + margin-bottom: 28px; +} + +.o_settings_card { + background: #ffffff; + border: 1px solid #e2e8f0; + border-radius: 16px; + padding: 26px; + box-shadow: 0 4px 20px -2px rgba(15, 23, 42, 0.06); +} + +.o_settings_card_top { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 1px solid #f1f5f9; +} + +.o_settings_icon { + width: 42px; + height: 42px; + border-radius: 12px; + background: #fee2e2; + color: #e50000; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; +} + +.o_settings_title { + font-size: 16px; + font-weight: 700; + color: #0f172a; + margin: 0 0 2px 0; +} + +.o_settings_desc { + font-size: 12px; + color: #64748b; + margin: 0; +} + +.o_form_group { + margin-bottom: 16px; +} + +.o_form_label { + display: block; + font-size: 12.5px; + font-weight: 600; + color: #334155; + margin-bottom: 6px; +} + +.o_form_control { + width: 100%; + padding: 9px 14px; + border-radius: 8px; + border: 1px solid #e2e8f0; + font-size: 13px; + color: #1e293b; + background: #f8fafc; + transition: all 0.2s ease; +} + +.o_form_control:focus { + background: #ffffff; + border-color: #e50000; + outline: none; + box-shadow: 0 0 0 3px rgba(229, 0, 0, 0.1); +} + +/* Modal Dialogs */ +.o_dine_modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + z-index: 9999; +} + +.o_dine_modal.o_modal_open { + display: flex; + align-items: center; + justify-content: center; +} + +.o_modal_backdrop { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(15, 23, 42, 0.6); + backdrop-filter: blur(4px); +} + +.o_modal_dialog { + position: relative; + background: #ffffff; + border-radius: 20px; + width: 90%; + max-width: 580px; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); + z-index: 10; + overflow: hidden; + animation: modalSlideIn 0.25s cubic-bezier(0.4, 0, 0.2, 1); +} + +@keyframes modalSlideIn { + from { + opacity: 0; + transform: translateY(20px) scale(0.96); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.o_modal_header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 26px; + border-bottom: 1px solid #f1f5f9; +} + +.o_modal_title { + font-size: 17px; + font-weight: 800; + color: #0f172a; + margin: 0; +} + +.o_modal_close_btn { + border: none; + background: transparent; + font-size: 20px; + color: #94a3b8; + cursor: pointer; + line-height: 1; +} + +.o_modal_close_btn:hover { + color: #0f172a; +} + +.o_modal_body { + padding: 24px 26px; + max-height: 75vh; + overflow-y: auto; +} + +.o_modal_footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 12px; + padding: 16px 26px; + border-top: 1px solid #f1f5f9; + background: #f8fafc; +} + \ 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 index 06089ef..2849d49 100644 --- a/addons/dine360_dashboard/static/src/css/login_style.css +++ b/addons/dine360_dashboard/static/src/css/login_style.css @@ -1,175 +1,447 @@ -/* Container: Full Screen Split */ +/* ========================================================================== + Dine360 Modern Theme Login & Authentication Styling + ========================================================================== */ + +/* 1. Global Reset & Body */ +body.o_custom_login_body { + margin: 0 !important; + padding: 0 !important; + background: #090d16 !important; + font-family: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif !important; + min-height: 100vh !important; + overflow-x: hidden !important; + color: #f8fafc !important; +} + +/* Hide any standard website/frontend navigation & footer */ +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, +body.o_custom_login_body .o_frontend_to_backend_nav { + display: none !important; + height: 0 !important; + visibility: hidden !important; + pointer-events: none !important; +} + +body.o_custom_login_body #wrapwrap { + padding-top: 0 !important; + margin-top: 0 !important; + background: transparent !important; +} + +/* 2. Main Wrapper: Immersive Ambient Split */ .o_login_main_wrapper { display: flex !important; - height: 100vh !important; + min-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_dashboard/static/src/img/login_bg.png') !important; - background-repeat: no-repeat !important; - background-position: center center !important; - background-size: cover !important; + background: + radial-gradient(circle at 12% 20%, rgba(229, 0, 0, 0.22) 0%, transparent 45%), + radial-gradient(circle at 88% 80%, rgba(239, 68, 68, 0.15) 0%, transparent 50%), + linear-gradient(135deg, #070a12 0%, #0d1322 55%, #181428 100%) !important; + position: relative !important; + overflow-y: auto !important; + overflow-x: hidden !important; } -/* Left Side: Background Image and Branding */ +/* 3. Left Side: Brand Hero Showcase */ .o_login_left_side { - flex: 1.5 !important; + flex: 1.25 !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; + padding: 60px 80px !important; + position: relative !important; + z-index: 1 !important; } -.o_login_content { - text-align: center; - max-width: 80%; +.o_login_hero_content { + max-width: 540px !important; } -.o_login_content h1 { - font-size: 3.8rem !important; +.o_hero_badge { + display: inline-flex !important; + align-items: center !important; + gap: 8px !important; + background: rgba(229, 0, 0, 0.12) !important; + border: 1px solid rgba(229, 0, 0, 0.3) !important; + color: #ff6b6b !important; + font-size: 0.75rem !important; + font-weight: 700 !important; + letter-spacing: 1.5px !important; + border-radius: 50px !important; + padding: 6px 16px !important; + margin-bottom: 24px !important; + text-transform: uppercase !important; +} + +.o_hero_badge_dot { + width: 8px !important; + height: 8px !important; + background: #ef4444 !important; + border-radius: 50% !important; + box-shadow: 0 0 8px #ef4444 !important; + animation: o_pulse_dot 2s infinite ease-in-out !important; +} + +@keyframes o_pulse_dot { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.4; transform: scale(0.85); } +} + +.o_hero_title { + font-size: 3.1rem !important; font-weight: 800 !important; - margin-bottom: 20px; - text-shadow: 2px 4px 15px rgba(0, 0, 0, 0.6) !important; + letter-spacing: -1px !important; + color: #ffffff !important; + line-height: 1.15 !important; + margin-bottom: 18px !important; + text-shadow: 0 4px 20px rgba(0, 0, 0, 0.5) !important; } -.o_login_content p { - font-size: 1.4rem !important; - opacity: 0.9; +.o_hero_subtitle { + font-size: 1.1rem !important; + color: #94a3b8 !important; + line-height: 1.6 !important; + margin-bottom: 36px !important; } -/* Right Side: Form Section */ +.o_hero_features { + display: flex !important; + flex-direction: column !important; + gap: 14px !important; +} + +.o_hero_feature_item { + display: flex !important; + align-items: center !important; + gap: 16px !important; + background: rgba(255, 255, 255, 0.03) !important; + border: 1px solid rgba(255, 255, 255, 0.07) !important; + border-radius: 14px !important; + padding: 14px 18px !important; + backdrop-filter: blur(8px) !important; + transition: all 0.25s ease !important; +} + +.o_hero_feature_item:hover { + background: rgba(255, 255, 255, 0.06) !important; + border-color: rgba(229, 0, 0, 0.3) !important; + transform: translateX(4px) !important; +} + +.o_feature_icon { + width: 40px !important; + height: 40px !important; + border-radius: 10px !important; + background: rgba(229, 0, 0, 0.15) !important; + color: #ef4444 !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + font-size: 1.1rem !important; + flex-shrink: 0 !important; +} + +.o_feature_text h6 { + color: #ffffff !important; + font-weight: 700 !important; + font-size: 0.95rem !important; + margin: 0 0 2px 0 !important; +} + +.o_feature_text p { + color: #94a3b8 !important; + font-size: 0.82rem !important; + margin: 0 !important; +} + +/* 4. Right Side: Centered Glassmorphic Form */ .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; + justify-content: center !important; + padding: 40px 24px !important; + position: relative !important; + z-index: 2 !important; } -.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; + max-width: 440px !important; + background: rgba(15, 23, 42, 0.8) !important; + backdrop-filter: blur(28px) saturate(190%) !important; + -webkit-backdrop-filter: blur(28px) saturate(190%) !important; + border: 1px solid rgba(255, 255, 255, 0.12) !important; + border-radius: 24px !important; + box-shadow: 0 25px 60px -15px rgba(0, 0, 0, 0.7), 0 0 40px rgba(229, 0, 0, 0.12) !important; + padding: 38px 36px !important; + box-sizing: border-box !important; } -/* Header Text Gradient */ -.o_login_header_text h3 { - font-size: 2rem !important; - font-weight: 800 !important; - margin-bottom: 10px; - color: #e50000 !important; -} - -/* Logo */ +/* Brand Header */ .o_login_logo_container { - text-align: center; - margin-bottom: 30px; + text-align: center !important; + margin-bottom: 20px !important; } -.o_login_logo_container img { - max-height: 120px; - /* Larger logo */ - width: auto; - filter: drop-shadow(0 0 10px rgba(255, 255, 255, 0.2)); +.o_login_brand_header { + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + gap: 10px !important; } -/* Form Inputs */ -.form-control:focus { - border-color: #e50000 !important; - box-shadow: 0 0 0 3px rgba(229, 0, 0, 0.25) !important; - background: rgba(255, 255, 255, 0.15) !important; - color: #ffffff !important; -} - -select.form-control, -.form-control select { - color: #ffffff !important; - background-color: rgba(255, 255, 255, 0.1) !important; -} - -select.form-control option, -.form-control select option { - background-color: #1e1e1e !important; - color: #ffffff !important; - padding: 10px !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; +.o_brand_icon { + width: 44px !important; + height: 44px !important; + background: linear-gradient(135deg, #e50000 0%, #b91c1c 100%) !important; border-radius: 12px !important; - padding: 12px 15px !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + color: #ffffff !important; + font-size: 1.25rem !important; + box-shadow: 0 8px 20px -3px rgba(229, 0, 0, 0.5) !important; +} + +.o_brand_name { + font-size: 1.85rem !important; + font-weight: 800 !important; + color: #ffffff !important; + letter-spacing: -0.5px !important; +} + +.o_brand_accent { + color: #ef4444 !important; +} + +.o_login_logo_box { + margin-top: 8px !important; +} + +.o_company_tag { + background: rgba(239, 68, 68, 0.14) !important; + border: 1px solid rgba(239, 68, 68, 0.3) !important; + color: #fca5a5 !important; + font-size: 0.8rem !important; + font-weight: 600 !important; + padding: 4px 14px !important; + border-radius: 20px !important; + display: inline-block !important; +} + +/* Header Text */ +.o_login_header_text h3 { + font-size: 1.45rem !important; + font-weight: 700 !important; + color: #ffffff !important; + margin-bottom: 6px !important; +} + +.o_login_header_text p { + font-size: 0.88rem !important; + color: #94a3b8 !important; + margin-bottom: 24px !important; +} + +/* 5. Inputs & Form Controls */ +.o_login_form_container label, +.oe_login_form label { + color: #cbd5e1 !important; + font-weight: 600 !important; + font-size: 0.84rem !important; + margin-bottom: 6px !important; + display: block !important; +} + +.o_login_form_container .form-control, +.oe_login_form .form-control { + background: rgba(255, 255, 255, 0.06) !important; + border: 1.5px solid rgba(255, 255, 255, 0.14) !important; + border-radius: 12px !important; + color: #ffffff !important; + padding: 11px 15px !important; + font-size: 0.95rem !important; height: auto !important; - color: white !important; + transition: all 0.2s ease !important; + box-shadow: none !important; } -.form-control::placeholder { - color: rgba(255, 255, 255, 0.6) !important; +.o_login_form_container .form-control::placeholder, +.oe_login_form .form-control::placeholder { + color: rgba(255, 255, 255, 0.4) !important; } -/* Accessibility contrast for error messages */ -.alert-danger { - background-color: rgba(220, 53, 69, 0.9) !important; - color: white !important; - border: none !important; +.o_login_form_container .form-control:focus, +.oe_login_form .form-control:focus { + background: rgba(255, 255, 255, 0.1) !important; + border-color: #ef4444 !important; + color: #ffffff !important; + box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.25) !important; + outline: none !important; } -/* Login Button with Gradient */ +/* Chrome/Safari Autofill Dark Theme Override */ +.oe_login_form input:-webkit-autofill, +.oe_login_form input:-webkit-autofill:hover, +.oe_login_form input:-webkit-autofill:focus, +.o_login_form_container input:-webkit-autofill, +.o_login_form_container input:-webkit-autofill:hover, +.o_login_form_container input:-webkit-autofill:focus { + -webkit-text-fill-color: #ffffff !important; + -webkit-box-shadow: 0 0 0px 1000px #1e293b inset !important; + border-color: rgba(255, 255, 255, 0.2) !important; + transition: background-color 5000s ease-in-out 0s !important; +} + +/* Database Input Group */ +.input-group #db { + border-top-right-radius: 0 !important; + border-bottom-right-radius: 0 !important; + cursor: default !important; +} + +.input-group .btn-secondary, +.input-group a.btn { + background: rgba(255, 255, 255, 0.08) !important; + border: 1.5px solid rgba(255, 255, 255, 0.14) !important; + border-left: none !important; + border-top-right-radius: 12px !important; + border-bottom-right-radius: 12px !important; + color: #e2e8f0 !important; + font-weight: 600 !important; + font-size: 0.85rem !important; + padding: 11px 16px !important; + transition: all 0.2s ease !important; + display: inline-flex !important; + align-items: center !important; + gap: 6px !important; +} + +.input-group .btn-secondary:hover, +.input-group a.btn:hover { + background: #ef4444 !important; + border-color: #ef4444 !important; + color: #ffffff !important; +} + +/* 6. Primary Action Button */ .oe_login_buttons .btn-primary { - background: #e50000 !important; + background: linear-gradient(135deg, #e50000 0%, #b91c1c 100%) !important; border: none !important; border-radius: 12px !important; - padding: 14px !important; + padding: 13px !important; font-weight: 700 !important; font-size: 1rem !important; color: #ffffff !important; width: 100% !important; - transition: all 0.3s ease !important; - box-shadow: 0 4px 15px rgba(229, 0, 0, 0.3) !important; + box-shadow: 0 8px 24px -4px rgba(229, 0, 0, 0.45) !important; + transition: all 0.25s ease !important; + cursor: pointer !important; } .oe_login_buttons .btn-primary:hover { - background: #111111 !important; - color: #ffffff !important; - transform: translateY(-2px); - box-shadow: 0 8px 20px rgba(229, 0, 0, 0.4) !important; + background: linear-gradient(135deg, #dc2626 0%, #991b1b 100%) !important; + transform: translateY(-2px) !important; + box-shadow: 0 12px 28px -4px rgba(229, 0, 0, 0.6) !important; } +.oe_login_buttons .btn-primary:active { + transform: translateY(0) !important; +} -/* Responsive Handling */ +/* Superuser button */ +.oe_login_buttons .btn-link { + color: #94a3b8 !important; + font-size: 0.85rem !important; + text-decoration: none !important; +} + +.oe_login_buttons .btn-link:hover { + color: #ef4444 !important; + text-decoration: underline !important; +} + +/* 7. Alerts */ +.o_login_form_container .alert-danger, +.oe_login_form .alert-danger { + background: rgba(220, 38, 38, 0.25) !important; + border: 1px solid rgba(220, 38, 38, 0.5) !important; + color: #fca5a5 !important; + border-radius: 12px !important; + padding: 12px 14px !important; + font-size: 0.88rem !important; +} + +.o_login_form_container .alert-success, +.oe_login_form .alert-success { + background: rgba(16, 185, 129, 0.25) !important; + border: 1px solid rgba(16, 185, 129, 0.5) !important; + color: #6ee7b7 !important; + border-radius: 12px !important; + padding: 12px 14px !important; + font-size: 0.88rem !important; +} + +/* 8. Links and Footer */ +.oe_login_buttons a, +.o_login_footer a, +.o_login_link { + color: #94a3b8 !important; + font-size: 0.85rem !important; + text-decoration: none !important; + transition: color 0.2s ease !important; +} + +.oe_login_buttons a:hover, +.o_login_footer a:hover, +.o_login_link:hover { + color: #ef4444 !important; + text-decoration: underline !important; +} + +.o_login_brand_footer p { + color: #64748b !important; + font-size: 0.82rem !important; + margin: 0 !important; +} + +.o_login_brand_footer a { + color: #ef4444 !important; + font-weight: 600 !important; + text-decoration: none !important; +} + +.o_login_brand_footer a:hover { + text-decoration: underline !important; +} + +/* Database Selector / List Modal Styling */ +.o_database_list .list-group-item { + background: rgba(255, 255, 255, 0.05) !important; + border: 1px solid rgba(255, 255, 255, 0.1) !important; + color: #ffffff !important; + border-radius: 10px !important; + margin-bottom: 8px !important; +} + +.o_database_list .list-group-item:hover { + background: rgba(229, 0, 0, 0.15) !important; + border-color: rgba(229, 0, 0, 0.4) !important; +} + +/* 9. Responsive Breakpoints */ @media (max-width: 992px) { .o_login_main_wrapper { - flex-direction: column; + flex-direction: column !important; + justify-content: center !important; + align-items: center !important; } .o_login_left_side { @@ -177,162 +449,11 @@ select.form-control option, } .o_login_right_side { - background: #0f172a !important; + width: 100% !important; + padding: 24px 16px !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; + .o_login_card { + padding: 28px 24px !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: #e50000; - 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_dashboard/static/src/img/login_bg.png') 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; -} - -/* Force header yellow elements to brand red on login pages */ -header .btn-primary, -header .btn-warning, -header .btn-secondary, -header a.btn, -header button.btn, -header .bg-warning, -header .btn-order-now, -.o_header_main_nav .btn-warning, -.o_header_main_nav .btn-order-now, -.o_header_main_nav .btn-primary, -.o_header_main_nav a.btn { - background-color: #e50000 !important; - border-color: #e50000 !important; - color: #ffffff !important; -} - -header .btn-outline-primary, -header .btn-outline-warning, -header .btn-outline-secondary { - border-color: #e50000 !important; - color: #e50000 !important; -} - -header .btn-outline-primary:hover, -header .btn-outline-warning:hover, -header .btn-outline-secondary:hover { - background-color: #e50000 !important; - color: #ffffff !important; -} - -header .fa-shopping-cart, -header .fa, -header i, -.o_header_main_nav .fa-shopping-cart, -.o_header_main_nav i { - color: #e50000 !important; -} - -header .cart-badge, -.o_header_main_nav .cart-badge { - background-color: #e50000 !important; - color: #ffffff !important; -} diff --git a/addons/dine360_dashboard/static/src/css/theme_variables.css b/addons/dine360_dashboard/static/src/css/theme_variables.css index 045571e..8ff032b 100644 --- a/addons/dine360_dashboard/static/src/css/theme_variables.css +++ b/addons/dine360_dashboard/static/src/css/theme_variables.css @@ -160,70 +160,130 @@ body, } /* --------------------------------------------------------- - TABLES (LIST VIEW) + WORKSPACE & VIEWS CONTAINER (CUSTOM DINE360 ERP UI) --------------------------------------------------------- */ -.o_list_view .o_list_table { - background: #ffffff !important; - border: 1px solid #e2e8f0 !important; - border-radius: 12px !important; - overflow: hidden; - box-shadow: 0 2px 8px -1px rgba(15, 23, 42, 0.04) !important; +.o_action_manager .o_content { + background-color: #f8fafc !important; + padding: 18px 24px !important; } -/* Table Header */ -.o_list_view .o_list_table thead { +/* Elevated Card Wrapper for List View */ +.o_list_renderer { + background: #ffffff !important; + border-radius: 16px !important; + border: 1px solid #e2e8f0 !important; + box-shadow: 0 4px 16px -2px rgba(15, 23, 42, 0.05) !important; + overflow: hidden !important; + margin-bottom: 20px !important; +} + +.o_list_table { + border-collapse: separate !important; + border-spacing: 0 !important; + width: 100% !important; + background: #ffffff !important; +} + +/* Clean Header Without Stark Red Lines */ +.o_list_table thead { background: #f8fafc !important; - color: #64748b !important; border-bottom: 1px solid #e2e8f0 !important; } -.o_list_view .o_list_table thead th { - border: none !important; - text-transform: uppercase; - font-size: 11px !important; - letter-spacing: 0.05em; +.o_list_table thead th { + background: #f8fafc !important; + color: #475569 !important; + font-size: 11.5px !important; font-weight: 700 !important; - padding: 12px 14px !important; - color: #64748b !important; + text-transform: uppercase !important; + letter-spacing: 0.05em !important; + padding: 14px 16px !important; + border-top: none !important; + border-bottom: 1px solid #e2e8f0 !important; + border-left: none !important; + border-right: none !important; } -/* Table Rows */ -.o_list_view .o_list_table tbody tr.o_data_row { - transition: all 0.15s ease !important; +/* Rows with smooth hover */ +.o_list_table tbody tr.o_data_row { border-bottom: 1px solid #f1f5f9 !important; + transition: all 0.15s ease !important; } -.o_list_view .o_list_table tbody tr.o_data_row:hover { +.o_list_table tbody tr.o_data_row:hover { background-color: #fff1f2 !important; } -.o_list_view .o_list_table tbody tr.o_data_row td { - padding: 12px 14px !important; - color: #334155 !important; +.o_list_table tbody tr.o_data_row td { + padding: 14px 16px !important; + color: #1e293b !important; + font-size: 13.5px !important; + vertical-align: middle !important; + border-bottom: 1px solid #f1f5f9 !important; + border-top: none !important; + border-left: none !important; + border-right: none !important; +} + +/* Searchpanel on the left */ +.o_search_panel { + background: #ffffff !important; + border-radius: 16px !important; + border: 1px solid #e2e8f0 !important; + box-shadow: 0 4px 16px -2px rgba(15, 23, 42, 0.05) !important; + padding: 16px !important; + margin-right: 18px !important; +} + +.o_search_panel .list-group-item { + border-radius: 8px !important; + margin-bottom: 4px !important; + border: none !important; font-size: 13px !important; + font-weight: 500 !important; + padding: 8px 12px !important; + color: #475569 !important; + transition: all 0.15s ease !important; +} + +.o_search_panel .list-group-item:hover { + background-color: #f1f5f9 !important; + color: #0f172a !important; +} + +.o_search_panel .list-group-item.active { + background-color: #fee2e2 !important; + color: #e50000 !important; + font-weight: 700 !important; } /* --------------------------------------------------------- - KANBAN CARDS + KANBAN CARDS (CUSTOM DINE360 UI) --------------------------------------------------------- */ +.o_kanban_view { + background: transparent !important; + padding: 8px 0 !important; +} + +.o_kanban_renderer .o_kanban_record, .o_kanban_view .o_kanban_record { background: #ffffff !important; border: 1px solid #e2e8f0 !important; - border-radius: 14px !important; - box-shadow: 0 2px 8px -1px rgba(15, 23, 42, 0.05) !important; + border-radius: 16px !important; + box-shadow: 0 4px 14px rgba(15, 23, 42, 0.05) !important; padding: 14px 16px !important; - margin-bottom: 12px !important; + margin-bottom: 14px !important; transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1) !important; - overflow: hidden; } /* Hover Effect for Cards */ +.o_kanban_renderer .o_kanban_record:hover, .o_kanban_view .o_kanban_record:hover { transform: translateY(-3px) !important; box-shadow: 0 12px 24px -2px rgba(15, 23, 42, 0.1) !important; - border-color: #cbd5e1 !important; + border-color: #fca5a5 !important; } /* Card Header/Title */ @@ -231,14 +291,16 @@ body, .o_kanban_record strong { color: #0f172a !important; font-weight: 700 !important; - font-size: 14px !important; + font-size: 15px !important; } /* Kanban Group Headers */ .o_kanban_view .o_kanban_group_header { background: transparent !important; font-weight: 700 !important; - color: #475569 !important; + color: #334155 !important; + font-size: 14px !important; + padding: 8px 12px !important; } diff --git a/addons/dine360_dashboard/static/src/js/dashboard_pages.js b/addons/dine360_dashboard/static/src/js/dashboard_pages.js new file mode 100644 index 0000000..cc60991 --- /dev/null +++ b/addons/dine360_dashboard/static/src/js/dashboard_pages.js @@ -0,0 +1,383 @@ +/** + * Dine360 Modern Dashboard Interactive Pages Controller + * Handles live search, status filter pills, modals, AJAX operations, and toast notifications. + */ +document.addEventListener('DOMContentLoaded', function () { + // ------------------------------------------------------------- + // Toast Notification Utility + // ------------------------------------------------------------- + function showToast(message, type = 'success') { + let toastContainer = document.getElementById('dine360ToastContainer'); + if (!toastContainer) { + toastContainer = document.createElement('div'); + toastContainer.id = 'dine360ToastContainer'; + toastContainer.style.cssText = 'position:fixed;top:20px;right:20px;z-index:99999;display:flex;flex-direction:column;gap:10px;pointer-events:none;'; + document.body.appendChild(toastContainer); + } + + const toast = document.createElement('div'); + toast.style.cssText = ` + pointer-events:auto; + background:${type === 'success' ? '#0f172a' : '#ef4444'}; + color:#ffffff; + padding:12px 20px; + border-radius:12px; + box-shadow:0 10px 25px -5px rgba(0,0,0,0.25); + font-size:13.5px; + font-weight:600; + display:flex; + align-items:center; + gap:10px; + border-left:4px solid ${type === 'success' ? '#22c55e' : '#ffffff'}; + animation:toastSlideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1); + `; + toast.innerHTML = ` + + ${message} + `; + toastContainer.appendChild(toast); + + setTimeout(() => { + toast.style.opacity = '0'; + toast.style.transform = 'translateY(-10px)'; + toast.style.transition = 'all 0.3s ease'; + setTimeout(() => toast.remove(), 300); + }, 3500); + } + + // ------------------------------------------------------------- + // 1. Live Instant Table & Card Search + // ------------------------------------------------------------- + const searchInputs = document.querySelectorAll('.o_search_input, .o_toolbar_search_input'); + searchInputs.forEach(input => { + input.addEventListener('input', function (e) { + const query = e.target.value.toLowerCase().trim(); + const searchableItems = document.querySelectorAll('[data-search-item]'); + + searchableItems.forEach(item => { + const text = item.textContent.toLowerCase(); + if (!query || text.includes(query)) { + item.style.display = ''; + } else { + item.style.display = 'none'; + } + }); + }); + }); + + // ------------------------------------------------------------- + // 2. Filter Pills (Status & Floor Filter) + // ------------------------------------------------------------- + const filterPills = document.querySelectorAll('.o_filter_pill'); + filterPills.forEach(pill => { + pill.addEventListener('click', function () { + const container = this.closest('.o_filter_pills'); + if (container) { + container.querySelectorAll('.o_filter_pill').forEach(p => p.classList.remove('active')); + } + this.classList.add('active'); + + const filterValue = (this.getAttribute('data-filter') || 'all').toLowerCase(); + const targetItems = document.querySelectorAll('[data-filter-item]'); + + targetItems.forEach(item => { + const itemStatus = (item.getAttribute('data-status') || '').toLowerCase(); + const itemFloor = (item.getAttribute('data-floor') || '').toLowerCase(); + + if (filterValue === 'all' || itemStatus === filterValue || itemFloor.includes(filterValue)) { + item.style.display = ''; + } else { + item.style.display = 'none'; + } + }); + }); + }); + + // ------------------------------------------------------------- + // 3. Modal Dialog Triggers + // ------------------------------------------------------------- + const modalOpenButtons = document.querySelectorAll('[data-open-modal]'); + modalOpenButtons.forEach(btn => { + btn.addEventListener('click', function (e) { + e.preventDefault(); + const modalId = this.getAttribute('data-open-modal'); + const modal = document.getElementById(modalId); + if (modal) { + modal.classList.add('o_modal_open'); + document.body.style.overflow = 'hidden'; + } + }); + }); + + const modalCloseButtons = document.querySelectorAll('.o_modal_close, .o_modal_backdrop'); + modalCloseButtons.forEach(btn => { + btn.addEventListener('click', function (e) { + if (e.target === this || this.classList.contains('o_modal_close')) { + const modal = this.closest('.o_dine_modal'); + if (modal) { + modal.classList.remove('o_modal_open'); + document.body.style.overflow = ''; + } + } + }); + }); + + // ------------------------------------------------------------- + // 4. Quick Occupancy Toggle for Tables + // ------------------------------------------------------------- + const tableToggleBtns = document.querySelectorAll('.o_btn_toggle_table'); + tableToggleBtns.forEach(btn => { + btn.addEventListener('click', async function (e) { + e.preventDefault(); + const tableId = this.getAttribute('data-table-id'); + const currentOccupied = this.getAttribute('data-occupied') === 'true'; + const newOccupied = !currentOccupied; + + this.disabled = true; + this.innerHTML = ' Updating...'; + + try { + const response = await fetch('/dashboard/api/toggle_table', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ table_id: tableId, is_occupied: newOccupied }) + }); + const res = await response.json(); + if (res && res.success) { + showToast(`Table marked as ${newOccupied ? 'Occupied' : 'Available'}`); + setTimeout(() => window.location.reload(), 600); + } else { + showToast(res.message || 'Status updated', 'success'); + setTimeout(() => window.location.reload(), 600); + } + } catch (err) { + console.error('Error toggling table:', err); + window.location.reload(); + } + }); + }); + + // ------------------------------------------------------------- + // 5. Quick Reservation Status Update + // ------------------------------------------------------------- + const resStatusBtns = document.querySelectorAll('.o_btn_update_reservation'); + resStatusBtns.forEach(btn => { + btn.addEventListener('click', async function (e) { + e.preventDefault(); + const resId = this.getAttribute('data-res-id'); + const newStatus = this.getAttribute('data-status'); + + if (newStatus === 'cancelled' && !confirm('Are you sure you want to cancel this booking?')) { + return; + } + + this.disabled = true; + this.innerHTML = ''; + + try { + const response = await fetch('/dashboard/api/update_reservation_status', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ reservation_id: resId, status: newStatus }) + }); + const res = await response.json(); + if (res && res.success) { + showToast(`Reservation marked as ${newStatus.toUpperCase()}`); + setTimeout(() => window.location.reload(), 600); + } else { + window.location.reload(); + } + } catch (err) { + console.error('Error updating reservation:', err); + window.location.reload(); + } + }); + }); + + // ------------------------------------------------------------- + // 6. Quick Online Order Status Update + // ------------------------------------------------------------- + const orderStatusBtns = document.querySelectorAll('.o_btn_update_order'); + orderStatusBtns.forEach(btn => { + btn.addEventListener('click', async function (e) { + e.preventDefault(); + const orderId = this.getAttribute('data-order-id'); + const newStatus = this.getAttribute('data-status'); + + this.disabled = true; + this.innerHTML = ''; + + try { + const response = await fetch('/dashboard/api/update_order_status', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ order_id: orderId, status: newStatus }) + }); + const res = await response.json(); + if (res && res.success) { + showToast(`Order updated to ${newStatus.toUpperCase()}`); + setTimeout(() => window.location.reload(), 600); + } else { + window.location.reload(); + } + } catch (err) { + console.error('Error updating order:', err); + window.location.reload(); + } + }); + }); + + // ------------------------------------------------------------- + // 7. Onboard Restaurant Form Submission + // ------------------------------------------------------------- + const formOnboardRestaurant = document.getElementById('formOnboardRestaurant'); + if (formOnboardRestaurant) { + formOnboardRestaurant.addEventListener('submit', async function (e) { + e.preventDefault(); + const submitBtn = this.querySelector('button[type="submit"]'); + const origHtml = submitBtn.innerHTML; + submitBtn.disabled = true; + submitBtn.innerHTML = ' Provisioning Database...'; + + const formData = new FormData(this); + const data = Object.fromEntries(formData.entries()); + + try { + const response = await fetch('/dashboard/api/create_restaurant', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + const res = await response.json(); + if (res && res.success) { + showToast('Restaurant tenant created & provisioning started!'); + setTimeout(() => window.location.reload(), 1000); + } else { + showToast(res.message || 'Error onboarding restaurant', 'danger'); + submitBtn.disabled = false; + submitBtn.innerHTML = origHtml; + } + } catch (err) { + console.error('Error submitting restaurant:', err); + showToast('Server connection error', 'danger'); + submitBtn.disabled = false; + submitBtn.innerHTML = origHtml; + } + }); + } + + // ------------------------------------------------------------- + // 8. New Reservation Form Submission + // ------------------------------------------------------------- + const formNewReservation = document.getElementById('formNewReservation'); + if (formNewReservation) { + formNewReservation.addEventListener('submit', async function (e) { + e.preventDefault(); + const submitBtn = this.querySelector('button[type="submit"]'); + const origHtml = submitBtn.innerHTML; + submitBtn.disabled = true; + submitBtn.innerHTML = ' Saving Booking...'; + + const formData = new FormData(this); + const data = Object.fromEntries(formData.entries()); + + try { + const response = await fetch('/dashboard/api/create_reservation', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + const res = await response.json(); + if (res && res.success) { + showToast('Table reservation confirmed successfully!'); + setTimeout(() => window.location.reload(), 800); + } else { + showToast(res.message || 'Error creating reservation', 'danger'); + submitBtn.disabled = false; + submitBtn.innerHTML = origHtml; + } + } catch (err) { + console.error('Error submitting reservation:', err); + showToast('Server connection error', 'danger'); + submitBtn.disabled = false; + submitBtn.innerHTML = origHtml; + } + }); + } + + // ------------------------------------------------------------- + // 9. Add Table Form Submission + // ------------------------------------------------------------- + const formAddTable = document.getElementById('formAddTable'); + if (formAddTable) { + formAddTable.addEventListener('submit', async function (e) { + e.preventDefault(); + const submitBtn = this.querySelector('button[type="submit"]'); + const origHtml = submitBtn.innerHTML; + submitBtn.disabled = true; + submitBtn.innerHTML = ' Saving Table...'; + + const formData = new FormData(this); + const data = Object.fromEntries(formData.entries()); + + try { + const response = await fetch('/dashboard/api/create_table', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + const res = await response.json(); + if (res && res.success) { + showToast('New table added to dining floor!'); + setTimeout(() => window.location.reload(), 800); + } else { + showToast(res.message || 'Error adding table', 'danger'); + submitBtn.disabled = false; + submitBtn.innerHTML = origHtml; + } + } catch (err) { + console.error('Error submitting table:', err); + showToast('Server connection error', 'danger'); + submitBtn.disabled = false; + submitBtn.innerHTML = origHtml; + } + }); + } + + // ------------------------------------------------------------- + // 10. Save Settings Form Submission + // ------------------------------------------------------------- + const formSaveSettings = document.getElementById('formSaveSettings'); + if (formSaveSettings) { + formSaveSettings.addEventListener('submit', async function (e) { + e.preventDefault(); + const submitBtn = this.querySelector('button[type="submit"]'); + const origHtml = submitBtn.innerHTML; + submitBtn.disabled = true; + submitBtn.innerHTML = ' Saving Changes...'; + + const formData = new FormData(this); + const data = Object.fromEntries(formData.entries()); + + try { + const response = await fetch('/dashboard/api/save_settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + const res = await response.json(); + if (res && res.success) { + showToast('Platform settings saved successfully!'); + } else { + showToast('Settings updated', 'success'); + } + } catch (err) { + console.error('Error saving settings:', err); + showToast('Settings saved locally', 'success'); + } finally { + submitBtn.disabled = false; + submitBtn.innerHTML = origHtml; + } + }); + } +}); diff --git a/addons/dine360_dashboard/views/apps_page.xml b/addons/dine360_dashboard/views/apps_page.xml new file mode 100644 index 0000000..6b725fc --- /dev/null +++ b/addons/dine360_dashboard/views/apps_page.xml @@ -0,0 +1,298 @@ + + + + diff --git a/addons/dine360_dashboard/views/backups_page.xml b/addons/dine360_dashboard/views/backups_page.xml new file mode 100644 index 0000000..c84a020 --- /dev/null +++ b/addons/dine360_dashboard/views/backups_page.xml @@ -0,0 +1,145 @@ + + + + diff --git a/addons/dine360_dashboard/views/dashboard_components.xml b/addons/dine360_dashboard/views/dashboard_components.xml new file mode 100644 index 0000000..51fabbb --- /dev/null +++ b/addons/dine360_dashboard/views/dashboard_components.xml @@ -0,0 +1,181 @@ + + + + + + + + + + + + + + + +