Generate Documents
Each document downloads as an HTML file — open it in your browser, then use Print → Save as PDF.
You can go back and edit, then regenerate.
Uses Resend (resend.com — free tier) to email workers when Schedule B changes. Add your API key and verified sending address below.
When you approve a worker invoice and click Send to Quickbooks, Spotless will POST the invoice data to this webhook URL. Use a Zapier or Make webhook connected to your Quickbooks account to create the vendor bill automatically. Leave blank to use CSV export only.
Customise how your company appears across all generated documents (Schedule A, B, SLA, invoices) and the app UI. Changes are stored in your database and apply to all users.
If VAT registered, a VAT line is added to all client invoices automatically.
Shown on client invoices as payment instructions. Leave blank to omit.
Rename generated documents to match your trade (e.g. "Schedule A" → "Scope of Works").
Run once in Supabase → SQL Editor to enable worker management:
CREATE TABLE IF NOT EXISTS expenses (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
signoff_id uuid REFERENCES signoffs(id) ON DELETE CASCADE,
client_id uuid REFERENCES clients(id) ON DELETE SET NULL,
cleaner_name text,
visit_date date,
amount numeric(10,2) DEFAULT 0,
reason text,
receipt_data_url text,
receipt_name text,
status text DEFAULT 'pending',
decline_reason text,
approved_at timestamptz,
invoice_ref text,
reimbursed_at timestamptz,
archived boolean DEFAULT false,
created_at timestamptz DEFAULT now()
);
ALTER TABLE expenses ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Admins manage expenses" ON expenses
FOR ALL TO authenticated
USING (current_user_is_admin())
WITH CHECK (current_user_is_admin());
CREATE POLICY "Workers view own expenses" ON expenses
FOR SELECT TO authenticated
USING (cleaner_name IN (
SELECT name FROM profiles WHERE id = auth.uid()
));
Run this if workers appear with no name or email — it copies their login email into the profiles table:
Run once to enable the Maintenance Issues feature on sign-off forms:
CREATE TABLE IF NOT EXISTS maintenance_issues ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), client_id uuid REFERENCES clients(id) ON DELETE SET NULL, reported_by_name text, reported_at timestamptz DEFAULT now(), description text NOT NULL, resolved boolean DEFAULT false, resolved_at timestamptz, resolved_by_name text, created_at timestamptz DEFAULT now() ); ALTER TABLE maintenance_issues ENABLE ROW LEVEL SECURITY; CREATE POLICY "Admins manage maintenance_issues" ON maintenance_issues FOR ALL TO authenticated USING (current_user_is_admin()) WITH CHECK (current_user_is_admin()); CREATE POLICY "Workers insert maintenance_issues" ON maintenance_issues FOR INSERT TO authenticated WITH CHECK (true); CREATE POLICY "Workers select maintenance_issues" ON maintenance_issues FOR SELECT TO authenticated USING (true); CREATE POLICY "Workers update maintenance_issues" ON maintenance_issues FOR UPDATE TO authenticated USING (true); CREATE INDEX IF NOT EXISTS maint_issues_client_idx ON maintenance_issues(client_id); CREATE INDEX IF NOT EXISTS maint_issues_resolved_idx ON maintenance_issues(client_id, resolved);
Run once to enable the EOT Property Survey feature:
CREATE TABLE IF NOT EXISTS eot_surveys (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
client_id uuid REFERENCES clients(id) ON DELETE SET NULL,
client_name text,
visit_date date,
rooms jsonb DEFAULT '[]',
generated_rooms jsonb DEFAULT '[]',
kit_checklist jsonb DEFAULT '{}',
submitted_at timestamptz DEFAULT now(),
created_at timestamptz DEFAULT now()
);
ALTER TABLE eot_surveys ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Admins manage eot_surveys" ON eot_surveys
FOR ALL TO authenticated
USING (current_user_is_admin())
WITH CHECK (current_user_is_admin());
CREATE POLICY "Workers insert eot_surveys" ON eot_surveys
FOR INSERT TO authenticated WITH CHECK (true);
CREATE POLICY "Workers select eot_surveys" ON eot_surveys
FOR SELECT TO authenticated USING (true);
CREATE INDEX IF NOT EXISTS eot_surveys_client_id_idx ON eot_surveys(client_id);
CREATE INDEX IF NOT EXISTS eot_surveys_visit_date_idx ON eot_surveys(visit_date);
Run once to enable the EOT Jobs module:
CREATE TABLE IF NOT EXISTS eot_jobs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
job_ref text NOT NULL,
status text NOT NULL DEFAULT 'setup',
property_address text,
property_postcode text,
landlord_name text, landlord_email text, landlord_phone text,
agent_name text, agent_email text, agent_phone text,
tenant_name text,
moveout_date date,
clean_date date,
access_details text,
notes text,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
);
ALTER TABLE eot_jobs ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Admins manage eot_jobs" ON eot_jobs
FOR ALL TO authenticated USING (current_user_is_admin()) WITH CHECK (current_user_is_admin());
CREATE TABLE IF NOT EXISTS eot_job_workers (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
job_id uuid REFERENCES eot_jobs(id) ON DELETE CASCADE,
worker_id uuid REFERENCES profiles(id) ON DELETE CASCADE,
UNIQUE(job_id, worker_id)
);
ALTER TABLE eot_job_workers ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Admins manage eot_job_workers" ON eot_job_workers
FOR ALL TO authenticated USING (current_user_is_admin()) WITH CHECK (current_user_is_admin());
CREATE POLICY "Workers read own eot_job_workers" ON eot_job_workers
FOR SELECT TO authenticated USING (worker_id = auth.uid());
-- Allow workers to read eot_jobs they are assigned to
CREATE POLICY "Workers read assigned eot_jobs" ON eot_jobs
FOR SELECT TO authenticated
USING (
id IN (
SELECT job_id FROM eot_job_workers WHERE worker_id = auth.uid()
)
);
-- Link surveys and sign-offs to EOT jobs
ALTER TABLE eot_surveys ADD COLUMN IF NOT EXISTS job_id uuid REFERENCES eot_jobs(id) ON DELETE SET NULL;
ALTER TABLE signoffs ADD COLUMN IF NOT EXISTS job_id uuid REFERENCES eot_jobs(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS eot_surveys_job_id_idx ON eot_surveys(job_id);
CREATE INDEX IF NOT EXISTS signoffs_job_id_idx ON signoffs(job_id);
Run once to enable the Billing / Invoicing feature:
Run once to enable the Jobs scheduling system:
-- Recurring job patterns
CREATE TABLE IF NOT EXISTS job_recurrences (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
client_id uuid REFERENCES clients(id) ON DELETE CASCADE,
label text NOT NULL,
job_type text DEFAULT 'regular',
days_of_week text[] DEFAULT '{}',
scheduled_time text,
duration_minutes int DEFAULT 120,
active boolean DEFAULT true,
created_at timestamptz DEFAULT now()
);
-- Individual job occurrences
CREATE TABLE IF NOT EXISTS jobs (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
client_id uuid REFERENCES clients(id) ON DELETE CASCADE,
recurrence_id uuid REFERENCES job_recurrences(id) ON DELETE SET NULL,
job_type text DEFAULT 'regular',
label text,
scheduled_date date NOT NULL,
scheduled_time text,
duration_minutes int,
status text DEFAULT 'scheduled',
notes text,
created_at timestamptz DEFAULT now()
);
-- Worker assignments per job occurrence
CREATE TABLE IF NOT EXISTS job_assignments (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
job_id uuid REFERENCES jobs(id) ON DELETE CASCADE,
worker_id uuid NOT NULL,
created_at timestamptz DEFAULT now(),
UNIQUE(job_id, worker_id)
);
-- RLS
ALTER TABLE job_recurrences ENABLE ROW LEVEL SECURITY;
ALTER TABLE jobs ENABLE ROW LEVEL SECURITY;
ALTER TABLE job_assignments ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Auth users manage job_recurrences" ON job_recurrences FOR ALL TO authenticated USING (true) WITH CHECK (true);
CREATE POLICY "Auth users manage jobs" ON jobs FOR ALL TO authenticated USING (true) WITH CHECK (true);
CREATE POLICY "Auth users manage job_assignments" ON job_assignments FOR ALL TO authenticated USING (true) WITH CHECK (true);
-- Indexes
CREATE INDEX IF NOT EXISTS jobs_date_idx ON jobs(scheduled_date);
CREATE INDEX IF NOT EXISTS jobs_client_idx ON jobs(client_id);
CREATE INDEX IF NOT EXISTS job_assignments_job_idx ON job_assignments(job_id);
CREATE INDEX IF NOT EXISTS job_assignments_wkr_idx ON job_assignments(worker_id);
Run once to enable per-worker durations, job start tracking, and part-done status:
-- Per-worker duration override (null = inherit from job) ALTER TABLE job_assignments ADD COLUMN IF NOT EXISTS duration_minutes int; -- When this worker started the job ALTER TABLE job_assignments ADD COLUMN IF NOT EXISTS started_at timestamptz; -- When this worker marked their part complete (multi-worker jobs) ALTER TABLE job_assignments ADD COLUMN IF NOT EXISTS worker_done_at timestamptz;
Run once to enable separate EOT rates and locked rates on completed jobs:
-- EOT rate (higher rate for end-of-tenancy jobs) ALTER TABLE profiles ADD COLUMN IF NOT EXISTS eot_hourly_rate numeric(10,2) DEFAULT 0; -- Rate at time of assignment — locked so completed jobs never change ALTER TABLE job_assignments ADD COLUMN IF NOT EXISTS hourly_rate_snapshot numeric(10,2);
Run once to enable the worker pay tracking and invoice workflow:
-- Add hourly rate to worker profiles ALTER TABLE profiles ADD COLUMN IF NOT EXISTS hourly_rate numeric(10,2) DEFAULT 0; -- Worker invoices submitted per week CREATE TABLE IF NOT EXISTS worker_invoices ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), worker_id uuid REFERENCES profiles(id) ON DELETE CASCADE, worker_name text, worker_email text, period_start date NOT NULL, period_end date NOT NULL, status text NOT NULL DEFAULT 'pending', -- pending | approved | sent jobs_data jsonb DEFAULT '[]', calculated_amount numeric(10,2) DEFAULT 0, approved_amount numeric(10,2), admin_notes text, submitted_at timestamptz DEFAULT now(), approved_at timestamptz, sent_at timestamptz, UNIQUE(worker_id, period_start) ); ALTER TABLE worker_invoices ENABLE ROW LEVEL SECURITY; -- Helpers CREATE POLICY "Workers insert own invoices" ON worker_invoices FOR INSERT TO authenticated WITH CHECK (worker_id = auth.uid()); CREATE POLICY "Workers read own invoices" ON worker_invoices FOR SELECT TO authenticated USING (worker_id = auth.uid()); CREATE POLICY "Admins manage all invoices" ON worker_invoices FOR ALL TO authenticated USING (current_user_is_admin()) WITH CHECK (current_user_is_admin()); CREATE INDEX IF NOT EXISTS worker_invoices_worker_idx ON worker_invoices(worker_id); CREATE INDEX IF NOT EXISTS worker_invoices_period_idx ON worker_invoices(period_start);
Run once to enable the "Offer to workers" feature on jobs and push notifications with deep-linking:
-- Allow admin to offer jobs to all workers ALTER TABLE jobs ADD COLUMN IF NOT EXISTS is_offered boolean DEFAULT false;
| Item | Unit | Min | Max |
|---|
| Item | Unit | Min | Max |
|---|
| Item | Unit | Min | Max |
|---|
Each document downloads as an HTML file — open it in your browser, then use Print → Save as PDF.
You can go back and edit, then regenerate.
Are you sure you want to permanently delete ?
This will also delete all surveys and sign-offs linked to this job. This cannot be undone.
Get push notifications when you're assigned a job or a new job is available to claim.
Add items to each room by searching below. Unknown items can be added — they'll be flagged ⚠ on the sign-off sheet.
Survey the property room by room to generate your personalised task list and kit checklist.
Once you've completed the clean, submit your sign-off here.
By sending this invoice you confirm the hours shown are correct. Your manager will review and approve before payment is made.
Your full cleaning schedule for this site — tasks, frequencies and equipment.
Before starting your end-of-tenancy clean — survey the property room by room to generate your personalised task list and kit checklist.
Tick off completed tasks, record stock levels and add any notes. Submit when the clean is done.
Issues reported at this venue. Tick to mark resolved, or add new ones below.
Record any out-of-pocket expenses from this visit — include the receipt, amount and reason.
Post this job to workers' Available Jobs tab so someone can claim it. Workers receive a push notification instantly.