Jobora Manual, as shipped in the download
Jobora — User Manual
Field-work management: customers, jobs, a schedule of visits, quotes, invoices — and the margin on every job. The REST ping reports version 1.0.4 [src: app/controllers/api.php:18].
About this manual
Every statement here was written by reading Jobora's own source, and each one carries the file it came from in a bracketed src marker. Paths are relative to the folder this docs directory sits in. If a sentence disagrees with the application, the application is right and this manual has a bug.
Nothing is described that the code does not do. Where something is deliberately limited, or currently wrong, that is said plainly rather than left out.
⭐ The margin view is the whole argument
The source states the case for the product in one sentence: a notes app will tell you a job happened; it will not tell you that the job you quoted at £1,400 cost you £1,610 in parts and hours [src: app/src/Jobs.php:5].
Run that exact example through the product's own arithmetic — three parts at £270 and eight hours at £100:
materials 810.00
labour 800.00 (480 minutes)
cost 1,610.00
quoted 1,400.00
margin −210.00 −15.0% tone: bad
The definitions are stated where the arithmetic lives [src: app/src/Jobs.php:10]:
- quoted is the snapshot taken when the quote was accepted, never the live quote — because a quote edited after acceptance must not silently rewrite last month's margin. The snapshot is written onto the job at conversion [src: app/controllers/app.php:1030] and everything downstream reads that column [src: app/controllers/app.php:179].
- cost is materials (quantity × unit) plus labour (minutes ÷ 60 × rate) [src: app/src/Jobs.php:63].
- margin is quoted − cost, and margin % is margin ÷ quoted.
⭐ Unknown is not zero
A job with nothing quoted has no margin to report, and the source is explicit that printing "−£1,610" against it would read as a loss on work that was never priced here [src: app/src/Jobs.php:93]. So both figures come back null. Executed on the same cost rows with nothing quoted:
cost_cents 161000 quoted false margin_cents NULL margin_bp NULL label "—"
That is the difference between a number you can act on and a number that would make you cancel a profitable line of work.
Money and time are integers
Every figure is integer cents, and hours are minutes so a 20-minute call is not 0.3 of anything [src: app/src/Jobs.php:16]. Executed:
20 minutes @ £100.00/h = 3333 cents
7 minutes @ £95.00/h = 1108 cents
One note on reading the margin chip: its colour thresholds are display only, never a rule [src: app/src/Jobs.php:120], and the label rounds to one decimal place — so 14.99% and 15.00% both print as "15.0%" while showing different colours. Trust the colour boundary, not the rounded label.
The life cycle, and refusals in the operator's words
Five statuses [src: app/src/Jobs.php:23], and a job may only move where the transition table allows [src: app/src/Jobs.php:26]. A completed job is not re-opened — a follow-up is a new job. Executed, with the refusal each attempt actually produces [src: app/src/Jobs.php:40]:
completed -> in_progress A completed job stays completed — raise a follow-up job rather
than reopening the record.
cancelled -> scheduled A cancelled job stays cancelled. Raise a new one.
scheduled -> completed A job cannot go from Scheduled to Completed.
completed -> completed That job is already Completed.
scheduled -> nonsense That is not a job status.
in_progress -> completed ALLOWED
Refusals are written for the person reading them, not for a log.
Quotes convert once
Acceptance is what converts a quote into a job, and it converts once [src: app/src/Jobs.php:159]. The refusals are the interesting part [src: app/src/Jobs.php:163]: a quote that already became a job cannot spawn a second one, a declined quote is not an agreement to do work, and a draft has to be sent before it can be accepted.
Customer email: two gates, both off
ClientMail is the only thing in Jobora that emails a customer [src: app/src/ClientMail.php:3], and the guarantees are worth knowing before you turn it on:
Two switches, each defaulting to off in the schema — a master flag and a per-event flag. Installing or upgrading never starts emailing anybody's customers; you have to say yes twice [src: app/src/ClientMail.php:8].
Your SMTP, not ours. There is no Jobora relay, no shared sender, no fallback account [src: app/src/ClientMail.php:11].
A demo install opens no socket — it writes an outbox row instead, because a public demo that could send real mail to a real address is a liability rather than a feature [src: app/src/ClientMail.php:12].
A mail failure never breaks the work. Accepting a quote, booking a job and issuing an invoice all succeed whatever the mail server is doing; the failure is recorded and reported, not raised [src: app/src/ClientMail.php:14].
And every message carries only what the customer already has a right to see — their own document, job and dates. No internal notes, no margin, no other customer, no staff comment [src: app/src/ClientMail.php:18].
Users and roles
Three roles [src: app/controllers/v3.php:23], with the reasoning stated: a field technician needs to run the day and record what happened; they do not get to delete a job, move money, or change what the company charges [src: app/controllers/v3.php:19].
Executing the product's own permission function rather than reading the map:
job.view viewer=Y member=Y admin=Y
job.edit viewer=n member=Y admin=Y
photo.upload viewer=n member=Y admin=Y
settings.edit viewer=n member=n admin=Y
audit.view viewer=n member=Y admin=Y
The viewer's grants are listed, not wildcarded
A viewer holds job.view and reports.view and nothing else [src: app/controllers/v3.php:23], so the audit page and its CSV export, which require audit.view [src: app/controllers/v3.php:86], refuse a read-only account. Before 1.0.4 the viewer held the wildcard *.view, which matched audit.view too.
Backups are gated properly: both downloads require settings.edit [src: app/controllers/api.php:334], and the restore page and the scheduled-backup token require settings.manage; no role below admin holds either.
The JSON backup is the one you can share, so it blanks every credential column — password hashes, 2FA seeds and recovery codes, API-key and invite token hashes, webhook secrets, the SMTP password, the SSO client secret, the backup and calendar tokens — and, from 1.0.4, each quote's customer link, because that link opens the quote and can accept it [src: app/controllers/v3.php:78]. A restore keeps the link of every quote that still exists; a quote the restore brings back after it was deleted gets a new link, so send it to the customer again. On a SQLite install the .sqlite download is the whole database, secrets included, for bare-metal recovery.
An API key carries its holder's role at the REST door as well as the agent door: creating or moving a job, booking a visit and accepting a quote over /api/* need job.edit [src: app/controllers/api.php:63], so a viewer's key is refused with 403. Before 1.0.4 only the key's read/write scope was checked there.
Webhooks: the events on offer are the events Jobora sends
The settings page offers exactly what the code fires [src: app/src/Webhook.php:21]:
| event | browser | REST | agent |
|---|---|---|---|
job.created | [src: app/controllers/app.php:464] | [src: app/controllers/api.php:92] | [src: app/controllers/mcp.php:226] |
job.<status> — job.scheduled, job.in_progress, job.on_hold, job.completed, job.cancelled | [src: app/controllers/app.php:495] | [src: app/controllers/api.php:112] | [src: app/controllers/mcp.php:291] |
quote.accepted | the one conversion function every door calls [src: app/controllers/app.php:1046] | same | same |
job.signed_off | [src: app/controllers/app.php:1239] | — | — |
Sign-off exists only in the browser, where the customer signs. Before 1.0.4 the page offered workorder.created, workorder.completed and reading.recorded, another product's events, so none of the real ones could be ticked; an endpoint saved with no events ticked still receives everything [src: app/src/Webhook.php:32].
One version, everywhere
The ping, the OpenAPI document, the agent probe and specification, the health check and the version stamped into every backup all read the same constant [src: app/controllers/api.php:18], [src: app/controllers/api.php:329], [src: app/controllers/mcp.php:41]. Before 1.0.4 three of them said 1.0.0 and two said 3.1.1.
The API and agent access
Eight tools [src: app/controllers/mcp.php:88]: four read — the job list with margins, one job in detail, a day's schedule and a margin report — and four write: creating a job [src: app/controllers/mcp.php:197], booking a visit [src: app/controllers/mcp.php:231], advancing a job [src: app/controllers/mcp.php:270] and accepting a quote [src: app/controllers/mcp.php:296].
The instructions given to an agent restate the rules that matter, including the one this manual leads with [src: app/controllers/mcp.php:82]:
"Money is INTEGER CENTS everywhere and is computed by the product, never by the caller … margin_cents is null when nothing was quoted — that means unknown, not zero."
They also warn that schedule times are local wall-clock strings rather than timestamps, and that a visit overlapping one the same technician already has is refused.
What Jobora does not do
It does not reopen a completed job [src: app/src/Jobs.php:25] — a follow-up is a new job.
It does not let an accepted quote's later edits rewrite a past margin [src: app/src/Jobs.php:10].
It does not report a margin it cannot know [src: app/src/Jobs.php:93].
It does not email your customers until you switch it on twice [src: app/src/ClientMail.php:8], and it never sends through anything but your own SMTP [src: app/src/ClientMail.php:11].
It does not put internal notes or margin figures in a customer's email [src: app/src/ClientMail.php:18].