Skip to main content

Service module

The workshop work-order screen. Same sales-3zone chassis (Search · Ledger · Till) as Sales, adapted to service tickets: today's-service-orders ledger grouped by status on the left, the active service order (work-order) on the right. Drop-off → labour + parts → status lifecycle → pickup invoices as sale.

Tab labelService
Tab accent#2563eb (blue)
Strip position2nd — between Sales and Customers (position set by setTabOrder() in src/modules/index.ts, v0.7.409+)
Foldersrc/modules/service/
Migration030_service.sql
Steward#sv-search — focus stewardship adopted v0.7.88; search Enter/Escape conventions + Escape race-guard on the pending 170ms debounce
Ledger headingTitle-case "Today's Service Orders" (v0.7.99) + shared kbDatePicker universal date navigator (BIKE.L1-0010, v0.7.98) — today shows the full work-queue; picking a past day filters by drop-off date
Intake modalOne reactive modal covering customer search → bike selection → problem description → condition → work requested → max budget (v0.6.983 → v0.7.014). Mirrors the Sales attach-customer chassis (v0.7.010)
Ledger look/feelSales-parity as of v0.7.247 — fixed header over a scrolling list; first column shows Time on a single day and Date across a range; ring-up total in the display serif; footer order Cancel left · Print right; page sub-header inline with the title
TimezonesBIKE.L1-0018 (v0.7.247) — server stores drop-off / pickup timestamps as UTC and the ledger renders them in the shop's local time (utcDate); operator-entered promised_at (Estimated completion) stays naive-local, exactly as typed

What it owns​

src/modules/service/
index.ts — registers the module with the Kernel
view.ts — markup + styles for the till + status-grouped ledger + modals
client.ts — browser JS (ticket flow, labour + parts add, status transitions)
api.ts — server-side routes (/api/m/service/*)

What it does​

The work-order. New Service Order → attach customer → pick a bike from the customer's Equipment section (or add a new one) → drop-off ticket opens on the right pane. The ticket carries the standard order fields (customer, bike, dates) plus the service-specific ones (drop-off date, promised date, reported issues, mechanic assigned, deposit).

Lines. Two kinds:

  • Labour lines — picked from the service_menu catalog (coded shop items like a1 BARE ESSENTIALS TUNE, b3 E-BIKE ADVANCED TUNE, …). Every menu row has a fixed price and default labour minutes; the row itself carries the tax category. Labour is GST-only in BC (no PST), so every service_menu row falls under gst_only.
  • Parts lines — pulled from product_variants via the same search chassis that Sales uses. Adding a part decrements stock through the movement engine (core/movements.ts) once the ticket is invoiced, not when the line lands on the ticket.

Status lifecycle.

StatusMeaning
Drop-offTicket exists, bike is in
Start WorkMechanic has started
Wait for PartsBlocked on incoming parts (typically linked to a Special Order)
Parts holdSet automatically when the mechanic uses Order parts to ticket — ticket sits here until the ordered part arrives
Stop · CallBlocked on customer decision — call and confirm
ReadyWork complete, awaiting pickup
NotifyCustomer has been notified (SMS / email stub in v1; real wire deferred)
InvoicedTicket has been rung as a sale at pickup

Transitions land on audit_events and, for Stop · Call and Notify, insert into service_ticket_messages with the notification channel + body. The real SMS / email wire is deferred to a later minor; the substrate captures the notification record with a stub channel for now.

Estimate / deposit / cancel. Standard sales-side controls: create an estimate before Start Work; take a deposit against a Wait-for-Parts ticket; cancel with a captured reason. Deposits ride the same /deposit path Sales uses.

Pickup invoices as sale. When the ticket is Ready and the customer arrives, Pickup rings the ticket as a service_invoice transaction (transactions.transaction_type='service_invoice', related_ticket_id set) that flows through the standard tender modal. Parts lines fire the movement engine; labour lines are cash-only in the accounting sense (no stock change).

The service_menu catalog​

Coded labour items with fixed price + default labour minutes. Full 67-item catalog imports from the prior system's tuning menu; v1 seeds a representative starter set (a1 BARE ESSENTIALS TUNE, b3 E-BIKE ADVANCED TUNE, categorised by Tune-ups / Wheels / Brakes / …) so the module is testable without the full import.

The menu items live in one shared catalog per shop; every ticket picks from it. Edits to a menu row don't flow retroactively into past tickets (line snapshots the price + tax at add-time, same as Sales lines).

Permissions​

PermissionWhat it gates
screen.serviceThe Service tab is visible at all
service.readToday's ledger + ticket detail
service.createStart a new ticket
service.editAdd / remove labour + parts; change assigned mechanic; edit issues + notes
service.transitionMove ticket status (Start Work / Ready / Notify / etc.)
service.invoiceRing pickup as a service_invoice sale
service.voidVoid a ticket after invoicing

Reactive intake modal — customer + bike + problem + budget​

One reactive intake surface (v0.6.983 → v0.7.014), replacing the earlier multi-step drop-off flow.

  • v0.6.983 — one reactive intake modal: customer search + bikes inline (rather than a page-swap sequence).
  • v0.6.984 — clear-× in the search returns to search; drops duplicated customer name.
  • v0.6.985 — bike-brand autocomplete on the new-bike Brand field (fed by core/bike-brands.ts, the definitive Canadian list of 173 makes).
  • v0.6.986 — Service #3: customer-approved max budget on the work order — captured at intake, printed on the ticket, gates the mechanic from silently exceeding it during work.
  • v0.7.008 — Service #1: capture the reported problem at drop-off (free-text; feeds the ticket + the mechanic's Slot).
  • v0.7.009 — Service #1: create a new customer from the drop-off intake (walk-in on the bench doesn't need a Sales-tab detour).
  • v0.7.010 — customer step now mirrors the Sales attach-customer modal — same picker, same search-then-create pattern, same "Keep walk-in" option.
  • v0.7.011 — Service #2: Condition & work-requested modal (text box for now; a structured form is on the docket).
  • v0.7.012 — Definitive bike-brand list on the Brand autocomplete.
  • v0.7.013 / v0.7.014 — Service search bar is context-aware (like the Sales bar) — the hint flips reliably with the ticket context so the operator always sees what the search will scope over.

The system_data audit KIND (v0.6.120 area) closes the earlier Service add-bike audit hole — every user-added master-data record (customers, bikes, suppliers, products, discounts, staff, roles) files under one filterable audit category.

Order parts to ticket (BIKE.L2-0074)​

A mechanic can add a part the shop doesn't stock (v0.7.141). When the search-add lands on a variant that has zero on-hand, a prompt offers to order it from the vendor. Confirming:

  1. Stages a Purchases-Bucket line earmarked to that customer (the same for <Customer> mechanism the Sales backorder flow uses — see Backorder → Bucket flow)
  2. Vendor auto-derives from the part's primary supplier — no picker
  3. Flips the ticket status to Parts hold

The ticket then sits in Parts hold until the vendor PO arrives; when the Bucket line is received against a cut PO, the ticket's parts-hold notice clears and the operator can move to Start Work / Ready. Cross-tab write: the only Service→Purchases shared-substrate write in the module. Everything else on the Service tab is Service-only.

Special orders → PO bucket → back to the ticket​

A service ticket in Wait for Parts often has a linked special-order deposit (a Sales-side transaction with transaction_type='special_order'). As of v0.7.86 (migration 033), the special-order auto-posts a line to the Purchases Bucket the moment it's created; the Sales ledger row for that special order tracks the PO status live — Special order → Ordered → Partially received → Ready for pickup. One PO line links back to its originating special-order transaction; one PO can carry lines for several different customers' special orders at once.

ETC — estimated time of completion (migs 104 + 108 + core/serviceetc.ts)​

"James's Service flow asks for an estimated time of completion in the ledger, recalculating as work orders move up the queue." The arithmetic is straightforward — sum the labour minutes ahead of you in the queue and spread them over the bench — but it depends on three things that are not ours to guess:

  • Bench count — how many mechanics work in parallel.
  • Effective bench hours per day — not clock-open hours; the time actually spent on tickets.
  • Working days per week.

All three land as shop settings in mig 108 (shop_config.service_bench_count, service_bench_hours_per_day, service_working_days_per_week); the ETC engine core/serviceetc.ts reads them and stamps the ETC on each ticket row in the Service ledger — "the ledger now says when each bike will actually be ready" (d17a5c3). Guessing the numbers produces a date the shop stops trusting inside a week, which is worse than no date at all — hence: shop settings, not defaults.

Zero labour minutes = unrecorded, not "no work" (fad1946). A ticket with 0 labour minutes doesn't push the queue by zero — the ETC engine treats it as "we haven't estimated this yet" rather than "this is instant", so a not-yet-scoped ticket doesn't silently poison the queue's readout.

Editable ticket rows (0fdf55d → 4aa61ce)​

The Till's ticket details section used to be a click-to-unlock read-then-edit surface, then a details-header + inline pickers experiment, and now live-controls-in-place: each ticket row is a live control (a dropdown, a picker, or a text input); labels sit left, data right; the Status is back to a read-only pill (a status change is a transition, not an edit — it goes through the transition affordance, not the ticket row). Rows edit inline with dropdowns — no popover, no confirm; the transitions themselves have their own controls elsewhere on the Till. Picking an urgency level hands focus to its note (2c2ac75) so a "rush" pick doesn't leave the note field un-filled.

Two pickers became shop settings (mig 104). bike_location was free text — "rack 3", "Rack 3", "rack three" and "back wall" were four different places to the database and one place in the workshop. urgency was low / normal / rush hard-coded in the client — a shop wanting "Warranty" or "While-you-wait" needed a deploy. Both are now shop-editable lists in Settings > Service (7cbd961 + ac31619).

Parts belong to a service (mig 110)​

"Parts listed under each related service" + "totals for each service and its parts" (James). Before mig 110 service_ticket_lines was flat — line_type (labour | part) and service_menu_id on the labour lines only. A work order with a tune-up, a wheel build, and eleven parts was eleven parts in a list, and nobody reading it could tell what the wheel build actually cost.

Mig 110 adds a parent_line_id on service_ticket_lines — a part row points at the labour row it was fitted for. The ticket view groups accordingly, and totals-per-service become tractable (da9c8 / da8ec / 2c2ac75 / 953d770). The first service on a ticket adopts the parts already on it (2c2ac75) — so pre-existing parts don't orphan when the first labour line is added; they attach to that first service. T-N0004's spoke lands under its wheel true (953d770) as a specific historical case.

Parts hold — vendor + ETA + who to chase (mig 109)​

"If on parts hold, notes must list where they are on order from, and an ETA. Ideally, a mandatory pop-up asking for vendor + ETA of part." (James.) Mig 109 adds a parts_hold shape recording vendor + expected date + who to chase. A late part shows on the ledger to the person who can chase it (5172b77) — not just to the mechanic. Parts hold now has to say who and when (1721122).

Customer sign-off on a work order (mig 112)​

"Rentals has had signature capture since the waiver work; Service has had nothing, so a bike is taken in, worked on and handed back with no record that the owner ever agreed to any of it." (Tom.)

Two moments, one table. service_signoffs:

  • Intake sign-off — the owner agrees to what's being estimated + the deposit terms.
  • Completion sign-off — the owner accepts the work at pickup.

Signatures are captured via the same core/sigpad.ts canvas the rental waiver uses (base64 PNG on the row); waiver text is stored per signature the same way (immutable historical record).

Guardrails — refuse to ring, respect prior state​

  • Refuse to ring up a ticket its line items cannot explain (ef9c91f). If lines + totals don't reconcile, the invoice action refuses; the operator has to fix the ticket before it becomes a sale.
  • The ready status has never actually been produced (378b709). A migration recorded that — the current status set stops at Notify; anything else was aspirational and got documented rather than shipped.
  • A departed mechanic keeps their own ticket (aca34e4). A staffer leaving doesn't reassign their in-progress tickets by default — the record of who owned it stays intact.
  • Name the ordered item — parts on order surface with the actual item name, not the internal shorthand (aca34e4).

Internal rental repair — the shop's own bike (mig 085)​

A damaged rental bike leaves the fleet as an internal service ticket — service_tickets.is_internal = 1, inventory_unit_id links to the shop-owned bike under repair, rental_booking_id links to the rental it came off (context only). Mig 085 is pure additive (ADD COLUMN); no in_repair unit STATUS was introduced (that would need the risky inventory_units.status CHECK rebuild — cheapest safe rebuild is no rebuild). Instead, while an internal ticket is OPEN the Rentals fleet query excludes the unit as a flag; completing the ticket auto-returns the bike to the fleet.

Internal tickets have no customer + no customer_bikes row (the shop owns the bike); the pickup flow does not invoice a customer. Parts are still consumed against the ticket. See Rentals — damage-hold arc for the till-side workflow that ends in POST /service/rental-repair.

Service menu is a tile-picker (v0.7.7xx, af18068 → d259915). The + Service control opens the same 80% wide tile picker Rentals / Sales GC-Bulk use — multi-select tiles, Add Service, X to close, no search box. $0 services are dropped from the picker (af18068).

How it composes with the rest​

  • Customers — the attached customer's Equipment section supplies the bike picker; the customer Till's ledger shows this ticket as one of the interactions after invoicing.
  • Sales — the pickup flow uses the same tender modal, refund path, and audit shape as a Sales sale. related_ticket_id on the resulting transaction links back.
  • Inventory — every parts line reads from product_variants; the movement engine records the decrement at invoicing.
  • Purchases — the special-order → PO bucket link (migration 033) surfaces order progress on the Service ticket without the operator having to switch tabs.
  • Rentals — an internal repair ticket owns a damaged rental bike while it's out of the fleet (mig 085); completing the ticket auto-returns it.
  • Audit chain — every ticket create, status transition, line add / remove, deposit, pickup / invoice, void lands in audit_events.

See also​