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 label | Service |
| Tab accent | #2563eb (blue) |
| Strip position | 2nd — between Sales and Customers (position set by setTabOrder() in src/modules/index.ts, v0.7.409+) |
| Folder | src/modules/service/ |
| Migration | 030_service.sql |
| Steward | #sv-search — focus stewardship adopted v0.7.88; search Enter/Escape conventions + Escape race-guard on the pending 170ms debounce |
| Ledger heading | Title-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 modal | One 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/feel | Sales-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 |
| Timezones | BIKE.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_menucatalog (coded shop items likea1 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 everyservice_menurow falls undergst_only. - Parts lines — pulled from
product_variantsvia 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.
| Status | Meaning |
|---|---|
| Drop-off | Ticket exists, bike is in |
| Start Work | Mechanic has started |
| Wait for Parts | Blocked on incoming parts (typically linked to a Special Order) |
| Parts hold | Set automatically when the mechanic uses Order parts to ticket — ticket sits here until the ordered part arrives |
| Stop · Call | Blocked on customer decision — call and confirm |
| Ready | Work complete, awaiting pickup |
| Notify | Customer has been notified (SMS / email stub in v1; real wire deferred) |
| Invoiced | Ticket 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
| Permission | What it gates |
|---|---|
screen.service | The Service tab is visible at all |
service.read | Today's ledger + ticket detail |
service.create | Start a new ticket |
service.edit | Add / remove labour + parts; change assigned mechanic; edit issues + notes |
service.transition | Move ticket status (Start Work / Ready / Notify / etc.) |
service.invoice | Ring pickup as a service_invoice sale |
service.void | Void 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:
- Stages a Purchases-Bucket line earmarked to that customer (the same
for <Customer>mechanism the Sales backorder flow uses — see Backorder → Bucket flow) - Vendor auto-derives from the part's primary supplier — no picker
- 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
readystatus has never actually been produced (378b709). A migration recorded that — the current status set stops atNotify; 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_idon 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
- Three strata — Kernel · Meta · Modules — the architectural shape
- Sales module — the tender + transaction substrate the pickup flow uses
- Inventory module — parts lines read variants from here
- Purchases module — special-order → Bucket → PO substrate (migration 033)
- Customers module — the Equipment section supplies the bike picker