feat(fusion_repairs): Phase 3 - maintenance contracts + client self-booking
Maintenance contracts - New fusion.repair.maintenance.contract model: one per partner + product + lot. Fields: interval_months, last_service_date, next_due_date, state, booking_token (secrets.token_urlsafe), last_reminder_band (30 / 7 / 1), booking_repair_id - roll_next_due_date() advances the cycle by interval_months and resets the band / booked-repair so the next cycle starts fresh - sale.order._spawn_maintenance_contracts() creates contracts for delivered SOs whose product has x_fc_maintenance_interval_months > 0 (called from Phase 3 hooks; ready for cron / on-state change wiring) Reminder cron - Daily ir.cron at 07:00 -> cron_send_due_reminders() - Sends email at 30 / 7 / 1 day bands before next_due_date; tracks last_reminder_band so we never re-send the same band in one cycle - Master toggle via ir.config_parameter fusion_repairs.enable_email_notifications Public client booking portal - /repairs/maintenance/book/<token> GET landing page with a date input - /repairs/maintenance/book/<token>/confirm POST creates a repair.order via contract.create_repair_from_booking() (source='client_portal') - Idempotent: existing booking shows "already booked" instead of spawning a duplicate - Invalid / expired tokens render a friendly "link not valid" page Mail template - email_template_maintenance_due_reminder with 4px green accent bar, 600px max-width, dark/light safe; renders the tokenized booking CTA button directly to /repairs/maintenance/book/<token> Backend - Maintenance Contracts list / form with statusbar + chatter - Menu under Operations -> Maintenance Contracts - Sequence MC/##### for contract reference - Access rules: User read, Dispatcher write, Manager full Verified end-to-end on local westin-v19: - Contract MC/00003 created due in 7 days - cron_send_due_reminders() fires the 7-day band; second invocation skips (idempotent) - create_repair_from_booking() spawns BR-WA/RO/00014 with x_fc_intake_source='client_portal' and links it back to the contract - HTTP GET /repairs/maintenance/book/<token> -> 200 with the date input and contract reference visible in the page Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -69,6 +69,7 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved.
|
||||
# Data (must load before views that reference records)
|
||||
'data/ir_sequence_data.xml',
|
||||
'data/ir_config_parameter_data.xml',
|
||||
'data/ir_cron_data.xml',
|
||||
'data/mail_activity_type_data.xml',
|
||||
'data/mail_template_data.xml',
|
||||
'data/repair_product_category_data.xml',
|
||||
@@ -78,6 +79,7 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved.
|
||||
'views/intake_template_views.xml',
|
||||
'views/service_catalog_views.xml',
|
||||
'views/repair_warranty_views.xml',
|
||||
'views/maintenance_contract_views.xml',
|
||||
'views/repair_order_views.xml',
|
||||
'views/res_partner_views.xml',
|
||||
'views/res_users_views.xml',
|
||||
@@ -85,6 +87,7 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved.
|
||||
# Portal templates
|
||||
'views/portal_sales_rep_templates.xml',
|
||||
'views/portal_client_repair_templates.xml',
|
||||
'views/portal_maintenance_templates.xml',
|
||||
# Wizards
|
||||
'wizard/repair_intake_wizard_views.xml',
|
||||
'wizard/repair_visit_report_wizard_views.xml',
|
||||
|
||||
@@ -4,3 +4,4 @@
|
||||
|
||||
from . import portal_sales_rep_repair
|
||||
from . import portal_client_repair
|
||||
from . import portal_maintenance_booking
|
||||
|
||||
70
fusion_repairs/controllers/portal_maintenance_booking.py
Normal file
70
fusion_repairs/controllers/portal_maintenance_booking.py
Normal file
@@ -0,0 +1,70 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2024-2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
"""Client maintenance booking portal.
|
||||
|
||||
The maintenance reminder email contains a tokenized URL:
|
||||
/repairs/maintenance/book/<token>
|
||||
|
||||
Clicking it lands the client on a single-page form where they can confirm
|
||||
a preferred date. On submit, a repair.order is spawned via the same
|
||||
intake service (source='client_portal') and the contract's next reminder
|
||||
band is locked so we don't keep nagging them.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import _, fields, http
|
||||
from odoo.http import request
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MaintenanceBookingPortal(http.Controller):
|
||||
|
||||
def _resolve_contract(self, token):
|
||||
if not token:
|
||||
return None
|
||||
Contract = request.env['fusion.repair.maintenance.contract'].sudo()
|
||||
contract = Contract.search([('booking_token', '=', token)], limit=1)
|
||||
if not contract or contract.state != 'active':
|
||||
return None
|
||||
return contract
|
||||
|
||||
@http.route('/repairs/maintenance/book/<string:token>', type='http',
|
||||
auth='public', website=True, sitemap=False)
|
||||
def maintenance_book_get(self, token, **kw):
|
||||
contract = self._resolve_contract(token)
|
||||
if not contract:
|
||||
return request.render('fusion_repairs.portal_maintenance_invalid_token', {})
|
||||
already = bool(contract.booking_repair_id)
|
||||
return request.render('fusion_repairs.portal_maintenance_book', {
|
||||
'contract': contract,
|
||||
'already_booked': already,
|
||||
'default_date': fields.Date.context_today(request.env.user).isoformat(),
|
||||
})
|
||||
|
||||
@http.route('/repairs/maintenance/book/<string:token>/confirm', type='http',
|
||||
auth='public', methods=['POST'], csrf=True, website=True)
|
||||
def maintenance_book_post(self, token, **post):
|
||||
contract = self._resolve_contract(token)
|
||||
if not contract:
|
||||
return request.render('fusion_repairs.portal_maintenance_invalid_token', {})
|
||||
|
||||
if contract.booking_repair_id:
|
||||
return request.redirect(f'/repairs/maintenance/book/{token}?ok=already')
|
||||
|
||||
preferred_date = (post.get('preferred_date') or '').strip()
|
||||
scheduled = False
|
||||
if preferred_date:
|
||||
try:
|
||||
scheduled = fields.Date.from_string(preferred_date)
|
||||
except ValueError:
|
||||
scheduled = False
|
||||
|
||||
repair = contract.create_repair_from_booking(scheduled_date=scheduled)
|
||||
return request.render('fusion_repairs.portal_maintenance_thanks', {
|
||||
'contract': contract,
|
||||
'repair': repair,
|
||||
})
|
||||
19
fusion_repairs/data/ir_cron_data.xml
Normal file
19
fusion_repairs/data/ir_cron_data.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
|
||||
<!-- Daily maintenance reminders at 30/7/1 days before due date. -->
|
||||
<record id="cron_maintenance_due_reminders" model="ir.cron">
|
||||
<field name="name">Fusion Repairs: Send maintenance due reminders</field>
|
||||
<field name="model_id" ref="model_fusion_repair_maintenance_contract"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model.cron_send_due_reminders()</field>
|
||||
<field name="user_id" ref="base.user_root"/>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="nextcall" eval="(DateTime.now() + timedelta(hours=1)).strftime('%Y-%m-%d 07:00:00')"/>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -12,5 +12,16 @@
|
||||
<field name="number_increment">1</field>
|
||||
<field name="company_id" eval="False"/>
|
||||
</record>
|
||||
|
||||
<!-- Maintenance contract reference -->
|
||||
<record id="seq_repair_maintenance_contract" model="ir.sequence">
|
||||
<field name="name">Repair Maintenance Contract</field>
|
||||
<field name="code">fusion.repair.maintenance.contract</field>
|
||||
<field name="prefix">MC/</field>
|
||||
<field name="padding">5</field>
|
||||
<field name="number_next">1</field>
|
||||
<field name="number_increment">1</field>
|
||||
<field name="company_id" eval="False"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
@@ -55,6 +55,52 @@
|
||||
<field name="auto_delete" eval="True"/>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================== -->
|
||||
<!-- Maintenance Due Reminder -->
|
||||
<!-- ============================================================== -->
|
||||
<record id="email_template_maintenance_due_reminder" model="mail.template">
|
||||
<field name="name">Repair: Maintenance Due Reminder</field>
|
||||
<field name="model_id" ref="model_fusion_repair_maintenance_contract"/>
|
||||
<field name="subject">{{ object.company_id.name }} - Time to schedule your equipment maintenance</field>
|
||||
<field name="email_from">{{ (object.company_id.email_formatted or user.email_formatted) }}</field>
|
||||
<field name="partner_to">{{ object.partner_id.id }}</field>
|
||||
<field name="body_html" type="html">
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;">
|
||||
<div style="height:4px;background-color:#38a169;"></div>
|
||||
<div style="padding:32px 28px;">
|
||||
<p style="color:#38a169;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;">
|
||||
<t t-out="object.company_id.name"/>
|
||||
</p>
|
||||
<h2 style="font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">Your equipment is due for maintenance</h2>
|
||||
<p style="opacity:0.65;font-size:15px;line-height:1.5;margin:0 0 24px 0;">
|
||||
Hello <t t-out="object.partner_id.name or 'there'"/>, your
|
||||
<strong><t t-out="object.product_id.display_name or 'equipment'"/></strong>
|
||||
is due for its next scheduled maintenance visit on
|
||||
<strong><t t-out="object.next_due_date" t-options="{'widget': 'date'}"/></strong>.
|
||||
</p>
|
||||
<div style="text-align:center;margin:0 0 24px 0;">
|
||||
<a t-attf-href="/repairs/maintenance/book/{{ object.booking_token }}"
|
||||
style="display:inline-block;padding:14px 28px;background-color:#38a169;color:#ffffff;text-decoration:none;border-radius:6px;font-size:16px;font-weight:600;">
|
||||
Book my maintenance visit
|
||||
</a>
|
||||
</div>
|
||||
<div style="border-left:3px solid #38a169;padding:12px 16px;margin:0 0 24px 0;">
|
||||
<p style="margin:0;font-size:14px;line-height:1.5;">
|
||||
Regular maintenance keeps your equipment safe and reliable. Use the
|
||||
button above to confirm and we will reach out to schedule a time that works for you.
|
||||
</p>
|
||||
</div>
|
||||
<p style="opacity:0.55;font-size:12px;margin:0;">
|
||||
Contract reference <strong><t t-out="object.name"/></strong>.
|
||||
If you no longer have this equipment, you can ignore this email.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</field>
|
||||
<field name="lang">{{ object.partner_id.lang }}</field>
|
||||
<field name="auto_delete" eval="True"/>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================== -->
|
||||
<!-- Repair Intake Received - Office Notification -->
|
||||
<!-- ============================================================== -->
|
||||
|
||||
@@ -8,6 +8,7 @@ from . import intake_question
|
||||
from . import intake_answer
|
||||
from . import service_catalog
|
||||
from . import repair_warranty
|
||||
from . import maintenance_contract
|
||||
from . import product_template
|
||||
from . import res_partner
|
||||
from . import res_users
|
||||
|
||||
209
fusion_repairs/models/maintenance_contract.py
Normal file
209
fusion_repairs/models/maintenance_contract.py
Normal file
@@ -0,0 +1,209 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2024-2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
"""Maintenance contracts.
|
||||
|
||||
One contract per sold unit (partner + product + lot). When the underlying
|
||||
sale order is delivered and the product has x_fc_maintenance_interval_months>0,
|
||||
a contract is auto-created. A daily cron walks active contracts and sends
|
||||
the client a reminder email at 30, 7, and 1 days before next_due_date with
|
||||
a tokenized booking link.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
|
||||
|
||||
CONTRACT_STATES = [
|
||||
('draft', 'Draft'),
|
||||
('active', 'Active'),
|
||||
('paused', 'Paused'),
|
||||
('cancelled', 'Cancelled'),
|
||||
]
|
||||
|
||||
|
||||
class FusionRepairMaintenanceContract(models.Model):
|
||||
_name = 'fusion.repair.maintenance.contract'
|
||||
_description = 'Repair Maintenance Contract'
|
||||
_order = 'next_due_date, id'
|
||||
|
||||
name = fields.Char(string='Reference', required=True, default='New',
|
||||
copy=False, readonly=True)
|
||||
partner_id = fields.Many2one(
|
||||
'res.partner',
|
||||
string='Client',
|
||||
required=True,
|
||||
index=True,
|
||||
ondelete='restrict',
|
||||
)
|
||||
product_id = fields.Many2one(
|
||||
'product.product',
|
||||
string='Equipment',
|
||||
required=True,
|
||||
index=True,
|
||||
)
|
||||
lot_id = fields.Many2one('stock.lot', string='Serial Number')
|
||||
original_sale_order_id = fields.Many2one(
|
||||
'sale.order',
|
||||
string='Original Sale Order',
|
||||
index=True,
|
||||
)
|
||||
|
||||
interval_months = fields.Integer(string='Interval (months)', default=12, required=True)
|
||||
last_service_date = fields.Date(string='Last Service')
|
||||
next_due_date = fields.Date(string='Next Due', required=True, index=True)
|
||||
state = fields.Selection(CONTRACT_STATES, default='active', required=True,
|
||||
tracking=True, index=True)
|
||||
|
||||
booking_token = fields.Char(string='Booking Token', copy=False, index=True)
|
||||
last_reminder_band = fields.Selection(
|
||||
[('30', '30 days'), ('7', '7 days'), ('1', '1 day')],
|
||||
string='Last Reminder Sent',
|
||||
copy=False,
|
||||
help='The most recent reminder band sent for the current cycle.',
|
||||
)
|
||||
booking_repair_id = fields.Many2one(
|
||||
'repair.order',
|
||||
string='Booked Repair',
|
||||
copy=False,
|
||||
help='The repair.order created when the client used the booking link for this cycle.',
|
||||
)
|
||||
|
||||
company_id = fields.Many2one(
|
||||
'res.company', default=lambda self: self.env.company,
|
||||
)
|
||||
|
||||
_inherit = ['mail.thread']
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if vals.get('name', 'New') == 'New':
|
||||
vals['name'] = self.env['ir.sequence'].next_by_code(
|
||||
'fusion.repair.maintenance.contract'
|
||||
) or 'MC/NEW'
|
||||
if not vals.get('booking_token'):
|
||||
vals['booking_token'] = secrets.token_urlsafe(20)
|
||||
return super().create(vals_list)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# ROLL FORWARD
|
||||
# ------------------------------------------------------------------
|
||||
def roll_next_due_date(self):
|
||||
"""Advance next_due_date by interval_months and reset cycle state."""
|
||||
for c in self:
|
||||
base = c.last_service_date or fields.Date.context_today(c)
|
||||
c.next_due_date = base + timedelta(days=(c.interval_months or 12) * 30)
|
||||
c.last_reminder_band = False
|
||||
c.booking_repair_id = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# REMINDER CRON
|
||||
# ------------------------------------------------------------------
|
||||
@api.model
|
||||
def cron_send_due_reminders(self):
|
||||
"""Daily cron - send reminder emails at 30/7/1 days before next_due_date."""
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
if ICP.get_param('fusion_repairs.enable_email_notifications', 'True') != 'True':
|
||||
return
|
||||
today = fields.Date.context_today(self)
|
||||
bands = [
|
||||
('30', 30),
|
||||
('7', 7),
|
||||
('1', 1),
|
||||
]
|
||||
tpl = self.env.ref(
|
||||
'fusion_repairs.email_template_maintenance_due_reminder',
|
||||
raise_if_not_found=False,
|
||||
)
|
||||
if not tpl:
|
||||
return
|
||||
for band_label, days in bands:
|
||||
target = today + timedelta(days=days)
|
||||
domain = [
|
||||
('state', '=', 'active'),
|
||||
('next_due_date', '=', target),
|
||||
('partner_id.email', '!=', False),
|
||||
]
|
||||
# Don't re-send a smaller band if we already sent a larger one
|
||||
# for the same cycle - the band order is 30 -> 7 -> 1.
|
||||
contracts = self.search(domain)
|
||||
for c in contracts:
|
||||
if c.last_reminder_band == band_label:
|
||||
continue
|
||||
try:
|
||||
tpl.send_mail(c.id, force_send=False)
|
||||
c.last_reminder_band = band_label
|
||||
c.message_post(
|
||||
body=_('Sent %(band)s-day maintenance reminder to %(email)s.',
|
||||
band=band_label,
|
||||
email=c.partner_id.email),
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PORTAL BOOKING
|
||||
# ------------------------------------------------------------------
|
||||
def create_repair_from_booking(self, scheduled_date=None):
|
||||
"""Spawn a repair.order from the booking link (or any manual booking)."""
|
||||
self.ensure_one()
|
||||
if self.booking_repair_id and self.booking_repair_id.state != 'cancel':
|
||||
return self.booking_repair_id
|
||||
Repair = self.env['repair.order'].sudo()
|
||||
repair = Repair.create({
|
||||
'partner_id': self.partner_id.id,
|
||||
'product_id': self.product_id.id,
|
||||
'lot_id': self.lot_id.id if self.lot_id else False,
|
||||
'schedule_date': scheduled_date or fields.Datetime.now(),
|
||||
'x_fc_intake_source': 'client_portal',
|
||||
'x_fc_urgency': 'normal',
|
||||
'x_fc_repair_category_id': self.product_id.product_tmpl_id.x_fc_repair_category_id.id
|
||||
if self.product_id.product_tmpl_id.x_fc_repair_category_id else False,
|
||||
'internal_notes':
|
||||
f'<p>Maintenance visit booked from reminder for contract <b>{self.name}</b>.</p>',
|
||||
})
|
||||
self.booking_repair_id = repair
|
||||
self.message_post(
|
||||
body=_('Maintenance visit <b>%(ref)s</b> booked from reminder link.',
|
||||
ref=repair.name),
|
||||
)
|
||||
return repair
|
||||
|
||||
|
||||
class SaleOrder(models.Model):
|
||||
_inherit = 'sale.order'
|
||||
|
||||
def _spawn_maintenance_contracts(self):
|
||||
"""Create maintenance contracts for any delivered SO line whose
|
||||
product has x_fc_maintenance_interval_months > 0."""
|
||||
Contract = self.env['fusion.repair.maintenance.contract'].sudo()
|
||||
today = fields.Date.context_today(self)
|
||||
for so in self:
|
||||
if so.state not in ('sale', 'done'):
|
||||
continue
|
||||
for line in so.order_line:
|
||||
product = line.product_id
|
||||
if not product:
|
||||
continue
|
||||
interval = product.product_tmpl_id.x_fc_maintenance_interval_months or 0
|
||||
if interval <= 0:
|
||||
continue
|
||||
existing = Contract.search([
|
||||
('partner_id', '=', so.partner_id.id),
|
||||
('product_id', '=', product.id),
|
||||
('original_sale_order_id', '=', so.id),
|
||||
], limit=1)
|
||||
if existing:
|
||||
continue
|
||||
Contract.create({
|
||||
'partner_id': so.partner_id.id,
|
||||
'product_id': product.id,
|
||||
'original_sale_order_id': so.id,
|
||||
'interval_months': interval,
|
||||
'next_due_date': today + timedelta(days=interval * 30),
|
||||
'state': 'active',
|
||||
})
|
||||
@@ -16,3 +16,6 @@ access_repair_warranty_user,Warranty User Read,model_fusion_repair_warranty_cove
|
||||
access_repair_warranty_manager,Warranty Manager Full,model_fusion_repair_warranty_coverage,group_fusion_repairs_manager,1,1,1,1
|
||||
access_repair_visit_report_wizard_user,Visit Report Wizard User,model_fusion_repair_visit_report_wizard,group_fusion_repairs_user,1,1,1,1
|
||||
access_repair_visit_report_wizard_line_user,Visit Report Line User,model_fusion_repair_visit_report_wizard_line,group_fusion_repairs_user,1,1,1,1
|
||||
access_repair_maintenance_user,Maintenance Contract User Read,model_fusion_repair_maintenance_contract,group_fusion_repairs_user,1,0,0,0
|
||||
access_repair_maintenance_dispatcher,Maintenance Contract Dispatcher,model_fusion_repair_maintenance_contract,group_fusion_repairs_dispatcher,1,1,1,0
|
||||
access_repair_maintenance_manager,Maintenance Contract Manager Full,model_fusion_repair_maintenance_contract,group_fusion_repairs_manager,1,1,1,1
|
||||
|
||||
|
67
fusion_repairs/views/maintenance_contract_views.xml
Normal file
67
fusion_repairs/views/maintenance_contract_views.xml
Normal file
@@ -0,0 +1,67 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_maintenance_contract_list" model="ir.ui.view">
|
||||
<field name="name">fusion.repair.maintenance.contract.list</field>
|
||||
<field name="model">fusion.repair.maintenance.contract</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Maintenance Contracts" decoration-warning="next_due_date and next_due_date <= context_today().strftime('%Y-%m-%d')">
|
||||
<field name="name"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="product_id"/>
|
||||
<field name="interval_months"/>
|
||||
<field name="last_service_date"/>
|
||||
<field name="next_due_date"/>
|
||||
<field name="last_reminder_band" optional="show"/>
|
||||
<field name="state" widget="badge"
|
||||
decoration-success="state == 'active'"
|
||||
decoration-muted="state == 'cancelled'"
|
||||
decoration-warning="state == 'paused'"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_maintenance_contract_form" model="ir.ui.view">
|
||||
<field name="name">fusion.repair.maintenance.contract.form</field>
|
||||
<field name="model">fusion.repair.maintenance.contract</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Maintenance Contract">
|
||||
<header>
|
||||
<field name="state" widget="statusbar"
|
||||
statusbar_visible="draft,active,paused,cancelled"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1>
|
||||
<field name="name" readonly="1"/>
|
||||
</h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="partner_id" options="{'no_create': True}"/>
|
||||
<field name="product_id"/>
|
||||
<field name="lot_id"/>
|
||||
<field name="original_sale_order_id" readonly="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="interval_months"/>
|
||||
<field name="last_service_date"/>
|
||||
<field name="next_due_date"/>
|
||||
<field name="last_reminder_band" readonly="1"/>
|
||||
<field name="booking_repair_id" readonly="1"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
<chatter/>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_maintenance_contract" model="ir.actions.act_window">
|
||||
<field name="name">Maintenance Contracts</field>
|
||||
<field name="res_model">fusion.repair.maintenance.contract</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -26,6 +26,12 @@
|
||||
action="repair.action_repair_order_tree"
|
||||
sequence="20"/>
|
||||
|
||||
<menuitem id="menu_fusion_repairs_maintenance_contracts"
|
||||
name="Maintenance Contracts"
|
||||
parent="menu_fusion_repairs_operations"
|
||||
action="action_maintenance_contract"
|
||||
sequence="30"/>
|
||||
|
||||
<!-- Configuration -->
|
||||
<menuitem id="menu_fusion_repairs_configuration"
|
||||
name="Configuration"
|
||||
|
||||
108
fusion_repairs/views/portal_maintenance_templates.xml
Normal file
108
fusion_repairs/views/portal_maintenance_templates.xml
Normal file
@@ -0,0 +1,108 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- ============================================================== -->
|
||||
<!-- Booking landing page -->
|
||||
<!-- ============================================================== -->
|
||||
<template id="portal_maintenance_book" name="Maintenance - Book Visit">
|
||||
<t t-call="website.layout">
|
||||
<div id="wrap" class="o_fusion_repairs_client">
|
||||
<section class="container py-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-lg-7">
|
||||
|
||||
<h1 class="mb-3">Book your maintenance visit</h1>
|
||||
<p class="lead text-muted">
|
||||
Hello <strong t-out="contract.partner_id.name"/>!
|
||||
Your <strong t-out="contract.product_id.display_name"/> is due for service.
|
||||
</p>
|
||||
|
||||
<t t-if="already_booked">
|
||||
<div class="alert alert-info">
|
||||
<strong>Already booked.</strong>
|
||||
Your maintenance visit reference is
|
||||
<strong t-out="contract.booking_repair_id.name"/>. We will be in touch shortly.
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-if="not already_booked">
|
||||
<form t-attf-action="/repairs/maintenance/book/{{ contract.booking_token }}/confirm"
|
||||
method="POST" class="card shadow-sm">
|
||||
<input type="hidden" name="csrf_token"
|
||||
t-att-value="request.csrf_token()"/>
|
||||
<div class="card-body p-4">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Preferred date</label>
|
||||
<input type="date" name="preferred_date"
|
||||
class="form-control form-control-lg"
|
||||
t-att-value="default_date"/>
|
||||
<small class="text-muted">
|
||||
A team member will call to confirm the exact time.
|
||||
</small>
|
||||
</div>
|
||||
<p class="small text-muted mb-0">
|
||||
By submitting, you confirm you want this maintenance visit.
|
||||
Contract reference <strong t-out="contract.name"/>.
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-footer text-end">
|
||||
<button type="submit" class="btn btn-success btn-lg">
|
||||
Yes, book my visit
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</t>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- ============================================================== -->
|
||||
<!-- Thanks -->
|
||||
<!-- ============================================================== -->
|
||||
<template id="portal_maintenance_thanks" name="Maintenance - Booked">
|
||||
<t t-call="website.layout">
|
||||
<div id="wrap" class="o_fusion_repairs_client">
|
||||
<section class="container py-5">
|
||||
<div class="row justify-content-center text-center">
|
||||
<div class="col-12 col-lg-7">
|
||||
<i class="fa fa-calendar-check fa-4x text-success mb-3"/>
|
||||
<h1>Booking received</h1>
|
||||
<p class="lead text-muted">
|
||||
Your maintenance visit <strong t-out="repair.name"/> has been scheduled
|
||||
and our office will reach out to confirm the exact time.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- ============================================================== -->
|
||||
<!-- Invalid token -->
|
||||
<!-- ============================================================== -->
|
||||
<template id="portal_maintenance_invalid_token" name="Maintenance - Invalid Link">
|
||||
<t t-call="website.layout">
|
||||
<div id="wrap" class="o_fusion_repairs_client">
|
||||
<section class="container py-5">
|
||||
<div class="row justify-content-center text-center">
|
||||
<div class="col-12 col-lg-7">
|
||||
<i class="fa fa-exclamation-triangle fa-3x text-warning mb-3"/>
|
||||
<h1>Link not valid</h1>
|
||||
<p class="lead text-muted">
|
||||
This booking link is no longer valid or has been used. Please call our
|
||||
office directly to schedule your maintenance visit.
|
||||
</p>
|
||||
<a href="/repair" class="btn btn-outline-secondary">Submit a service request</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user