feat(fusion_claims): OWL service-booking wizard + dark/light SCSS
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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'],
|
||||
|
||||
102
fusion_claims/static/src/js/service_booking/service_booking.js
Normal file
102
fusion_claims/static/src/js/service_booking/service_booking.js
Normal file
@@ -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);
|
||||
73
fusion_claims/static/src/scss/_service_booking_tokens.scss
Normal file
73
fusion_claims/static/src/scss/_service_booking_tokens.scss
Normal file
@@ -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};
|
||||
}
|
||||
283
fusion_claims/static/src/scss/service_booking.scss
Normal file
283
fusion_claims/static/src/scss/service_booking.scss
Normal file
@@ -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; }
|
||||
}
|
||||
208
fusion_claims/static/src/xml/service_booking.xml
Normal file
208
fusion_claims/static/src/xml/service_booking.xml
Normal file
@@ -0,0 +1,208 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_claims.ServiceBookingWizard" owl="1">
|
||||
<div class="o_service_booking">
|
||||
<div class="wrap">
|
||||
<div class="dialog">
|
||||
<div class="topbar">
|
||||
<div>
|
||||
<h1>Book a Service</h1>
|
||||
<div class="sub">Repair · delivery · pickup — captures the job and creates the priced repair order</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stepper">
|
||||
<span class="step active">Scheduled</span>
|
||||
<span class="step">En Route</span>
|
||||
<span class="step">In Progress</span>
|
||||
<span class="step">Completed</span>
|
||||
<span class="step draft">● Draft repair SO will be created</span>
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
<div class="grid">
|
||||
<!-- CUSTOMER -->
|
||||
<div class="card">
|
||||
<h3><span class="dot"></span>Customer</h3>
|
||||
<div class="row">
|
||||
<div class="seg full">
|
||||
<button t-att-class="{ on: state.custMode === 'existing' }"
|
||||
t-on-click="() => this.setCust('existing')">Existing customer</button>
|
||||
<button t-att-class="{ on: state.custMode === 'new' }"
|
||||
t-on-click="() => this.setCust('new')">New client</button>
|
||||
</div>
|
||||
</div>
|
||||
<div t-if="state.custMode === 'existing'">
|
||||
<div class="row">
|
||||
<label class="fl">Search by phone, name or SO</label>
|
||||
<input class="f" t-model="state.soSearch" placeholder="e.g. (416) 555-0142 …"/>
|
||||
<div class="hint">Inbound call? Type the phone number — we match the contact & their history.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div t-if="state.custMode === 'new'">
|
||||
<div class="row two">
|
||||
<div><label class="fl">Client name *</label><input class="f" t-model="state.customer.name" placeholder="Full name"/></div>
|
||||
<div><label class="fl">Phone *</label><input class="f" t-model="state.customer.phone" placeholder="(416) 555-…"/></div>
|
||||
</div>
|
||||
<div class="row"><label class="fl">Email</label><input class="f" type="email" t-model="state.customer.email" placeholder="client@email.com"/></div>
|
||||
<div class="row"><label class="fl">Address</label>
|
||||
<div class="with-icon"><input class="f" t-model="state.customer.street" placeholder="Start typing an address…"/><span class="pin">📍</span></div>
|
||||
</div>
|
||||
<div class="row three">
|
||||
<div><label class="fl">Unit</label><input class="f" t-model="state.customer.unit" placeholder="#"/></div>
|
||||
<div><label class="fl">Buzz</label><input class="f" t-model="state.customer.buzz" placeholder="—"/></div>
|
||||
<div><label class="fl">City</label><input class="f" t-model="state.customer.city" placeholder="City"/></div>
|
||||
</div>
|
||||
<div class="hint">Contact is created & linked on save — all from this page.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SERVICE & PRICING -->
|
||||
<div class="card">
|
||||
<h3><span class="dot"></span>Service & Pricing<span class="tag">$ REVENUE</span></h3>
|
||||
<div class="row two">
|
||||
<div>
|
||||
<label class="fl">Device being serviced</label>
|
||||
<select class="f" t-on-change="onDevice">
|
||||
<option value="standard">Mobility Scooter</option>
|
||||
<option value="standard">Powerchair</option>
|
||||
<option value="standard">Wheelchair</option>
|
||||
<option value="lift">Stairlift</option>
|
||||
<option value="lift">Patient / Ceiling Lift</option>
|
||||
<option value="standard">Lift Chair</option>
|
||||
<option value="standard">Hospital Bed</option>
|
||||
<option value="standard">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="fl">Issue / symptom</label>
|
||||
<input class="f" t-model="state.issue" placeholder="e.g. won't power on"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" t-if="!state.inShop">
|
||||
<label class="fl">Service call type</label>
|
||||
<select class="f"
|
||||
t-on-change="(ev) => { const r = state.calloutRates.find(x => x.code === ev.target.value); if (r) { state.category = r.category; state.timing = r.timing; } }">
|
||||
<t t-foreach="state.calloutRates" t-as="r" t-key="r.code">
|
||||
<option t-att-value="r.code"
|
||||
t-att-selected="state.category === r.category and state.timing === r.timing">
|
||||
<t t-esc="r.name"/> — $<t t-esc="fmt(r.price)"/><t t-if="r.adds_per_km"> + $<t t-esc="fmt(state.perKm)"/>/km ×2-way</t>
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
<div class="hint">Auto-suggested from the device — change if needed.</div>
|
||||
</div>
|
||||
<div class="feeline" t-if="!state.inShop and callout">
|
||||
<div class="lbl">Call-out fee<small><t t-esc="callout.name"/><t t-if="callout.adds_per_km"> · + travel</t><t t-else=""> · includes 30 min labour</t></small></div>
|
||||
<div class="amt">$<t t-esc="fmt(callout.price)"/></div>
|
||||
</div>
|
||||
<div class="hint" t-if="state.inShop">In-shop job — no call-out fee; labour billed at $<t t-esc="fmt(state.labour.inshop)"/>/hr.</div>
|
||||
</div>
|
||||
|
||||
<!-- SCHEDULE -->
|
||||
<div class="card">
|
||||
<h3><span class="dot"></span>Schedule</h3>
|
||||
<div class="row two">
|
||||
<div><label class="fl">Date</label><input class="f" type="date" t-model="state.date"/></div>
|
||||
<div><label class="fl">Duration</label>
|
||||
<select class="f" t-model.number="state.durationHr">
|
||||
<option value="0.5">30 min</option>
|
||||
<option value="1">1 hour</option>
|
||||
<option value="1.5">1.5 hours</option>
|
||||
<option value="2">2 hours</option>
|
||||
<option value="3">3 hours</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="fl">Start time</label>
|
||||
<div class="timepick">
|
||||
<select class="f" t-model.number="state.hour">
|
||||
<option value="9">9</option>
|
||||
<option value="10">10</option>
|
||||
<option value="11">11</option>
|
||||
<option value="12">12</option>
|
||||
<option value="1">1</option>
|
||||
<option value="2">2</option>
|
||||
<option value="3">3</option>
|
||||
<option value="4">4</option>
|
||||
</select>
|
||||
<select class="f" t-model.number="state.minute">
|
||||
<option value="0">:00</option>
|
||||
<option value="15">:15</option>
|
||||
<option value="30">:30</option>
|
||||
<option value="45">:45</option>
|
||||
</select>
|
||||
<div class="ampm">
|
||||
<button t-att-class="{ on: state.ampm === 'AM' }" t-on-click="() => this.setAmpm('AM')">AM</button>
|
||||
<button t-att-class="{ on: state.ampm === 'PM' }" t-on-click="() => this.setAmpm('PM')">PM</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="endtime">Ends at <b><t t-esc="endLabel"/></b> · your local time</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="fl">Technician</label>
|
||||
<select class="f" t-model.number="state.technicianId">
|
||||
<option value="false">— Choose —</option>
|
||||
<t t-foreach="state.technicians" t-as="t" t-key="t.id">
|
||||
<option t-att-value="t.id"><t t-esc="t.name"/></option>
|
||||
</t>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LOCATION -->
|
||||
<div class="card">
|
||||
<h3><span class="dot"></span>Location</h3>
|
||||
<div class="opt" style="border:none; padding-top:0;">
|
||||
<div class="lab">In-shop job<small>At the store — no call-out, labour @ $<t t-esc="fmt(state.labour.inshop)"/>/hr</small></div>
|
||||
<div class="sw" t-att-class="{ on: state.inShop }" t-on-click="toggleInShop"></div>
|
||||
</div>
|
||||
<div t-if="!state.inShop">
|
||||
<div class="row"><label class="fl">Job address</label>
|
||||
<div class="with-icon"><input class="f" t-model="state.customer.street" placeholder="Auto-fills from customer…"/><span class="pin">📍</span></div>
|
||||
</div>
|
||||
<div class="row two">
|
||||
<div><label class="fl">Unit / Suite</label><input class="f" t-model="state.customer.unit" placeholder="#"/></div>
|
||||
<div><label class="fl">Buzz code</label><input class="f" t-model="state.customer.buzz" placeholder="—"/></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JOB DETAILS -->
|
||||
<div class="card span2">
|
||||
<h3><span class="dot"></span>Job details</h3>
|
||||
<div class="two">
|
||||
<div class="row"><label class="fl">Work description</label><textarea class="f" t-model="state.description" placeholder="Symptom, what to check, history…"></textarea></div>
|
||||
<div class="row"><label class="fl">Parts / materials to bring</label><textarea class="f" t-model="state.materials" placeholder="Batteries, controller, casters…"></textarea></div>
|
||||
</div>
|
||||
<div class="opt"><div class="lab">Under manufacturer warranty<small>Parts not billed when covered</small></div><div class="sw" t-att-class="{ on: state.warranty }" t-on-click="() => state.warranty = !state.warranty"></div></div>
|
||||
<div class="opt"><div class="lab">POD required<small>Capture proof of delivery on completion</small></div><div class="sw" t-att-class="{ on: state.pod }" t-on-click="() => state.pod = !state.pod"></div></div>
|
||||
<div class="opt"><div class="lab">Send client confirmation (email/SMS)<small>Booked · en-route · completed</small></div><div class="sw" t-att-class="{ on: state.emailConfirm }" t-on-click="() => state.emailConfirm = !state.emailConfirm"></div></div>
|
||||
<div class="opt"><div class="lab">Request Google review after completion</div><div class="sw" t-att-class="{ on: state.googleReview }" t-on-click="() => state.googleReview = !state.googleReview"></div></div>
|
||||
</div>
|
||||
|
||||
<!-- ESTIMATE -->
|
||||
<div class="estimate">
|
||||
<div class="breakdown">
|
||||
<div class="bk"><div class="k">Call-out</div><div class="v"><t t-if="state.inShop">—</t><t t-else="">$<t t-esc="fmt(estimate.callout)"/></t></div></div>
|
||||
<div class="bk"><div class="k">Est. labour</div><div class="v">$<t t-esc="fmt(estimate.labour)"/> · <t t-esc="estimate.billHr"/>h @ $<t t-esc="fmt(labourRate)"/></div></div>
|
||||
<div class="bk" t-if="estimate.addsKm"><div class="k">Travel ($<t t-esc="fmt(state.perKm)"/>/km ×2)</div><div class="v">$<t t-esc="fmt(estimate.km)"/></div></div>
|
||||
</div>
|
||||
<div class="total"><div class="k">Estimated total</div><div class="v">$<t t-esc="fmt(estimate.total)"/></div>
|
||||
<div class="note">+ parts as used · pre-tax · a draft SO is created</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="foot">
|
||||
<span class="spacer">Local time · America/Toronto · <t t-esc="state.distanceKm"/> km away</span>
|
||||
<button class="btn ghost" t-on-click="() => this.action.doAction({ type: 'ir.actions.act_window_close' })">Cancel</button>
|
||||
<button class="btn primary" t-on-click="submit" t-att-disabled="state.saving">Book & Create SO</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
Reference in New Issue
Block a user