The dashboard root used min-height: calc(100vh - 46px) which expanded
to the viewport but bypassed the parent .o_action_manager flex sizing,
so the inner overflow-y: auto had nothing to scroll - vertical content
was clipped or stuck.
Replaced with height: 100% + overflow-y: auto + overflow-x: hidden so
the component fills its action container and scrolls naturally. Bumped
to 19.0.1.0.6 to bust the asset bundle hash.
Co-authored-by: Cursor <cursoragent@cursor.com>
A real landing dashboard for the Fusion Repairs app so users see at a
glance what is open, what is urgent, and where to click. Built as an
OWL client action, theme-aware (light AND dark) at SCSS compile time,
zero hardcoded user-facing colours.
What's on it
- Hero banner with gradient accent
- 4 quick-action tiles (New Service Call, Service Calls, Maintenance
Contracts, Repair Warranties)
- 6 KPI stat tiles (Open / Urgent+Safety / Awaiting Dispatch /
Needs Re-Quote / New This Month / Maintenance Due 30d) - each is
clickable and lands in the right filtered list
- Self-service portal cards with copy-to-clipboard for the public
client portal URL and the sales rep portal URL (so office can
share them on voicemail / printed materials / training)
- Recent Service Calls list (last 5) - click jumps to repair form
- Upcoming Maintenance list (next 5 due) - red pill when <=7 days out
- Configuration tiles (Equipment Categories / Intake Templates /
Service Catalogue)
- Refresh button
Architecture
- fusion.repair.dashboard AbstractModel exposes get_dashboard_data():
returns stats + urgency_breakdown + source_breakdown + recent[5] +
upcoming[5] + portals (URLs resolved via web.base.url +
fusion_repairs.client_portal_url)
- FusionRepairsDashboard OWL component (registry actions
'fusion_repairs.dashboard') uses standalone rpc() per project rule
#3, useService('action') for navigation, useService('notification')
for copy feedback. static props = ['*'] to accept the client-action
props envelope.
- _fr_tokens.scss registered FIRST in web.assets_backend so its
variables are in scope when dashboard.scss compiles. NO @import (per
project rule). Branches on $o-webclient-color-scheme at compile time
so the dark bundle (web.assets_web_dark) gets dark hex values
automatically - per project CLAUDE.md rule on dark mode.
- All visible colours come from CSS-variable-wrapped SCSS tokens
(--fr-page-bg, --fr-card-bg, --fr-border, --fr-accent, ...) which
fall back to the SCSS hex value. Three-layer contrast: page (grayest)
-> card (mid) -> elevated (brightest).
- New ir.actions.client action_fusion_repairs_home_dashboard with
tag='fusion_repairs.dashboard'.
- Top-level menu now lands on this dashboard. 'Dashboard' added as
the first sub-menu; 'Service Calls' (the kanban) is still right
below it.
Verified on local westin-v19:
STATS: open=15, urgent=4, new_this_month=13, awaiting_dispatch=9,
requires_requote=1, maintenance_due_30d=1, active_total=2
PORTALS: client=http://192.168.139.165:8069/repair
sales_rep=http://192.168.139.165:8069/my/repair/new
RECENT count: 5
UPCOMING count: 2
SOURCE breakdown: backend_wizard 9, client_portal 3, manual 2, sales_rep_portal 1
Web /web/login: 200, no SCSS compile errors in logs.
Bumped to 19.0.1.0.5 so the asset bundle hash refreshes.
Co-authored-by: Cursor <cursoragent@cursor.com>
Replaced the picking-type default reference (BR-WA/RO/00010) with a
date-based monthly-resetting sequence: RO-202605-01, RO-202605-02, ...
where YYYY is the year and MM is the zero-padded month. The counter
resets to 01 every time the month rolls over.
Implementation:
- New ir.sequence 'fusion.repair.order.monthly' with prefix
'RO-%(year)s%(month)s-', padding=2, use_date_range=True (Odoo creates
one ir.sequence.date_range per month, each with its own number_next)
- repair.order.create() override pre-fills vals['name'] with the new
sequence BEFORE super(), so Odoo's native picking-type sequence
assignment (which only fires when name is empty / 'New') is bypassed
Verified on local westin-v19: three back-to-back creates produced
RO-202605-01 / -02 / -03. Existing records (pre-upgrade) keep their
old BR-WA/RO/##### references - this only affects repairs created
from this version onward.
Bumped to 19.0.1.0.4.
Co-authored-by: Cursor <cursoragent@cursor.com>
Reports of literal '<b>Client Self-Service</b>' showing in the chatter
instead of bold formatting. Cause: message_post(body=str) HTML-escapes
the string. The Odoo idiom for HTML chatter bodies is markupsafe.Markup,
with the % operator auto-escaping substitution values for XSS safety.
Fixed every message_post call:
models/intake_service.py
- 'Service call submitted via <b>...</b>' (the reported one)
- 'This repair MAY be covered by our active warranty <b>...</b>'
models/maintenance_contract.py
- 'Sent N-day maintenance reminder to <email>'
- 'Maintenance visit <b>...</b> booked from reminder link'
models/technician_task.py
- 'Rolled forward after maintenance task <b>...</b> completed'
wizard/repair_visit_report_wizard.py
- 'Spawned follow-up repair <b>...</b> for "found another issue"'
Pattern used: Markup(_('... <b>%(x)s</b> ...')) % {'x': escaped_value}.
Verified on local westin-v19 (BR-WA/RO/00026): DB row now reads
'<p>Service call submitted via <b>Client Self-Service</b> by Gurpreet
Singh. Session reference: RIS000015.</p>' which renders correctly in
the chatter UI.
Bumped to 19.0.1.0.3.
Co-authored-by: Cursor <cursoragent@cursor.com>
Two complaints from the first hands-on test:
1) Submit button raised "Access Error (Document type: Activity,
Operation: create)" - the wizard called the intake service WITHOUT
sudo so the mail.activity records the service schedules tripped on
the activity ACL (admin's group chain does not auto-grant activity
create on repair.order without sudo). Both portal controllers
already sudo'd; the wizard now does too. x_fc_intake_user_id
preserves audit identity regardless.
Verified end-to-end as gsingh@westinhealthcare.com (admin):
Created: BR-WA/RO/00025
Activities: 2
Source: backend_wizard
Intake user: gsingh@westinhealthcare.com
2) "Real dashboard with dedicated pages would have been nice" - the
main menu opened the wizard directly as a modal. Restructured so
the menu lands on a proper kanban dashboard of service calls,
matching the standard Odoo app pattern:
Fusion Repairs (app icon)
- Service Calls <- dashboard kanban (default landing)
- New Service Call <- wizard (still a modal, accessed from menu OR kanban's New button)
- All Repair Orders <- native Odoo repair list (full backend)
- Maintenance Contracts
- Configuration
- Equipment Categories / Intake Templates / Service Catalogue / Repair Warranties
New view_fusion_repair_dashboard_kanban shows urgency badges (red /
amber / grey), category, scheduled date, intake source pill, and
a 3rd-party warning. Default group_by=state.
New view_fusion_repair_dashboard_search adds quick filters: Today,
This Week, Safety/Urgent, Third-Party, Open, plus per-source filters
and Group By (Status / Urgency / Category / Intake Source).
Wizard remains target='new' (modal) so submitting drops the user
back to the kanban they came from with the new repair visible.
Bumped version to 19.0.1.0.2 to bust the asset bundle hash.
Co-authored-by: Cursor <cursoragent@cursor.com>
Two related issues that hid the Fusion Repairs app from the Apps menu
for admin users:
1. Custom security groups don't auto-include admin
The Repairs User / Dispatcher / Manager groups are new custom groups.
Having base.group_user or base.group_system on its own does NOT grant
membership in custom child groups - implied chains only flow one way
(child -> parent). Admin therefore had no Repairs groups, so the
top-level "Fusion Repairs" menu (gated on group_fusion_repairs_user)
was hidden from them.
Fix: extend base.group_system with implied_ids that include
group_fusion_repairs_manager. Manager already implies Dispatcher
implies User, so admin (= base.group_system) now automatically gets
the whole chain on install / upgrade with no manual user editing.
Verified via odoo-shell:
admin.has_group('fusion_repairs.group_fusion_repairs_user') == True
admin.has_group('fusion_repairs.group_fusion_repairs_dispatcher') == True
admin.has_group('fusion_repairs.group_fusion_repairs_manager') == True
menu_fusion_repairs_root._filter_visible_menus() == ir.ui.menu(2735,)
2. Missing static/description/icon.png
The manifest referenced fusion_repairs,static/description/icon.png
via web_icon on the top-level menu but the file did not exist. Odoo
handles missing icons gracefully but the apps list ends up rendering
without a tile graphic. Copied fusion_tasks/static/description/icon.png
as a placeholder; replace with a custom asset whenever desired.
Verified: /fusion_repairs/static/description/icon.png returns
HTTP 200 with 43989 bytes after restart.
Bumped manifest version to 19.0.1.0.1 to bust the asset bundle hash so
clients pick up the new icon without a manual cache clear.
Co-authored-by: Cursor <cursoragent@cursor.com>
On the original purchase sale.order:
- Repairs button (fa-wrench) lists all repair.order records where
x_fc_original_sale_order_id = this SO
- Maintenance button (fa-calendar-check-o) lists all
fusion.repair.maintenance.contract records spawned from this SO
- Both auto-hide when count is zero
- Both gated by fusion_repairs.group_fusion_repairs_user
Follows the count + action_view_* + oe_stat_button / statinfo pattern
from fusion_claims/views/sale_order_views.xml line ~1176.
Co-authored-by: Cursor <cursoragent@cursor.com>
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>
Service catalogue
- New fusion.repair.service.catalog model: named service entries per
equipment category with symptom keywords, estimated hours / cost,
default parts, auto_schedule flag, optional pricelist override
- find_best_match() scores candidates by symptom-keyword overlap against
intake text hints (issue summary + category + notes)
- Intake service wires it in: on submit, the matcher sets
x_fc_service_catalog_id + x_fc_estimated_duration + x_fc_estimated_cost
and (when auto_schedule=True) creates a draft dispatch task
- Double-task guard: if catalogue match already created a task, the
urgency-based dispatch skips so we never duplicate
Visit report wizard
- fusion.repair.visit.report.wizard with labour hours + parts lines +
technician notes + 'found another issue' branch
- Computes actual cost = (labour x service_product.list_price) + parts
- Compares against estimate -> sets requires_requote when variance
exceeds configured threshold (% or $); shows warning banner inline
- On confirm: writes actuals back to repair, posts notes to chatter,
optionally spawns a follow-up repair (T5 'found another issue')
Repair warranty
- New fusion.repair.warranty.coverage model (start/expiry, partner,
product, lot, active flag)
- find_active_for(partner, product, lot) returns the most-recent active
coverage
- Intake service auto-checks: when a new repair lands on an equipment
that has active warranty coverage, posts a chatter banner so the
office knows the work may be free under our 30/90-day re-do policy
(manager review still required; never auto-zeros pricing)
Repair form
- Header: Visit Report + Collect Payment buttons (gated by group)
- action_collect_payment looks up the linked posted unpaid invoice on
the repair SO and opens the Poynt wizard (action_open_poynt_payment_wizard)
AI intake summary
- _generate_ai_summary calls self.env['fusion.api.service'].call_openai
with consumer='fusion_repairs', feature='intake_triage'
- Strict system prompt: no medical advice, no diagnoses, no recommending
stop equipment use; ~80 words; plain English
- Try/fallback per fusion-api-integration.mdc: if fusion_api not
installed or call fails -> silently skip; intake never blocked
Verified end-to-end on local westin-v19:
- Stairlift motor intake -> catalogue match -> estimated $500/2h -> auto
dispatch task (count=1, not duplicated)
- Visit report: 2.5h x $250 + $100 parts = $725 actual vs $500 estimated
= 45% variance -> requires_requote=True
- Warranty: 30-day coverage on the completed repair; second repair on
same partner triggers warranty banner in chatter
Co-authored-by: Cursor <cursoragent@cursor.com>
Both portals share the existing fusion.repair.intake.service so behaviour
stays identical across all three intake surfaces (backend wizard,
sales rep portal, public client portal).
Sales rep portal
- Hard depends on fusion_authorizer_portal (reuses is_sales_rep_portal
flag + group_sales_rep_portal scaffolding)
- /my/repair/new - mobile-friendly intake form with phone-first
partner search (jsonrpc lookup), category select, third-party flag,
urgency, photo capture
- /my/repairs - list of repairs the rep submitted (paginated)
- /my/repair/<id> - read-only detail with status, equipment, scheduled
visit
- Interaction-class JS (Odoo 19 public.interactions), safe DOM construction
- Mobile SCSS with 44px tap targets, sticky CTA on small screens
- Record rule scopes portal users to repairs where
x_fc_intake_user_id = user.id
Public client portal
- auth='public' - voicemail-ready /repair URL
- /repair - landing page with 911 disclaimer and Start CTA
- /repair/new - single-page form: contact, equipment, issue, urgency,
optional photos. QR pre-fill via ?sn=<serial>
- /repair/submit - CSRF + honeypot + per-IP rate limit (configurable);
finds or creates partner; calls intake service with sudo
- /repair/thanks - confirmation with reference number
- /repair/lookup_phone (jsonrpc) - safe partner match returning ONLY
masked name (first + last initial) + city (no other PII leakage)
Security fix: technician record rule on repair.order now uses STORED
fields (technician_id + additional_technician_ids) instead of the
non-stored all_technician_ids compute, which was failing SQL generation.
Verified end-to-end on local westin-v19:
- Sales rep create via intake service with the rep user context creates
the repair with x_fc_intake_source='sales_rep_portal' and proper
activities
- /repair/submit posts urlencoded data -> creates partner + repair
('BR-WA/RO/00010', source='client_portal', urgency='urgent') ->
redirects to /repair/thanks with the reference
Co-authored-by: Cursor <cursoragent@cursor.com>