implement a comprehensive restaurant reservation management system with scheduling, holiday management, and table booking.
This commit is contained in:
parent
73453d0e26
commit
e1a84ff327
@ -16,8 +16,12 @@
|
|||||||
'security/ir.model.access.csv',
|
'security/ir.model.access.csv',
|
||||||
'data/reservation_sequence.xml',
|
'data/reservation_sequence.xml',
|
||||||
'data/reservation_cron.xml',
|
'data/reservation_cron.xml',
|
||||||
|
'data/reservation_schedule_data.xml',
|
||||||
'data/website_menu.xml',
|
'data/website_menu.xml',
|
||||||
'views/reservation_views.xml',
|
'views/reservation_views.xml',
|
||||||
|
'views/restaurant_table_views.xml',
|
||||||
|
'views/reservation_schedule_views.xml',
|
||||||
|
'views/reservation_holiday_views.xml',
|
||||||
'views/reservation_templates.xml',
|
'views/reservation_templates.xml',
|
||||||
'views/menu_items.xml',
|
'views/menu_items.xml',
|
||||||
],
|
],
|
||||||
|
|||||||
Binary file not shown.
@ -7,47 +7,260 @@ class TableReservationController(http.Controller):
|
|||||||
|
|
||||||
@http.route(['/reservation'], type='http', auth="public", website=True)
|
@http.route(['/reservation'], type='http', auth="public", website=True)
|
||||||
def reservation_form(self, **post):
|
def reservation_form(self, **post):
|
||||||
floors = request.env['restaurant.floor'].sudo().search([])
|
schedule = request.env['reservation.schedule'].sudo().search([])
|
||||||
tables = request.env['restaurant.table'].sudo().search([])
|
|
||||||
return request.render("dine360_reservation.reservation_page_template", {
|
return request.render("dine360_reservation.reservation_page_template", {
|
||||||
'floors': floors,
|
'schedule': schedule
|
||||||
'tables': tables,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
def _get_slot_duration(self, schedule, time_float):
|
||||||
|
""" Returns the slot duration for a given time based on peak hour settings """
|
||||||
|
if not schedule:
|
||||||
|
return 1.0
|
||||||
|
for peak in schedule.peak_hour_ids:
|
||||||
|
if peak.start_time <= time_float < peak.end_time:
|
||||||
|
return float(peak.slot_duration)
|
||||||
|
return float(schedule.default_slot_duration or 1.0)
|
||||||
|
|
||||||
|
def _find_best_tables(self, floor, available_floor_tables, num_people):
|
||||||
|
""" Find the best combination of nearby tables on a floor, preferring same zone """
|
||||||
|
# Case 1: Single table fits
|
||||||
|
singles = [t for t in available_floor_tables if t.min_party_size <= num_people <= t.max_party_size]
|
||||||
|
if singles:
|
||||||
|
best_single = min(singles, key=lambda t: t.max_party_size)
|
||||||
|
return [best_single]
|
||||||
|
|
||||||
|
# Case 2: Merge needed
|
||||||
|
# Group tables by zone
|
||||||
|
zones = {}
|
||||||
|
for t in available_floor_tables:
|
||||||
|
z = t.zone or 'Default'
|
||||||
|
if z not in zones: zones[z] = []
|
||||||
|
zones[z].append(t)
|
||||||
|
|
||||||
|
best_overall_combo = []
|
||||||
|
min_overall_dist = float('inf')
|
||||||
|
|
||||||
|
# Try merging within each zone first
|
||||||
|
for zone_name, zone_tables in zones.items():
|
||||||
|
if sum(t.max_party_size for t in zone_tables) < num_people:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for start_table in zone_tables:
|
||||||
|
current_combo = [start_table]
|
||||||
|
current_cap = start_table.max_party_size
|
||||||
|
others = [t for t in zone_tables if t.id != start_table.id]
|
||||||
|
|
||||||
|
while current_cap < num_people and others:
|
||||||
|
nearest = min(others, key=lambda o: min(
|
||||||
|
(((o.position_h or 0) - (c.position_h or 0))**2 + ((o.position_v or 0) - (c.position_v or 0))**2)**0.5
|
||||||
|
for c in current_combo
|
||||||
|
))
|
||||||
|
current_combo.append(nearest)
|
||||||
|
current_cap += nearest.max_party_size
|
||||||
|
others.remove(nearest)
|
||||||
|
|
||||||
|
if current_cap >= num_people:
|
||||||
|
dist_sum = 0
|
||||||
|
for i in range(len(current_combo)):
|
||||||
|
for j in range(i + 1, len(current_combo)):
|
||||||
|
t1, t2 = current_combo[i], current_combo[j]
|
||||||
|
dist_sum += (((t1.position_h or 0) - (t2.position_h or 0))**2 + ((t1.position_v or 0) - (t2.position_v or 0))**2)**0.5
|
||||||
|
|
||||||
|
if dist_sum < min_overall_dist:
|
||||||
|
min_overall_dist = dist_sum
|
||||||
|
best_overall_combo = current_combo
|
||||||
|
|
||||||
|
# If no same-zone combo found, use the best combo across the whole floor
|
||||||
|
if not best_overall_combo:
|
||||||
|
for start_table in available_floor_tables:
|
||||||
|
current_combo = [start_table]
|
||||||
|
current_cap = start_table.max_party_size
|
||||||
|
others = [t for t in available_floor_tables if t.id != start_table.id]
|
||||||
|
while current_cap < num_people and others:
|
||||||
|
nearest = min(others, key=lambda o: min(
|
||||||
|
(((o.position_h or 0) - (c.position_h or 0))**2 + ((o.position_v or 0) - (c.position_v or 0))**2)**0.5
|
||||||
|
for c in current_combo
|
||||||
|
))
|
||||||
|
current_combo.append(nearest)
|
||||||
|
current_cap += nearest.max_party_size
|
||||||
|
others.remove(nearest)
|
||||||
|
if current_cap >= num_people:
|
||||||
|
dist_sum = 0
|
||||||
|
for i in range(len(current_combo)):
|
||||||
|
for j in range(i + 1, len(current_combo)):
|
||||||
|
t1, t2 = current_combo[i], current_combo[j]
|
||||||
|
dist_sum += (((t1.position_h or 0) - (t2.position_h or 0))**2 + ((t1.position_v or 0) - (t2.position_v or 0))**2)**0.5
|
||||||
|
if dist_sum < min_overall_dist:
|
||||||
|
min_overall_dist = dist_sum
|
||||||
|
best_overall_combo = current_combo
|
||||||
|
|
||||||
|
return best_overall_combo
|
||||||
|
|
||||||
|
@http.route(['/reservation/get_slots'], type='json', auth="public", website=True)
|
||||||
|
def get_available_slots(self, date_str, num_people):
|
||||||
|
try:
|
||||||
|
res_date = datetime.datetime.strptime(date_str, '%Y-%m-%d').date()
|
||||||
|
num_people = int(num_people)
|
||||||
|
except:
|
||||||
|
return {'error': 'Invalid date or guest count'}
|
||||||
|
|
||||||
|
# 1. Check for Holiday
|
||||||
|
holiday = request.env['reservation.holiday'].sudo().search([('date', '=', res_date)], limit=1)
|
||||||
|
if holiday and holiday.is_closed:
|
||||||
|
return {'error': 'The restaurant is closed on this day for %s.' % holiday.name}
|
||||||
|
|
||||||
|
# 2. Get Schedule
|
||||||
|
day = str(res_date.weekday())
|
||||||
|
schedule = request.env['reservation.schedule'].sudo().search([('day', '=', day)], limit=1)
|
||||||
|
if not schedule or schedule.is_closed:
|
||||||
|
return {'error': 'Reservations are not available on this day.'}
|
||||||
|
|
||||||
|
# Determine operating hours
|
||||||
|
opening = holiday.opening_time if (holiday and holiday.opening_time) else schedule.opening_time
|
||||||
|
closing = holiday.closing_time if (holiday and holiday.closing_time) else schedule.closing_time
|
||||||
|
|
||||||
|
# 3. Generate Slots (intervals based on table duration)
|
||||||
|
slots = []
|
||||||
|
current_time = opening
|
||||||
|
restaurant_tz = pytz.timezone('America/Toronto')
|
||||||
|
now_local = datetime.datetime.now(restaurant_tz).replace(tzinfo=None)
|
||||||
|
|
||||||
|
all_tables = request.env['restaurant.table'].sudo().search([('is_reservation_enabled', '=', True)])
|
||||||
|
floors = request.env['restaurant.floor'].sudo().search([])
|
||||||
|
|
||||||
|
while current_time < closing:
|
||||||
|
duration = self._get_slot_duration(schedule, current_time)
|
||||||
|
slot_start_dt = datetime.datetime.combine(res_date, datetime.time(int(current_time), int((current_time % 1) * 60)))
|
||||||
|
slot_end_dt = slot_start_dt + datetime.timedelta(hours=duration)
|
||||||
|
|
||||||
|
if slot_start_dt > now_local:
|
||||||
|
# Check for Break
|
||||||
|
is_in_break = False
|
||||||
|
if schedule.has_break:
|
||||||
|
if current_time < schedule.break_end_time and (current_time + duration) > schedule.break_start_time:
|
||||||
|
is_in_break = True
|
||||||
|
|
||||||
|
if not is_in_break:
|
||||||
|
best_available_combo = []
|
||||||
|
|
||||||
|
for floor in floors:
|
||||||
|
floor_tables = all_tables.filtered(lambda t: t.floor_id == floor)
|
||||||
|
available_on_floor = []
|
||||||
|
for table in floor_tables:
|
||||||
|
overlap = request.env['restaurant.reservation'].sudo().search([
|
||||||
|
('table_ids', 'in', [table.id]),
|
||||||
|
('state', 'in', ['confirmed', 'draft']),
|
||||||
|
('start_time', '<', slot_end_dt),
|
||||||
|
('end_time', '>', slot_start_dt),
|
||||||
|
])
|
||||||
|
if not overlap:
|
||||||
|
available_on_floor.append(table)
|
||||||
|
|
||||||
|
combo = self._find_best_tables(floor, available_on_floor, num_people)
|
||||||
|
if combo and (not best_available_combo or len(combo) < len(best_available_combo)):
|
||||||
|
best_available_combo = combo
|
||||||
|
if len(combo) == 1: break # Perfect match found
|
||||||
|
|
||||||
|
if best_available_combo:
|
||||||
|
hours_int = int(current_time)
|
||||||
|
mins_int = int((current_time % 1) * 60)
|
||||||
|
ampm = "AM" if hours_int < 12 else "PM"
|
||||||
|
disp_h = hours_int if hours_int <= 12 else hours_int - 12
|
||||||
|
if disp_h == 0: disp_h = 12
|
||||||
|
|
||||||
|
table_names = ", ".join([t.name for t in best_available_combo])
|
||||||
|
floor_name = best_available_combo[0].floor_id.name
|
||||||
|
|
||||||
|
slots.append({
|
||||||
|
'time': f"{hours_int:02d}:{mins_int:02d}",
|
||||||
|
'display': f"{disp_h}:{mins_int:02d} {ampm}",
|
||||||
|
'full_dt': slot_start_dt.strftime('%Y-%m-%dT%H:%M'),
|
||||||
|
'tables': f"{floor_name} - {table_names}"
|
||||||
|
})
|
||||||
|
|
||||||
|
# Use the calculated duration as the interval to respect user configuration
|
||||||
|
current_time += duration
|
||||||
|
|
||||||
|
return {'slots': slots}
|
||||||
|
|
||||||
@http.route(['/reservation/submit'], type='http', auth="public", website=True, methods=['POST'], csrf=True)
|
@http.route(['/reservation/submit'], type='http', auth="public", website=True, methods=['POST'], csrf=True)
|
||||||
def reservation_submit(self, **post):
|
def reservation_submit(self, **post):
|
||||||
# Extract data from post
|
# Extract data
|
||||||
customer_name = post.get('customer_name')
|
customer_name = post.get('customer_name')
|
||||||
phone = post.get('phone')
|
phone = post.get('phone')
|
||||||
email = post.get('email')
|
email = post.get('email')
|
||||||
floor_id = int(post.get('floor_id'))
|
|
||||||
table_id = int(post.get('table_id'))
|
|
||||||
start_time_str = post.get('start_time') # Format: 2024-05-20T18:00
|
|
||||||
num_people = int(post.get('num_people', 1))
|
num_people = int(post.get('num_people', 1))
|
||||||
|
start_time_str = post.get('start_time')
|
||||||
|
|
||||||
|
if not start_time_str:
|
||||||
|
return request.render("dine360_reservation.reservation_page_template", {'error': 'Please select a time slot.'})
|
||||||
|
|
||||||
# Convert start_time to datetime object and localize to restaurant timezone (Brampton)
|
# Convert start_time to datetime object and localize to restaurant timezone (America/Toronto)
|
||||||
restaurant_tz = pytz.timezone('America/Toronto')
|
restaurant_tz = pytz.timezone('America/Toronto')
|
||||||
local_start = datetime.datetime.strptime(start_time_str, '%Y-%m-%dT%H:%M')
|
try:
|
||||||
local_start = restaurant_tz.localize(local_start)
|
local_start = datetime.datetime.strptime(start_time_str, '%Y-%m-%dT%H:%M')
|
||||||
|
local_start = restaurant_tz.localize(local_start)
|
||||||
|
# Convert to UTC for Odoo
|
||||||
|
start_time = local_start.astimezone(pytz.utc).replace(tzinfo=None)
|
||||||
|
except Exception:
|
||||||
|
return request.render("dine360_reservation.reservation_page_template", {
|
||||||
|
'error': _("Invalid date or time format."),
|
||||||
|
'post': post,
|
||||||
|
})
|
||||||
|
|
||||||
# Convert to UTC for Odoo
|
res_date = local_start.date() # Use local_start for date to get correct weekday
|
||||||
start_time = local_start.astimezone(pytz.utc).replace(tzinfo=None)
|
|
||||||
|
|
||||||
# Standard duration of 1 hour for now
|
# Determine Duration
|
||||||
end_time = start_time + datetime.timedelta(hours=1)
|
day = str(res_date.weekday())
|
||||||
|
schedule = request.env['reservation.schedule'].sudo().search([('day', '=', day)], limit=1)
|
||||||
|
time_float = local_start.hour + local_start.minute / 60.0
|
||||||
|
duration = self._get_slot_duration(schedule, time_float)
|
||||||
|
end_time = start_time + datetime.timedelta(hours=duration)
|
||||||
|
|
||||||
# Create reservation
|
# FIND TABLES (Nearest Merge)
|
||||||
|
assigned_tables = []
|
||||||
|
target_floor = False
|
||||||
|
|
||||||
|
all_floors = request.env['restaurant.floor'].sudo().search([])
|
||||||
|
for floor in all_floors:
|
||||||
|
floor_tables = request.env['restaurant.table'].sudo().search([
|
||||||
|
('floor_id', '=', floor.id),
|
||||||
|
('is_reservation_enabled', '=', True)
|
||||||
|
])
|
||||||
|
|
||||||
|
available_tables = []
|
||||||
|
for table in floor_tables:
|
||||||
|
overlap = request.env['restaurant.reservation'].sudo().search([
|
||||||
|
('table_ids', 'in', [table.id]),
|
||||||
|
('state', 'in', ['confirmed', 'draft']),
|
||||||
|
('start_time', '<', end_time),
|
||||||
|
('end_time', '>', start_time),
|
||||||
|
])
|
||||||
|
if not overlap:
|
||||||
|
available_tables.append(table)
|
||||||
|
|
||||||
|
combo = self._find_best_tables(floor, available_tables, num_people)
|
||||||
|
if combo and (not assigned_tables or len(combo) < len(assigned_tables)):
|
||||||
|
assigned_tables = combo
|
||||||
|
target_floor = floor
|
||||||
|
if len(combo) == 1: break
|
||||||
|
|
||||||
|
if not assigned_tables:
|
||||||
|
return request.render("dine360_reservation.reservation_page_template", {'error': 'Sorry, no tables available for this time/group size.'})
|
||||||
|
|
||||||
|
# Create Reservation
|
||||||
try:
|
try:
|
||||||
reservation = request.env['restaurant.reservation'].sudo().create({
|
reservation = request.env['restaurant.reservation'].sudo().create({
|
||||||
'customer_name': customer_name,
|
'customer_name': customer_name,
|
||||||
'phone': phone,
|
'phone': phone,
|
||||||
'email': email,
|
'email': email,
|
||||||
'floor_id': floor_id,
|
'num_people': num_people,
|
||||||
'table_id': table_id,
|
'floor_id': target_floor.id,
|
||||||
|
'table_ids': [(6, 0, [t.id for t in assigned_tables])],
|
||||||
|
'table_id': assigned_tables[0].id,
|
||||||
'start_time': start_time,
|
'start_time': start_time,
|
||||||
'end_time': end_time,
|
'end_time': end_time,
|
||||||
'num_people': num_people,
|
'state': 'confirmed' # Direct confirmation from website
|
||||||
'state': 'draft'
|
|
||||||
})
|
})
|
||||||
return request.render("dine360_reservation.reservation_success_template", {
|
return request.render("dine360_reservation.reservation_success_template", {
|
||||||
'reservation': reservation,
|
'reservation': reservation,
|
||||||
@ -55,7 +268,5 @@ class TableReservationController(http.Controller):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return request.render("dine360_reservation.reservation_page_template", {
|
return request.render("dine360_reservation.reservation_page_template", {
|
||||||
'error': str(e),
|
'error': str(e),
|
||||||
'floors': request.env['restaurant.floor'].sudo().search([]),
|
|
||||||
'tables': request.env['restaurant.table'].sudo().search([]),
|
|
||||||
'post': post,
|
'post': post,
|
||||||
})
|
})
|
||||||
|
|||||||
@ -0,0 +1,40 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="1">
|
||||||
|
<record id="schedule_monday" model="reservation.schedule">
|
||||||
|
<field name="day">0</field>
|
||||||
|
<field name="opening_time">12.0</field>
|
||||||
|
<field name="closing_time">21.0</field>
|
||||||
|
</record>
|
||||||
|
<record id="schedule_tuesday" model="reservation.schedule">
|
||||||
|
<field name="day">1</field>
|
||||||
|
<field name="opening_time">12.0</field>
|
||||||
|
<field name="closing_time">21.0</field>
|
||||||
|
</record>
|
||||||
|
<record id="schedule_wednesday" model="reservation.schedule">
|
||||||
|
<field name="day">2</field>
|
||||||
|
<field name="opening_time">12.0</field>
|
||||||
|
<field name="closing_time">21.0</field>
|
||||||
|
</record>
|
||||||
|
<record id="schedule_thursday" model="reservation.schedule">
|
||||||
|
<field name="day">3</field>
|
||||||
|
<field name="opening_time">12.0</field>
|
||||||
|
<field name="closing_time">21.0</field>
|
||||||
|
</record>
|
||||||
|
<record id="schedule_friday" model="reservation.schedule">
|
||||||
|
<field name="day">4</field>
|
||||||
|
<field name="opening_time">12.0</field>
|
||||||
|
<field name="closing_time">23.0</field>
|
||||||
|
</record>
|
||||||
|
<record id="schedule_saturday" model="reservation.schedule">
|
||||||
|
<field name="day">5</field>
|
||||||
|
<field name="opening_time">12.0</field>
|
||||||
|
<field name="closing_time">23.0</field>
|
||||||
|
</record>
|
||||||
|
<record id="schedule_sunday" model="reservation.schedule">
|
||||||
|
<field name="day">6</field>
|
||||||
|
<field name="opening_time">12.0</field>
|
||||||
|
<field name="closing_time">21.0</field>
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
@ -1 +1,5 @@
|
|||||||
from . import restaurant_reservation
|
from . import restaurant_reservation
|
||||||
|
from . import restaurant_table
|
||||||
|
from . import reservation_schedule
|
||||||
|
from . import reservation_holiday
|
||||||
|
from . import reservation_peak_hour
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
26
addons/dine360_reservation/models/reservation_holiday.py
Normal file
26
addons/dine360_reservation/models/reservation_holiday.py
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
from odoo import models, fields, api, _
|
||||||
|
from odoo.exceptions import ValidationError
|
||||||
|
|
||||||
|
class ReservationHoliday(models.Model):
|
||||||
|
_name = 'reservation.holiday'
|
||||||
|
_description = 'Reservation Holiday/Override'
|
||||||
|
_order = 'date desc'
|
||||||
|
|
||||||
|
name = fields.Char(string='Description', required=True, placeholder="e.g. Christmas Day")
|
||||||
|
date = fields.Date(string='Date', required=True)
|
||||||
|
|
||||||
|
is_closed = fields.Boolean(string='Closed for Reservations', default=True)
|
||||||
|
|
||||||
|
opening_time = fields.Float(string='Override Opening Time', help="Leave empty to use regular schedule if not closed")
|
||||||
|
closing_time = fields.Float(string='Override Closing Time')
|
||||||
|
|
||||||
|
@api.constrains('opening_time', 'closing_time', 'is_closed')
|
||||||
|
def _check_times(self):
|
||||||
|
for rec in self:
|
||||||
|
if not rec.is_closed and (rec.opening_time or rec.closing_time):
|
||||||
|
if rec.opening_time >= rec.closing_time:
|
||||||
|
raise ValidationError(_("Override Opening time must be before closing time."))
|
||||||
|
|
||||||
|
_sql_constraints = [
|
||||||
|
('date_unique', 'unique(date)', 'An override for this date already exists!')
|
||||||
|
]
|
||||||
23
addons/dine360_reservation/models/reservation_peak_hour.py
Normal file
23
addons/dine360_reservation/models/reservation_peak_hour.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
from odoo import models, fields, api, _
|
||||||
|
from odoo.exceptions import ValidationError
|
||||||
|
|
||||||
|
class ReservationPeakHour(models.Model):
|
||||||
|
_name = 'reservation.peak.hour'
|
||||||
|
_description = 'Restaurant Peak Hour Duration'
|
||||||
|
|
||||||
|
schedule_id = fields.Many2one('reservation.schedule', string='Schedule', ondelete='cascade')
|
||||||
|
start_time = fields.Float(string='Peak Start Time', required=True)
|
||||||
|
end_time = fields.Float(string='Peak End Time', required=True)
|
||||||
|
|
||||||
|
slot_duration = fields.Selection([
|
||||||
|
('0.5', '30 Minutes'),
|
||||||
|
('1.0', '1 Hour'),
|
||||||
|
('1.5', '1.5 Hours'),
|
||||||
|
('2.0', '2 Hours'),
|
||||||
|
], string='Peak Slot Duration', default='1.0', required=True)
|
||||||
|
|
||||||
|
@api.constrains('start_time', 'end_time')
|
||||||
|
def _check_times(self):
|
||||||
|
for rec in self:
|
||||||
|
if rec.start_time >= rec.end_time:
|
||||||
|
raise ValidationError(_("Peak start time must be before end time."))
|
||||||
78
addons/dine360_reservation/models/reservation_schedule.py
Normal file
78
addons/dine360_reservation/models/reservation_schedule.py
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
from odoo import models, fields, api, _
|
||||||
|
from odoo.exceptions import ValidationError
|
||||||
|
|
||||||
|
class ReservationSchedule(models.Model):
|
||||||
|
_name = 'reservation.schedule'
|
||||||
|
_description = 'Restaurant Reservation Schedule'
|
||||||
|
_order = 'day asc'
|
||||||
|
|
||||||
|
day = fields.Selection([
|
||||||
|
('0', 'Monday'),
|
||||||
|
('1', 'Tuesday'),
|
||||||
|
('2', 'Wednesday'),
|
||||||
|
('3', 'Thursday'),
|
||||||
|
('4', 'Friday'),
|
||||||
|
('5', 'Saturday'),
|
||||||
|
('6', 'Sunday')
|
||||||
|
], string='Day of Week', required=True)
|
||||||
|
|
||||||
|
opening_time = fields.Float(string='Opening Time', default=12.0, help="Format: 14.5 for 2:30 PM")
|
||||||
|
closing_time = fields.Float(string='Closing Time', default=22.0)
|
||||||
|
|
||||||
|
has_break = fields.Boolean(string='Has Break Time', default=False)
|
||||||
|
break_start_time = fields.Float(string='Break Start Time')
|
||||||
|
break_end_time = fields.Float(string='Break End Time')
|
||||||
|
|
||||||
|
is_closed = fields.Boolean(string='Closed for Reservations', default=False)
|
||||||
|
|
||||||
|
default_slot_duration = fields.Selection([
|
||||||
|
('0.5', '30 Minutes'),
|
||||||
|
('1.0', '1 Hour'),
|
||||||
|
('1.5', '1.5 Hours'),
|
||||||
|
('2.0', '2 Hours'),
|
||||||
|
('3.0', '3 Hours'),
|
||||||
|
], string='Default Slot Duration', default='1.0', required=True)
|
||||||
|
|
||||||
|
peak_hour_ids = fields.One2many('reservation.peak.hour', 'schedule_id', string='Peak Hour Slot Overrides')
|
||||||
|
|
||||||
|
display_opening = fields.Char(compute='_compute_display_times')
|
||||||
|
display_closing = fields.Char(compute='_compute_display_times')
|
||||||
|
display_break = fields.Char(compute='_compute_display_times')
|
||||||
|
|
||||||
|
def _compute_display_times(self):
|
||||||
|
for rec in self:
|
||||||
|
rec.display_opening = self._float_to_time_str(rec.opening_time)
|
||||||
|
rec.display_closing = self._float_to_time_str(rec.closing_time)
|
||||||
|
if rec.has_break:
|
||||||
|
rec.display_break = f"{self._float_to_time_str(rec.break_start_time)} - {self._float_to_time_str(rec.break_end_time)}"
|
||||||
|
else:
|
||||||
|
rec.display_break = ""
|
||||||
|
|
||||||
|
def _float_to_time_str(self, time_float):
|
||||||
|
hours = int(time_float)
|
||||||
|
minutes = int((time_float - hours) * 60)
|
||||||
|
ampm = "am" if hours < 12 else "pm"
|
||||||
|
display_hours = hours if hours <= 12 else hours - 12
|
||||||
|
if display_hours == 0: display_hours = 12
|
||||||
|
return f"{display_hours}:{minutes:02d}{ampm}"
|
||||||
|
|
||||||
|
_sql_constraints = [
|
||||||
|
('day_unique', 'unique(day)', 'Schedule for this day already exists!')
|
||||||
|
]
|
||||||
|
|
||||||
|
@api.constrains('opening_time', 'closing_time', 'break_start_time', 'break_end_time', 'has_break')
|
||||||
|
def _check_times(self):
|
||||||
|
for rec in self:
|
||||||
|
if not rec.is_closed:
|
||||||
|
if rec.opening_time >= rec.closing_time:
|
||||||
|
raise ValidationError(_("Opening time must be before closing time."))
|
||||||
|
if rec.has_break:
|
||||||
|
if not (rec.opening_time < rec.break_start_time < rec.break_end_time < rec.closing_time):
|
||||||
|
raise ValidationError(_("Break time must be within opening and closing hours, and start before it ends."))
|
||||||
|
|
||||||
|
def name_get(self):
|
||||||
|
result = []
|
||||||
|
for rec in self:
|
||||||
|
day_name = dict(self._fields['day'].selection).get(rec.day)
|
||||||
|
result.append((rec.id, day_name))
|
||||||
|
return result
|
||||||
@ -14,8 +14,13 @@ class RestaurantReservation(models.Model):
|
|||||||
email = fields.Char(string='Email')
|
email = fields.Char(string='Email')
|
||||||
num_people = fields.Integer(string='Number of People', default=1)
|
num_people = fields.Integer(string='Number of People', default=1)
|
||||||
|
|
||||||
floor_id = fields.Many2one('restaurant.floor', string='Floor', required=True)
|
floor_id = fields.Many2one('restaurant.floor', string='Floor')
|
||||||
table_id = fields.Many2one('restaurant.table', string='Table', required=True)
|
table_id = fields.Many2one('restaurant.table', string='Primary Table')
|
||||||
|
table_ids = fields.Many2many('restaurant.table', string='Tables')
|
||||||
|
|
||||||
|
is_admin_override = fields.Boolean(string='Admin Override', default=False, help="Skip all validation and availability checks")
|
||||||
|
override_reason = fields.Text(string='Override Reason')
|
||||||
|
overridden_by_id = fields.Many2one('res.users', string='Overridden By', readonly=True)
|
||||||
|
|
||||||
start_time = fields.Datetime(string='Start Time', required=True)
|
start_time = fields.Datetime(string='Start Time', required=True)
|
||||||
end_time = fields.Datetime(string='End Time', required=True)
|
end_time = fields.Datetime(string='End Time', required=True)
|
||||||
@ -51,43 +56,104 @@ class RestaurantReservation(models.Model):
|
|||||||
def create(self, vals):
|
def create(self, vals):
|
||||||
if vals.get('name', _('New')) == _('New'):
|
if vals.get('name', _('New')) == _('New'):
|
||||||
vals['name'] = self.env['ir.sequence'].next_by_code('restaurant.reservation') or _('New')
|
vals['name'] = self.env['ir.sequence'].next_by_code('restaurant.reservation') or _('New')
|
||||||
|
if vals.get('is_admin_override'):
|
||||||
|
vals['overridden_by_id'] = self.env.user.id
|
||||||
return super(RestaurantReservation, self).create(vals)
|
return super(RestaurantReservation, self).create(vals)
|
||||||
|
|
||||||
@api.constrains('table_id', 'start_time', 'end_time', 'state')
|
def write(self, vals):
|
||||||
|
if vals.get('is_admin_override'):
|
||||||
|
vals['overridden_by_id'] = self.env.user.id
|
||||||
|
return super(RestaurantReservation, self).write(vals)
|
||||||
|
|
||||||
|
@api.constrains('is_admin_override', 'override_reason')
|
||||||
|
def _check_override_reason(self):
|
||||||
|
for rec in self:
|
||||||
|
if rec.is_admin_override and not rec.override_reason:
|
||||||
|
raise ValidationError(_("Please provide a reason for the admin override."))
|
||||||
|
|
||||||
|
@api.constrains('table_ids', 'start_time', 'end_time', 'state', 'is_admin_override')
|
||||||
def _check_overlap(self):
|
def _check_overlap(self):
|
||||||
for rec in self:
|
for rec in self:
|
||||||
|
if rec.is_admin_override:
|
||||||
|
continue
|
||||||
if rec.state in ['confirmed', 'completed']:
|
if rec.state in ['confirmed', 'completed']:
|
||||||
|
tables = rec.table_ids or rec.table_id
|
||||||
|
if not tables:
|
||||||
|
continue
|
||||||
overlap = self.search([
|
overlap = self.search([
|
||||||
('id', '!=', rec.id),
|
('id', '!=', rec.id),
|
||||||
('table_id', '=', rec.table_id.id),
|
('table_ids', 'in', tables.ids),
|
||||||
('state', '=', 'confirmed'),
|
('state', '=', 'confirmed'),
|
||||||
('start_time', '<', rec.end_time),
|
('start_time', '<', rec.end_time),
|
||||||
('end_time', '>', rec.start_time),
|
('end_time', '>', rec.start_time),
|
||||||
])
|
])
|
||||||
if overlap:
|
if overlap:
|
||||||
raise ValidationError(_('This table is already reserved for the selected time slot.'))
|
raise ValidationError(_('One or more tables are already reserved for the selected time slot.'))
|
||||||
|
|
||||||
@api.constrains('start_time', 'end_time')
|
@api.constrains('start_time', 'end_time', 'is_admin_override')
|
||||||
def _check_opening_hours(self):
|
def _check_opening_hours(self):
|
||||||
restaurant_tz = pytz.timezone('America/Toronto')
|
restaurant_tz = pytz.timezone('America/Toronto')
|
||||||
for rec in self:
|
for rec in self:
|
||||||
|
if rec.is_admin_override:
|
||||||
|
continue
|
||||||
local_start = pytz.utc.localize(rec.start_time).astimezone(restaurant_tz)
|
local_start = pytz.utc.localize(rec.start_time).astimezone(restaurant_tz)
|
||||||
local_end = pytz.utc.localize(rec.end_time).astimezone(restaurant_tz)
|
local_end = pytz.utc.localize(rec.end_time).astimezone(restaurant_tz)
|
||||||
|
|
||||||
day = local_start.weekday() # 0=Mon, 6=Sun
|
res_date = local_start.date()
|
||||||
|
day = str(local_start.weekday()) # 0=Mon, 6=Sun
|
||||||
time_start = local_start.hour + local_start.minute / 60.0
|
time_start = local_start.hour + local_start.minute / 60.0
|
||||||
time_end = local_end.hour + local_end.minute / 60.0
|
time_end = local_end.hour + local_end.minute / 60.0
|
||||||
|
|
||||||
if day in [6, 0, 1, 2, 3]: # Sun-Thu
|
# 1. Check for Holiday/Override
|
||||||
if time_start < 12.0 or time_end > 21.0:
|
holiday = self.env['reservation.holiday'].sudo().search([('date', '=', res_date)], limit=1)
|
||||||
raise ValidationError(_('Reservations for Sunday - Thursday must be between 12:00 PM and 9:00 PM (Local Time).'))
|
|
||||||
else: # Fri-Sat
|
if holiday:
|
||||||
if time_start < 12.0 or time_end > 23.0:
|
if holiday.is_closed:
|
||||||
raise ValidationError(_('Reservations for Friday & Saturday must be between 12:00 PM and 11:00 PM (Local Time).'))
|
raise ValidationError(_("Reservations are closed on %s for %s.") % (res_date.strftime('%B %d, %Y'), holiday.name))
|
||||||
|
|
||||||
|
# Check holiday override hours if specified
|
||||||
|
if holiday.opening_time or holiday.closing_time:
|
||||||
|
opening = holiday.opening_time or 0.0
|
||||||
|
closing = holiday.closing_time or 24.0
|
||||||
|
if time_start < opening or time_end > closing:
|
||||||
|
raise ValidationError(_("Special hours for %s (%s): %s to %s.") % (
|
||||||
|
holiday.name,
|
||||||
|
res_date.strftime('%B %d'),
|
||||||
|
self._float_to_time_str(opening),
|
||||||
|
self._float_to_time_str(closing)
|
||||||
|
))
|
||||||
|
return # If holiday found and validated, skip regular schedule
|
||||||
|
|
||||||
@api.onchange('start_time')
|
# 2. Check Regular Schedule
|
||||||
|
schedule = self.env['reservation.schedule'].sudo().search([('day', '=', day)], limit=1)
|
||||||
|
|
||||||
|
if not schedule:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if schedule.is_closed:
|
||||||
|
raise ValidationError(_("Reservations are closed for %s.") % dict(schedule._fields['day'].selection).get(day))
|
||||||
|
|
||||||
|
if time_start < schedule.opening_time or time_end > schedule.closing_time:
|
||||||
|
raise ValidationError(_("Reservations for %s must be between %s and %s.") % (
|
||||||
|
dict(schedule._fields['day'].selection).get(day),
|
||||||
|
self._float_to_time_str(schedule.opening_time),
|
||||||
|
self._float_to_time_str(schedule.closing_time)
|
||||||
|
))
|
||||||
|
|
||||||
|
if schedule.has_break:
|
||||||
|
if time_start < schedule.break_end_time and time_end > schedule.break_start_time:
|
||||||
|
raise ValidationError(_("The restaurant has a break between %s and %s on %s.") % (
|
||||||
|
self._float_to_time_str(schedule.break_start_time),
|
||||||
|
self._float_to_time_str(schedule.break_end_time),
|
||||||
|
dict(schedule._fields['day'].selection).get(day)
|
||||||
|
))
|
||||||
|
|
||||||
|
@api.onchange('start_time', 'table_id')
|
||||||
def _onchange_start_time(self):
|
def _onchange_start_time(self):
|
||||||
if self.start_time:
|
if self.start_time and self.table_id:
|
||||||
|
duration = float(self.table_id.reservation_slot_duration or 1.0)
|
||||||
|
self.end_time = self.start_time + timedelta(hours=duration)
|
||||||
|
elif self.start_time:
|
||||||
self.end_time = self.start_time + timedelta(hours=1)
|
self.end_time = self.start_time + timedelta(hours=1)
|
||||||
|
|
||||||
def action_confirm(self):
|
def action_confirm(self):
|
||||||
|
|||||||
29
addons/dine360_reservation/models/restaurant_table.py
Normal file
29
addons/dine360_reservation/models/restaurant_table.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
from odoo import models, fields, api
|
||||||
|
|
||||||
|
class RestaurantTable(models.Model):
|
||||||
|
_inherit = 'restaurant.table'
|
||||||
|
|
||||||
|
is_reservation_enabled = fields.Boolean(string='Enabled for Reservation', default=True)
|
||||||
|
min_party_size = fields.Integer(string='Min Party Size', default=1)
|
||||||
|
max_party_size = fields.Integer(string='Max Party Size', default=1)
|
||||||
|
zone = fields.Char(string='Zone/Section', help="e.g. Window Side, Garden, VIP Section")
|
||||||
|
reservation_slot_duration = fields.Selection([
|
||||||
|
('0.5', '30 Minutes'),
|
||||||
|
('1.0', '1 Hour'),
|
||||||
|
('1.5', '1.5 Hours'),
|
||||||
|
('2.0', '2 Hours'),
|
||||||
|
('3.0', '3 Hours'),
|
||||||
|
], string='Slot Duration', default='1.0', required=True)
|
||||||
|
|
||||||
|
@api.onchange('seats')
|
||||||
|
def _onchange_seats_for_reservation(self):
|
||||||
|
for record in self:
|
||||||
|
if record.seats == 6:
|
||||||
|
record.min_party_size = 4
|
||||||
|
record.max_party_size = 6
|
||||||
|
elif record.seats == 4:
|
||||||
|
record.min_party_size = 2
|
||||||
|
record.max_party_size = 4
|
||||||
|
else:
|
||||||
|
record.min_party_size = 1
|
||||||
|
record.max_party_size = record.seats or 1
|
||||||
@ -1,2 +1,5 @@
|
|||||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||||
access_restaurant_reservation_user,restaurant.reservation,model_restaurant_reservation,base.group_user,1,1,1,1
|
access_restaurant_reservation_user,restaurant.reservation,model_restaurant_reservation,base.group_user,1,1,1,1
|
||||||
|
access_reservation_schedule_user,reservation.schedule,model_reservation_schedule,base.group_user,1,1,1,1
|
||||||
|
access_reservation_holiday_user,reservation.holiday,model_reservation_holiday,base.group_user,1,1,1,1
|
||||||
|
access_reservation_peak_hour_user,reservation.peak_hour,model_reservation_peak_hour,base.group_user,1,1,1,1
|
||||||
|
|||||||
|
@ -12,4 +12,22 @@
|
|||||||
parent="menu_restaurant_reservation_root"
|
parent="menu_restaurant_reservation_root"
|
||||||
action="action_restaurant_reservation"
|
action="action_restaurant_reservation"
|
||||||
sequence="10"/>
|
sequence="10"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_restaurant_table_reservation_settings"
|
||||||
|
name="Reservation Settings"
|
||||||
|
parent="menu_restaurant_reservation_root"
|
||||||
|
action="action_restaurant_table_reservation_settings"
|
||||||
|
sequence="20"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_reservation_schedule_settings"
|
||||||
|
name="Reservation Schedule"
|
||||||
|
parent="menu_restaurant_reservation_root"
|
||||||
|
action="action_reservation_schedule"
|
||||||
|
sequence="30"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_reservation_holiday_settings"
|
||||||
|
name="Holiday / Special Overrides"
|
||||||
|
parent="menu_restaurant_reservation_root"
|
||||||
|
action="action_reservation_holiday"
|
||||||
|
sequence="40"/>
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|||||||
@ -0,0 +1,30 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="view_reservation_holiday_tree" model="ir.ui.view">
|
||||||
|
<field name="name">reservation.holiday.tree</field>
|
||||||
|
<field name="model">reservation.holiday</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<tree editable="bottom" decoration-danger="is_closed">
|
||||||
|
<field name="date"/>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="is_closed"/>
|
||||||
|
<field name="opening_time" widget="float_time" invisible="is_closed"/>
|
||||||
|
<field name="closing_time" widget="float_time" invisible="is_closed"/>
|
||||||
|
</tree>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_reservation_holiday" model="ir.actions.act_window">
|
||||||
|
<field name="name">Holiday / Special Overrides</field>
|
||||||
|
<field name="res_model">reservation.holiday</field>
|
||||||
|
<field name="view_mode">tree</field>
|
||||||
|
<field name="help" type="html">
|
||||||
|
<p class="o_view_nocontent_smiling_face">
|
||||||
|
Add holiday or festival overrides.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
You can mark a specific date as closed or set special opening/closing hours that override the regular schedule.
|
||||||
|
</p>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
@ -0,0 +1,67 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="view_reservation_schedule_tree" model="ir.ui.view">
|
||||||
|
<field name="name">reservation.schedule.tree</field>
|
||||||
|
<field name="model">reservation.schedule</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<tree editable="bottom">
|
||||||
|
<field name="day"/>
|
||||||
|
<field name="is_closed"/>
|
||||||
|
<field name="opening_time" widget="float_time" invisible="is_closed"/>
|
||||||
|
<field name="closing_time" widget="float_time" invisible="is_closed"/>
|
||||||
|
<field name="has_break" invisible="is_closed"/>
|
||||||
|
<field name="break_start_time" widget="float_time" invisible="not has_break or is_closed"/>
|
||||||
|
<field name="break_end_time" widget="float_time" invisible="not has_break or is_closed"/>
|
||||||
|
</tree>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_reservation_schedule_form" model="ir.ui.view">
|
||||||
|
<field name="name">reservation.schedule.form</field>
|
||||||
|
<field name="model">reservation.schedule</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Reservation Schedule">
|
||||||
|
<sheet>
|
||||||
|
<group>
|
||||||
|
<field name="day"/>
|
||||||
|
<field name="is_closed"/>
|
||||||
|
<field name="default_slot_duration"/>
|
||||||
|
</group>
|
||||||
|
<group invisible="is_closed">
|
||||||
|
<group name="hours" string="Operating Hours">
|
||||||
|
<field name="opening_time" widget="float_time"/>
|
||||||
|
<field name="closing_time" widget="float_time"/>
|
||||||
|
</group>
|
||||||
|
<group name="break" string="Break Time">
|
||||||
|
<field name="has_break"/>
|
||||||
|
<field name="break_start_time" widget="float_time" invisible="not has_break"/>
|
||||||
|
<field name="break_end_time" widget="float_time" invisible="not has_break"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<notebook invisible="is_closed">
|
||||||
|
<page string="Peak Hour Slot Overrides" name="peak_hours">
|
||||||
|
<field name="peak_hour_ids">
|
||||||
|
<tree editable="bottom">
|
||||||
|
<field name="start_time" widget="float_time"/>
|
||||||
|
<field name="end_time" widget="float_time"/>
|
||||||
|
<field name="slot_duration"/>
|
||||||
|
</tree>
|
||||||
|
</field>
|
||||||
|
</page>
|
||||||
|
</notebook>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_reservation_schedule" model="ir.actions.act_window">
|
||||||
|
<field name="name">Reservation Schedule</field>
|
||||||
|
<field name="res_model">reservation.schedule</field>
|
||||||
|
<field name="view_mode">tree,form</field>
|
||||||
|
<field name="help" type="html">
|
||||||
|
<p class="o_view_nocontent_smiling_face">
|
||||||
|
Manage your daily reservation hours and break times.
|
||||||
|
</p>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
@ -12,9 +12,35 @@
|
|||||||
<div class="text-center mb-5">
|
<div class="text-center mb-5">
|
||||||
<h2 class="display-4 fw-bold mb-2" style="color: #fecd4f;">Table Reservation</h2>
|
<h2 class="display-4 fw-bold mb-2" style="color: #fecd4f;">Table Reservation</h2>
|
||||||
<p class="text-muted mb-1">Book your spot for an authentic South Indian dining experience.</p>
|
<p class="text-muted mb-1">Book your spot for an authentic South Indian dining experience.</p>
|
||||||
<div class="small fw-bold mb-0" style="color: #171422;">
|
<div class="mt-2 text-center">
|
||||||
<i class="fa fa-clock-o me-1"></i> Sun - Thu: 12pm - 9pm | Fri & Sat: 12pm - 11pm
|
<button class="btn btn-sm btn-outline-dark border-0 fw-bold" type="button" data-bs-toggle="collapse" data-bs-target="#openingHours" aria-expanded="false">
|
||||||
</div>
|
<i class="fa fa-clock-o me-1"/> View Opening Hours
|
||||||
|
</button>
|
||||||
|
<div class="collapse mt-2" id="openingHours">
|
||||||
|
<div class="p-3 rounded shadow-sm" style="background: rgba(254, 205, 79, 0.1); border: 1px solid #fecd4f;">
|
||||||
|
<div class="row g-2">
|
||||||
|
<t t-foreach="schedule" t-as="day_schedule">
|
||||||
|
<div class="col-6 col-md-4 text-start">
|
||||||
|
<div class="small fw-bold text-uppercase" style="font-size: 0.7rem; color: #171422;">
|
||||||
|
<t t-esc="dict(day_schedule._fields['day'].selection).get(day_schedule.day)"/>
|
||||||
|
</div>
|
||||||
|
<div class="small">
|
||||||
|
<t t-if="day_schedule.is_closed">
|
||||||
|
<span class="text-danger">Closed</span>
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
<t t-esc="day_schedule.display_opening"/> - <t t-esc="day_schedule.display_closing"/>
|
||||||
|
<t t-if="day_schedule.has_break">
|
||||||
|
<br/><span class="text-muted" style="font-size: 0.75rem;">Break: <t t-esc="day_schedule.display_break"/></span>
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<t t-if="error">
|
<t t-if="error">
|
||||||
@ -39,32 +65,28 @@
|
|||||||
<label class="form-label fw-bold">Email (Optional)</label>
|
<label class="form-label fw-bold">Email (Optional)</label>
|
||||||
<input type="email" name="email" class="form-control form-control-lg border-0 shadow-sm" style="background: #f8f9fa; border-radius: 12px; color: #171422 !important; border: 1px solid #fecd4f !important;" placeholder="john@example.com" t-att-value="post.get('email') if post else ''"/>
|
<input type="email" name="email" class="form-control form-control-lg border-0 shadow-sm" style="background: #f8f9fa; border-radius: 12px; color: #171422 !important; border: 1px solid #fecd4f !important;" placeholder="john@example.com" t-att-value="post.get('email') if post else ''"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-12">
|
||||||
<label class="form-label fw-bold">Select Floor</label>
|
|
||||||
<select name="floor_id" id="floor_id" class="form-select form-select-lg border-0 shadow-sm" style="background: #f8f9fa; border-radius: 12px; color: #171422 !important; border: 1px solid #fecd4f !important;" required="1">
|
|
||||||
<option value="">Choose a Floor...</option>
|
|
||||||
<t t-foreach="floors" t-as="floor">
|
|
||||||
<option t-att-value="floor.id"><t t-esc="floor.name"/></option>
|
|
||||||
</t>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label fw-bold">Select Table</label>
|
|
||||||
<select name="table_id" id="table_id" class="form-select form-select-lg border-0 shadow-sm" style="background: #f8f9fa; border-radius: 12px; color: #171422 !important; border: 1px solid #fecd4f !important;" required="1">
|
|
||||||
<option value="">Choose a Table...</option>
|
|
||||||
<t t-foreach="tables" t-as="table">
|
|
||||||
<option t-att-value="table.id" t-att-data-floor="table.floor_id.id"><t t-esc="table.name"/> (Cap: <t t-esc="table.seats"/>)</option>
|
|
||||||
</t>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label fw-bold">Number of Guests</label>
|
<label class="form-label fw-bold">Number of Guests</label>
|
||||||
<input type="number" name="num_people" class="form-control form-control-lg border-0 shadow-sm" style="background: #f8f9fa; border-radius: 12px; color: #171422 !important; border: 1px solid #fecd4f !important;" value="2" min="1" required="1" t-att-value="post.get('num_people') if post else 2"/>
|
<input type="number" name="num_people" class="form-control form-control-lg border-0 shadow-sm" style="background: #f8f9fa; border-radius: 12px; color: #171422 !important; border: 1px solid #fecd4f !important;" value="2" min="1" required="1" t-att-value="post.get('num_people') if post else 2"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-12">
|
<div class="col-md-6">
|
||||||
<label class="form-label fw-bold">Reservation Date & Time</label>
|
<label class="form-label fw-bold">Reservation Date</label>
|
||||||
<input type="datetime-local" name="start_time" class="form-control form-control-lg border-0 shadow-sm" style="background: #f8f9fa; border-radius: 12px; color: #171422 !important; border: 1px solid #fecd4f !important;" required="1" t-att-value="post.get('start_time') if post else ''"/>
|
<input type="date" id="res_date" name="res_date" class="form-control form-control-lg border-0 shadow-sm" style="background: #f8f9fa; border-radius: 12px; color: #171422 !important; border: 1px solid #fecd4f !important;" required="1" t-att-value="post.get('res_date') if post else datetime.date.today().strftime('%Y-%m-%d')"/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label fw-bold">Available Time Slots</label>
|
||||||
|
<div id="slot_container" class="d-flex flex-wrap gap-2 p-3 rounded" style="background: #f8f9fa; border: 1px dashed #fecd4f; min-height: 100px;">
|
||||||
|
<div class="text-muted w-100 text-center py-4" id="slot_placeholder">
|
||||||
|
<i class="fa fa-calendar-check-o mb-2 d-block" style="font-size: 1.5rem;"></i>
|
||||||
|
Select a date and guest count to see available slots
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="selection_info" class="mt-2 small text-muted text-center" style="display: none;">
|
||||||
|
<i class="fa fa-info-circle me-1"/>
|
||||||
|
<span id="assigned_table_text"></span>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="start_time" id="selected_start_time" required="1"/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-5">
|
<div class="mt-5">
|
||||||
@ -72,26 +94,83 @@
|
|||||||
CONFIRM RESERVATION
|
CONFIRM RESERVATION
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
<script type="text/javascript">
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const dateInput = document.getElementById('res_date');
|
||||||
|
const guestsInput = document.getElementsByName('num_people')[0];
|
||||||
|
const container = document.getElementById('slot_container');
|
||||||
|
const hiddenInput = document.getElementById('selected_start_time');
|
||||||
|
|
||||||
<script type="text/javascript">
|
function fetchSlots() {
|
||||||
document.getElementById('floor_id').addEventListener('change', function() {
|
const date = dateInput.value;
|
||||||
var floorId = this.value;
|
const guests = guestsInput.value;
|
||||||
var tableSelect = document.getElementById('table_id');
|
|
||||||
var tableOptions = tableSelect.querySelectorAll('option');
|
if (!date || !guests) return;
|
||||||
|
|
||||||
tableSelect.value = "";
|
container.innerHTML = '<div class="text-center w-100 py-4"><i class="fa fa-spinner fa-spin me-2"></i>Loading slots...</div>';
|
||||||
tableOptions.forEach(function(option) {
|
hiddenInput.value = "";
|
||||||
if (option.value === "") {
|
|
||||||
option.style.display = "block";
|
fetch('/reservation/get_slots', {
|
||||||
} else if (option.getAttribute('data-floor') === floorId) {
|
method: 'POST',
|
||||||
option.style.display = "block";
|
headers: { 'Content-Type': 'application/json' },
|
||||||
} else {
|
body: JSON.stringify({
|
||||||
option.style.display = "none";
|
jsonrpc: "2.0",
|
||||||
}
|
params: { date_str: date, num_people: guests }
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
</script>
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.error) {
|
||||||
|
container.innerHTML = `<div class="alert alert-danger w-100 mb-0">${data.error.message}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = data.result;
|
||||||
|
container.innerHTML = "";
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
container.innerHTML = `<div class="alert alert-warning w-100 mb-0 text-center fw-bold">${result.error}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.slots.length === 0) {
|
||||||
|
container.innerHTML = '<div class="alert alert-info w-100 mb-0 text-center fw-bold">No available slots for this selection. Try another date or party size.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.slots.forEach(slot => {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = "button";
|
||||||
|
btn.className = "btn btn-outline-dark slot-btn transition-all m-1";
|
||||||
|
btn.style.cssText = "border-radius: 8px; font-weight: 600; padding: 10px 15px; border: 1px solid #fecd4f; min-width: 100px;";
|
||||||
|
btn.innerHTML = slot.display;
|
||||||
|
btn.onclick = function() {
|
||||||
|
document.querySelectorAll('.slot-btn').forEach(b => {
|
||||||
|
b.classList.remove('active');
|
||||||
|
b.style.backgroundColor = "";
|
||||||
|
b.style.color = "";
|
||||||
|
});
|
||||||
|
this.classList.add('active');
|
||||||
|
this.style.backgroundColor = "#fecd4f";
|
||||||
|
this.style.color = "#171422";
|
||||||
|
hiddenInput.value = slot.full_dt;
|
||||||
|
|
||||||
|
const infoDiv = document.getElementById('selection_info');
|
||||||
|
const infoText = document.getElementById('assigned_table_text');
|
||||||
|
infoDiv.style.display = 'block';
|
||||||
|
infoText.innerHTML = "Assignment: <strong>" + slot.tables + "</strong>";
|
||||||
|
};
|
||||||
|
container.appendChild(btn);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
dateInput.addEventListener('change', fetchSlots);
|
||||||
|
guestsInput.addEventListener('change', fetchSlots);
|
||||||
|
|
||||||
|
if(dateInput.value) fetchSlots();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -117,7 +196,14 @@
|
|||||||
<div class="card border-0 shadow-sm p-4 mb-5" style="border-radius: 15px; background: #f8f9fa;">
|
<div class="card border-0 shadow-sm p-4 mb-5" style="border-radius: 15px; background: #f8f9fa;">
|
||||||
<div class="row text-start g-3">
|
<div class="row text-start g-3">
|
||||||
<div class="col-6"><strong>Floor:</strong> <t t-esc="reservation.floor_id.name"/></div>
|
<div class="col-6"><strong>Floor:</strong> <t t-esc="reservation.floor_id.name"/></div>
|
||||||
<div class="col-6"><strong>Table:</strong> <t t-esc="reservation.table_id.name"/></div>
|
<div class="col-6"><strong>Table(s):</strong>
|
||||||
|
<t t-if="reservation.table_ids">
|
||||||
|
<t t-esc="', '.join(reservation.table_ids.mapped('name'))"/>
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
<t t-esc="reservation.table_id.name"/>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
<div class="col-6"><strong>Guests:</strong> <t t-esc="reservation.num_people"/></div>
|
<div class="col-6"><strong>Guests:</strong> <t t-esc="reservation.num_people"/></div>
|
||||||
<div class="col-12"><strong>Time:</strong> <t t-esc="reservation.start_time.strftime('%B %d, %Y at %I:%M %p')"/></div>
|
<div class="col-12"><strong>Time:</strong> <t t-esc="reservation.start_time.strftime('%B %d, %Y at %I:%M %p')"/></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
<field name="customer_name"/>
|
<field name="customer_name"/>
|
||||||
<field name="phone"/>
|
<field name="phone"/>
|
||||||
<field name="floor_id"/>
|
<field name="floor_id"/>
|
||||||
<field name="table_id"/>
|
<field name="table_ids" widget="many2many_tags"/>
|
||||||
<field name="start_time"/>
|
<field name="start_time"/>
|
||||||
<field name="end_time"/>
|
<field name="end_time"/>
|
||||||
<field name="num_people"/>
|
<field name="num_people"/>
|
||||||
@ -47,10 +47,13 @@
|
|||||||
<field name="phone"/>
|
<field name="phone"/>
|
||||||
<field name="email"/>
|
<field name="email"/>
|
||||||
<field name="num_people"/>
|
<field name="num_people"/>
|
||||||
|
<field name="is_admin_override"/>
|
||||||
|
<field name="override_reason" invisible="not is_admin_override" required="is_admin_override"/>
|
||||||
|
<field name="overridden_by_id" invisible="not is_admin_override"/>
|
||||||
</group>
|
</group>
|
||||||
<group string="Reservation Details">
|
<group string="Reservation Details">
|
||||||
<field name="floor_id"/>
|
<field name="floor_id"/>
|
||||||
<field name="table_id" domain="[('floor_id', '=', floor_id)]"/>
|
<field name="table_ids" widget="many2many_tags" domain="[('floor_id', '=', floor_id)]"/>
|
||||||
<field name="start_time"/>
|
<field name="start_time"/>
|
||||||
<field name="end_time"/>
|
<field name="end_time"/>
|
||||||
</group>
|
</group>
|
||||||
@ -69,12 +72,11 @@
|
|||||||
<field name="name"/>
|
<field name="name"/>
|
||||||
<field name="customer_name"/>
|
<field name="customer_name"/>
|
||||||
<field name="phone"/>
|
<field name="phone"/>
|
||||||
<field name="table_id"/>
|
<field name="table_ids"/>
|
||||||
<filter string="Draft" name="draft" domain="[('state', '=', 'draft')]"/>
|
<filter string="Draft" name="draft" domain="[('state', '=', 'draft')]"/>
|
||||||
<filter string="Confirmed" name="confirmed" domain="[('state', '=', 'confirmed')]"/>
|
<filter string="Confirmed" name="confirmed" domain="[('state', '=', 'confirmed')]"/>
|
||||||
<filter string="Completed" name="completed" domain="[('state', '=', 'completed')]"/>
|
<filter string="Completed" name="completed" domain="[('state', '=', 'completed')]"/>
|
||||||
<group expand="0" string="Group By">
|
<group expand="0" string="Group By">
|
||||||
<filter string="Table" name="group_by_table" context="{'group_by': 'table_id'}"/>
|
|
||||||
<filter string="Status" name="group_by_status" context="{'group_by': 'state'}"/>
|
<filter string="Status" name="group_by_status" context="{'group_by': 'state'}"/>
|
||||||
<filter string="Date" name="group_by_date" context="{'group_by': 'start_time'}"/>
|
<filter string="Date" name="group_by_date" context="{'group_by': 'start_time'}"/>
|
||||||
</group>
|
</group>
|
||||||
|
|||||||
51
addons/dine360_reservation/views/restaurant_table_views.xml
Normal file
51
addons/dine360_reservation/views/restaurant_table_views.xml
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<!-- Inherit Table Form View -->
|
||||||
|
<record id="view_restaurant_table_form_inherit" model="ir.ui.view">
|
||||||
|
<field name="name">restaurant.table.form.inherit</field>
|
||||||
|
<field name="model">restaurant.table</field>
|
||||||
|
<field name="inherit_id" ref="pos_restaurant.view_restaurant_table_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//field[@name='seats']" position="after">
|
||||||
|
<group string="Reservation Settings" name="reservation_settings">
|
||||||
|
<field name="is_reservation_enabled"/>
|
||||||
|
<field name="reservation_slot_duration"/>
|
||||||
|
<field name="min_party_size"/>
|
||||||
|
<field name="max_party_size"/>
|
||||||
|
<field name="zone"/>
|
||||||
|
</group>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Specific List View for Reservation Settings -->
|
||||||
|
<record id="view_restaurant_table_tree_reservation_settings" model="ir.ui.view">
|
||||||
|
<field name="name">restaurant.table.tree.reservation.settings</field>
|
||||||
|
<field name="model">restaurant.table</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<tree editable="bottom">
|
||||||
|
<field name="floor_id"/>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="seats"/>
|
||||||
|
<field name="is_reservation_enabled"/>
|
||||||
|
<field name="reservation_slot_duration"/>
|
||||||
|
<field name="min_party_size"/>
|
||||||
|
<field name="max_party_size"/>
|
||||||
|
<field name="zone"/>
|
||||||
|
</tree>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Action for Reservation Settings -->
|
||||||
|
<record id="action_restaurant_table_reservation_settings" model="ir.actions.act_window">
|
||||||
|
<field name="name">Reservation Settings</field>
|
||||||
|
<field name="res_model">restaurant.table</field>
|
||||||
|
<field name="view_mode">tree,form</field>
|
||||||
|
<field name="view_id" ref="view_restaurant_table_tree_reservation_settings"/>
|
||||||
|
<field name="help" type="html">
|
||||||
|
<p class="o_view_nocontent_smiling_face">
|
||||||
|
Configure your tables for reservation.
|
||||||
|
</p>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
Loading…
x
Reference in New Issue
Block a user