From 56ca82c6110c40b8a37462ce02fb1ef5b88f7e30 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Thu, 4 Jun 2026 01:16:29 -0400 Subject: [PATCH] feat(fusion_claims): OWL service-booking wizard + dark/light SCSS Co-Authored-By: Claude Opus 4.8 (1M context) --- fusion_claims/__manifest__.py | 8 + .../src/js/service_booking/service_booking.js | 102 +++++++ .../src/scss/_service_booking_tokens.scss | 73 +++++ .../static/src/scss/service_booking.scss | 283 ++++++++++++++++++ .../static/src/xml/service_booking.xml | 208 +++++++++++++ 5 files changed, 674 insertions(+) create mode 100644 fusion_claims/static/src/js/service_booking/service_booking.js create mode 100644 fusion_claims/static/src/scss/_service_booking_tokens.scss create mode 100644 fusion_claims/static/src/scss/service_booking.scss create mode 100644 fusion_claims/static/src/xml/service_booking.xml diff --git a/fusion_claims/__manifest__.py b/fusion_claims/__manifest__.py index 919a0a1c..aff5ea33 100644 --- a/fusion_claims/__manifest__.py +++ b/fusion_claims/__manifest__.py @@ -184,12 +184,20 @@ # Dashboard OWL countdown widget 'fusion_claims/static/src/js/fc_posting_countdown.js', 'fusion_claims/static/src/xml/fc_posting_countdown.xml', + # Service Booking wizard (client action): tokens MUST load before + # the component scss so the --sb-* vars resolve. + 'fusion_claims/static/src/scss/_service_booking_tokens.scss', + 'fusion_claims/static/src/scss/service_booking.scss', + 'fusion_claims/static/src/js/service_booking/service_booking.js', + 'fusion_claims/static/src/xml/service_booking.xml', ], 'web.assets_web_dark': [ # Dark bundle recompiles the same SCSS with the dark # $o-webclient-color-scheme default so tokens branch correctly. 'fusion_claims/static/src/scss/_fc_dashboard_tokens.scss', 'fusion_claims/static/src/scss/fc_dashboard.scss', + 'fusion_claims/static/src/scss/_service_booking_tokens.scss', + 'fusion_claims/static/src/scss/service_booking.scss', ], }, 'images': ['static/description/icon.png'], diff --git a/fusion_claims/static/src/js/service_booking/service_booking.js b/fusion_claims/static/src/js/service_booking/service_booking.js new file mode 100644 index 00000000..f5a4b861 --- /dev/null +++ b/fusion_claims/static/src/js/service_booking/service_booking.js @@ -0,0 +1,102 @@ +/** @odoo-module **/ +import { Component, useState, onWillStart } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { rpc } from "@web/core/network/rpc"; +import { useService } from "@web/core/utils/hooks"; + +export class ServiceBookingWizard extends Component { + static template = "fusion_claims.ServiceBookingWizard"; + static props = ["*"]; + + setup() { + this.action = useService("action"); + this.notification = useService("notification"); + this.state = useState({ + custMode: "existing", + customer: { name: "", phone: "", email: "", street: "", unit: "", buzz: "", city: "" }, + partnerId: false, soSearch: "", + device: "standard", category: "standard", timing: "normal", inShop: false, issue: "", + date: "", hour: 9, minute: 0, ampm: "AM", durationHr: 1.0, technicianId: false, + warranty: false, pod: false, emailConfirm: true, googleReview: true, + description: "", materials: "", + technicians: [], calloutRates: [], perKm: 0.70, + labour: { onsite: 85, inshop: 75, lift: 110 }, distanceKm: 13, saving: false, + }); + onWillStart(async () => { + const r = await rpc("/fusion_claims/service_booking/refdata", {}); + Object.assign(this.state, { + technicians: r.technicians, calloutRates: r.callout_rates, + perKm: r.per_km, labour: r.labour, + }); + }); + } + get callout() { + if (this.state.inShop) return null; + return this.state.calloutRates.find( + r => r.category === this.state.category && r.timing === this.state.timing) || null; + } + get labourRate() { + if (this.state.inShop) return this.state.labour.inshop; + return this.state.category === "lift" ? this.state.labour.lift : this.state.labour.onsite; + } + get estimate() { + const c = this.callout; + const callout = c ? c.price : 0; + const incl = (c && !c.adds_per_km) ? 0.5 : 0; + const billHr = Math.max(0, this.state.durationHr - incl); + const labour = billHr * this.labourRate; + const km = (c && c.adds_per_km) ? this.state.distanceKm * 2 * this.state.perKm : 0; + return { callout, labour, billHr, km, total: callout + labour + km, addsKm: !!(c && c.adds_per_km) }; + } + get endLabel() { + let h = (this.state.hour % 12) + (this.state.ampm === "PM" ? 12 : 0); + let m = h * 60 + this.state.minute + this.state.durationHr * 60; + let eh = Math.floor(m / 60) % 24, em = m % 60, ap = eh >= 12 ? "PM" : "AM"; + return `${eh % 12 || 12}:${String(em).padStart(2, "0")} ${ap}`; + } + fmt(n) { return (n || 0).toFixed(2); } + onDevice(ev) { + this.state.device = ev.target.value; + this.state.category = ev.target.value === "lift" ? "lift" : "standard"; + } + setCust(m) { this.state.custMode = m; } + setTiming(t) { this.state.timing = t; } + setAmpm(v) { this.state.ampm = v; } + toggleInShop() { this.state.inShop = !this.state.inShop; } + _timeStartFloat() { return (this.state.hour % 12) + (this.state.ampm === "PM" ? 12 : 0) + this.state.minute / 60; } + + async submit() { + if (this.state.saving) return; + const s = this.state; + if (s.custMode === "new" && (!s.customer.name || !s.customer.phone)) { + this.notification.add("Client name and phone are required.", { type: "danger" }); + return; + } + if (!s.technicianId) { + this.notification.add("Please choose a technician.", { type: "danger" }); + return; + } + s.saving = true; + const payload = { + cust_mode: s.custMode, customer: s.customer, partner_id: s.partnerId, so_search: s.soSearch, + category: s.category, timing: s.timing, in_shop: s.inShop, device: s.device, issue: s.issue, + date: s.date, time_start: this._timeStartFloat(), duration_hr: s.durationHr, + technician_id: s.technicianId, warranty: s.warranty, pod: s.pod, + email_confirm: s.emailConfirm, google_review: s.googleReview, + description: s.description, materials: s.materials, + }; + try { + const res = await rpc("/fusion_claims/service_booking/submit", { payload }); + if (res.error) { this.notification.add(res.error, { type: "danger" }); s.saving = false; return; } + this.notification.add("Service booked — draft repair SO created.", { type: "success" }); + this.action.doAction({ + type: "ir.actions.act_window", res_model: "fusion.technician.task", + res_id: res.task_id, views: [[false, "form"]], target: "current", + }); + } catch (e) { + this.notification.add("Booking failed: " + (e.message || e), { type: "danger" }); + s.saving = false; + } + } +} +registry.category("actions").add("fusion_claims.service_booking", ServiceBookingWizard); diff --git a/fusion_claims/static/src/scss/_service_booking_tokens.scss b/fusion_claims/static/src/scss/_service_booking_tokens.scss new file mode 100644 index 00000000..ca5cc065 --- /dev/null +++ b/fusion_claims/static/src/scss/_service_booking_tokens.scss @@ -0,0 +1,73 @@ +// Fusion Claims — Service Booking wizard design tokens. +// +// Per the repo dark-mode rule (CLAUDE.md "Dark Mode — Branch on +// $o-webclient-color-scheme at SCSS Compile Time"): this file is compiled into +// BOTH web.assets_backend (bright) and web.assets_web_dark (dark). We branch at +// COMPILE TIME on $o-webclient-color-scheme and emit one --sb-* CSS custom +// property per token, scoped to .o_service_booking. Do NOT use .o_dark_mode / +// [data-bs-theme] / prefers-color-scheme — none fire reliably in Odoo 19. +// +// Values are copied verbatim from the mockup's :root (light) and +// [data-theme="dark"] (dark) blocks — technician-booking-wizard.html. + +$o-webclient-color-scheme: bright !default; + +// --- light values (mockup :root / [data-theme="light"]) --- +$_page: #eef0f3; +$_panel: #e6e9ed; +$_card: #ffffff; +$_border: #d8dadd; +$_text: #1f2430; +$_muted: #6b7280; +$_faint: #9ca3af; +$_field: #ffffff; +$_field-border: #cfd3d8; +$_field-focus: #3a8fb7; +$_chip: #f1f4f7; +$_accent: #2e7aad; +$_accent-soft: #e8f2f8; +$_ok: #16a34a; +$_star: #f5b301; +$_money: #0f7d4e; +$_money-soft: #e7f6ee; + +@if $o-webclient-color-scheme == dark { + // --- dark values (mockup [data-theme="dark"]) --- + $_page: #14161b !global; + $_panel: #1b1e24 !global; + $_card: #22262d !global; + $_border: #343a42 !global; + $_text: #e7eaef !global; + $_muted: #9aa3af !global; + $_faint: #6b7480 !global; + $_field: #1a1d23 !global; + $_field-border: #3a4049 !global; + $_field-focus: #4aa3cf !global; + $_chip: #2a2f37 !global; + $_accent: #3a8fb7 !global; + $_accent-soft: #19303d !global; + $_ok: #22c55e !global; + $_star: #f5b301 !global; + $_money: #34d27f !global; + $_money-soft: #15281f !global; +} + +.o_service_booking { + --sb-page: #{$_page}; + --sb-panel: #{$_panel}; + --sb-card: #{$_card}; + --sb-border: #{$_border}; + --sb-text: #{$_text}; + --sb-muted: #{$_muted}; + --sb-faint: #{$_faint}; + --sb-field: #{$_field}; + --sb-field-border: #{$_field-border}; + --sb-field-focus: #{$_field-focus}; + --sb-chip: #{$_chip}; + --sb-accent: #{$_accent}; + --sb-accent-soft: #{$_accent-soft}; + --sb-ok: #{$_ok}; + --sb-star: #{$_star}; + --sb-money: #{$_money}; + --sb-money-soft: #{$_money-soft}; +} diff --git a/fusion_claims/static/src/scss/service_booking.scss b/fusion_claims/static/src/scss/service_booking.scss new file mode 100644 index 00000000..74b3e00f --- /dev/null +++ b/fusion_claims/static/src/scss/service_booking.scss @@ -0,0 +1,283 @@ +// Fusion Claims — Service Booking wizard component styles. +// +// Ported from the mockup (technician-booking-wizard.html) scoped under +// .o_service_booking. The mockup's CSS custom properties (--page, --card, …) +// are renamed mechanically to the --sb-* tokens emitted by +// _service_booking_tokens.scss (which MUST load first in the bundle). The +// manual .theme-btn dark toggle is dropped — Odoo serves the dark bundle. +// +// Surfaces use the explicit-hex tokens (three-layer contrast: page -> card -> +// field), never var(--bs-*). color-mix() is used only in standalone +// background / box-shadow properties — never inside a border shorthand (the +// Odoo 19 SCSS compiler silently drops color-mix in border shorthands). + +.o_service_booking { + background: var(--sb-page); + color: var(--sb-text); + font-family: 'Inter', 'Helvetica Neue', Helvetica, Arial, system-ui, sans-serif; + font-size: 14px; + min-height: 100%; + overflow: auto; + + * { box-sizing: border-box; } + + .wrap { max-width: 1000px; margin: 24px auto; padding: 0 18px; } + + .dialog { + background: var(--sb-panel); + border: 1px solid var(--sb-border); + border-radius: 16px; + box-shadow: 0 12px 40px rgba(16, 24, 40, .16); + overflow: hidden; + } + + .topbar { + background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%); + padding: 17px 24px; + display: flex; + align-items: center; + justify-content: space-between; + color: #fff; + + h1 { font-size: 19px; font-weight: 700; margin: 0; } + .sub { font-size: 12.5px; opacity: .9; margin-top: 2px; } + } + + .stepper { + display: flex; + gap: 6px; + padding: 11px 24px; + background: var(--sb-panel); + border-bottom: 1px solid var(--sb-border); + flex-wrap: wrap; + } + .step { + font-size: 11.5px; + font-weight: 600; + color: var(--sb-faint); + padding: 5px 13px; + border-radius: 20px; + background: var(--sb-chip); + } + .step.active { color: #fff; background: linear-gradient(135deg, #3a8fb7, #2e7aad); } + .step.draft { margin-left: auto; color: var(--sb-money); background: var(--sb-money-soft); } + + .body { padding: 20px 24px 6px; } + .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } + @media (max-width: 780px) { .grid { grid-template-columns: 1fr; } } + + .card { + background: var(--sb-card); + border: 1px solid var(--sb-border); + border-radius: 13px; + padding: 16px 17px; + box-shadow: 0 1px 3px rgba(16, 24, 40, .08), 0 1px 2px rgba(16, 24, 40, .06); + } + .card.span2 { grid-column: 1 / -1; } + .card h3 { + margin: 0 0 13px; + font-size: 11.5px; + font-weight: 700; + letter-spacing: .7px; + text-transform: uppercase; + color: var(--sb-muted); + display: flex; + align-items: center; + gap: 7px; + } + .card h3 .dot { width: 7px; height: 7px; border-radius: 50%; background: linear-gradient(135deg, #5ba848, #2e7aad); } + .card h3 .tag { + margin-left: auto; + font-size: 10px; + font-weight: 700; + color: var(--sb-money); + background: var(--sb-money-soft); + padding: 2px 8px; + border-radius: 10px; + letter-spacing: .3px; + } + + label.fl { display: block; font-size: 12px; font-weight: 600; color: var(--sb-muted); margin: 0 0 5px; } + .row { margin-bottom: 12px; } + .row:last-child { margin-bottom: 0; } + .two { display: grid; grid-template-columns: 1fr 1fr; gap: 11px; } + .three { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 9px; } + + input.f, select.f, textarea.f { + width: 100%; + background: var(--sb-field); + color: var(--sb-text); + border: 1px solid var(--sb-field-border); + border-radius: 9px; + padding: 9px 11px; + font-size: 13.5px; + font-family: inherit; + outline: none; + transition: border .15s, box-shadow .15s; + } + input.f:focus, select.f:focus, textarea.f:focus { + border-color: var(--sb-field-focus); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--sb-field-focus) 22%, transparent); + } + textarea.f { resize: vertical; min-height: 56px; } + + .hint { font-size: 11px; color: var(--sb-faint); margin-top: 5px; } + .with-icon { position: relative; } + .with-icon .pin { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); color: #5ba848; font-size: 16px; } + + .seg { + display: inline-flex; + background: var(--sb-chip); + border: 1px solid var(--sb-border); + border-radius: 9px; + padding: 3px; + gap: 3px; + } + .seg button { + border: none; + background: transparent; + color: var(--sb-muted); + font-weight: 600; + font-size: 12.5px; + padding: 6px 14px; + border-radius: 7px; + cursor: pointer; + font-family: inherit; + } + .seg button.on { background: var(--sb-card); color: var(--sb-accent); box-shadow: 0 1px 3px rgba(16, 24, 40, .08), 0 1px 2px rgba(16, 24, 40, .06); } + .seg.full { display: flex; } + .seg.full button { flex: 1; } + + .timepick { display: inline-flex; align-items: stretch; gap: 7px; } + .timepick select.f { width: auto; padding-right: 24px; } + .ampm { display: inline-flex; background: var(--sb-chip); border: 1px solid var(--sb-border); border-radius: 9px; padding: 3px; } + .ampm button { + border: none; + background: transparent; + color: var(--sb-muted); + font-weight: 700; + font-size: 12px; + padding: 6px 12px; + border-radius: 7px; + cursor: pointer; + font-family: inherit; + } + .ampm button.on { background: var(--sb-accent); color: #fff; } + .endtime { font-size: 13px; color: var(--sb-muted); margin-top: 7px; } + .endtime b { color: var(--sb-text); } + + .avail { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11.5px; + font-weight: 600; + color: var(--sb-ok); + background: color-mix(in srgb, var(--sb-ok) 14%, transparent); + padding: 3px 9px; + border-radius: 20px; + margin-top: 6px; + } + + .opt { + display: flex; + align-items: center; + justify-content: space-between; + padding: 9px 0; + border-bottom: 1px solid var(--sb-border); + } + .opt:last-child { border-bottom: none; } + .opt .lab { font-size: 13.5px; font-weight: 500; } + .opt .lab small { display: block; color: var(--sb-faint); font-weight: 400; font-size: 11.5px; } + + .sw { + width: 42px; + height: 24px; + border-radius: 20px; + background: var(--sb-field-border); + position: relative; + cursor: pointer; + transition: background .15s; + flex-shrink: 0; + } + .sw::after { + content: ''; + position: absolute; + width: 18px; + height: 18px; + border-radius: 50%; + background: #fff; + top: 3px; + left: 3px; + transition: left .15s; + box-shadow: 0 1px 2px rgba(0, 0, 0, .3); + } + .sw.on { background: var(--sb-ok); } + .sw.on::after { left: 21px; } + + // fee readout inside Service & Pricing + .feeline { + display: flex; + align-items: center; + justify-content: space-between; + background: var(--sb-money-soft); + border: 1px solid color-mix(in srgb, var(--sb-money) 35%, transparent); + border-radius: 10px; + padding: 11px 14px; + margin-top: 4px; + } + .feeline .lbl { font-size: 12.5px; font-weight: 600; color: var(--sb-text); } + .feeline .lbl small { display: block; color: var(--sb-faint); font-weight: 400; font-size: 11px; } + .feeline .amt { font-size: 20px; font-weight: 800; color: var(--sb-money); } + + // ESTIMATE strip + .estimate { + grid-column: 1 / -1; + background: var(--sb-money-soft); + border: 1px solid color-mix(in srgb, var(--sb-money) 40%, transparent); + border-left: 5px solid var(--sb-money); + border-radius: 13px; + padding: 15px 18px; + display: flex; + align-items: center; + gap: 20px; + flex-wrap: wrap; + } + .estimate .breakdown { display: flex; gap: 18px; flex-wrap: wrap; flex: 1; } + .estimate .bk .k { font-size: 10.5px; text-transform: uppercase; letter-spacing: .5px; color: var(--sb-faint); } + .estimate .bk .v { font-size: 15px; font-weight: 700; margin-top: 1px; } + .estimate .total { text-align: right; } + .estimate .total .k { font-size: 11px; text-transform: uppercase; letter-spacing: .5px; color: var(--sb-money); font-weight: 700; } + .estimate .total .v { font-size: 27px; font-weight: 800; color: var(--sb-money); line-height: 1; } + .estimate .total .note { font-size: 11px; color: var(--sb-faint); margin-top: 3px; } + + .foot { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 11px; + padding: 16px 24px; + background: var(--sb-panel); + border-top: 1px solid var(--sb-border); + } + .foot .spacer { margin-right: auto; font-size: 12px; color: var(--sb-faint); } + + .btn { + border: none; + border-radius: 10px; + padding: 11px 18px; + font-size: 13.5px; + font-weight: 600; + cursor: pointer; + font-family: inherit; + } + .btn.ghost { background: transparent; color: var(--sb-muted); border: 1px solid var(--sb-border); } + .btn.primary { + color: #fff; + background: linear-gradient(135deg, #5ba848, #2e7aad); + box-shadow: 0 3px 10px color-mix(in srgb, #2e7aad 40%, transparent); + } + .btn[disabled] { opacity: .6; cursor: not-allowed; } + + .hide { display: none !important; } +} diff --git a/fusion_claims/static/src/xml/service_booking.xml b/fusion_claims/static/src/xml/service_booking.xml new file mode 100644 index 00000000..35377cdd --- /dev/null +++ b/fusion_claims/static/src/xml/service_booking.xml @@ -0,0 +1,208 @@ + + + + +
+
+
+
+
+

Book a Service

+
Repair · delivery · pickup — captures the job and creates the priced repair order
+
+
+
+ Scheduled + En Route + In Progress + Completed + ● Draft repair SO will be created +
+ +
+
+ +
+

Customer

+
+
+ + +
+
+
+
+ + +
Inbound call? Type the phone number — we match the contact & their history.
+
+
+
+
+
+
+
+
+
+
📍
+
+
+
+
+
+
+
Contact is created & linked on save — all from this page.
+
+
+ + +
+

Service & Pricing$ REVENUE

+
+
+ + +
+
+ + +
+
+
+ + +
Auto-suggested from the device — change if needed.
+
+
+
Call-out fee · + travel · includes 30 min labour
+
$
+
+
In-shop job — no call-out fee; labour billed at $/hr.
+
+ + +
+

Schedule

+
+
+
+ +
+
+
+ +
+ + +
+ + +
+
+
Ends at · your local time
+
+
+ + +
+
+ + +
+

Location

+
+
In-shop jobAt the store — no call-out, labour @ $/hr
+
+
+
+
+
📍
+
+
+
+
+
+
+
+ + +
+

Job details

+
+
+
+
+
Under manufacturer warrantyParts not billed when covered
+
POD requiredCapture proof of delivery on completion
+
Send client confirmation (email/SMS)Booked · en-route · completed
+
Request Google review after completion
+
+ + +
+
+
Call-out
$
+
Est. labour
$ · h @ $
+
Travel ($/km ×2)
$
+
+
Estimated total
$
+
+ parts as used · pre-tax · a draft SO is created
+
+
+
+ +
+ Local time · America/Toronto · km away + + +
+
+
+
+
+ +