Skip to content

Migratie naar Beam Stack

Werkbon migreert van WordPress + WPForms + ACF naar de Beam stack:

Beam Stack (bestaand) Werkbon (nieuw)
───────────────────── ───────────────
Dashboard app Vite + React 19 + SWR Zelfde stack
API Hono op CF Workers Zelfde stack
Database Supabase (PostgreSQL + RLS) Zelfde instance
Auth Supabase Auth Zelfde auth
Storage Cloudflare R2 Werkbon-media bucket
Cache Cloudflare KV Werkbon-cache namespace
Monitoring Sentry (toucan-js) Zelfde project

De volledige middleware chain uit /apps/api/src/middleware/:

MiddlewareBestandWat het doetAanpassing nodig
Sentrysentry.tsError tracking met toucan-js, strips auth headersGeen
Loggerlogger.tsStructured JSON logging, requestId, alleen slow/errorsGeen
CORScors.tsSingle origin + localhost dev, 24h preflight cacheDASHBOARD_ORIGIN aanpassen
Authauth.tsBearer token → Supabase getUser → userId + 2 clientsGeen
Rate Limitrate-limit.tsSliding window 100/min, in-memory MapLimieten aanpassen

Hoe auth werkt:

Client → Authorization: Bearer <supabase_jwt>
auth.ts extraheert token
supabase.auth.getUser(token) → user object
Twee clients in context:
c.get('supabase') → user-scoped (RLS enforced)
c.get('adminSupabase') → service role (bypasses RLS)

Elke route volgt hetzelfde patroon:

// 1. Input validatie (Zod)
const parsed = schema.safeParse(body)
if (!parsed.success) return c.json({ error: 'Ongeldige invoer' }, 400)
// 2. Database operatie (RLS of admin)
const { data, error } = await supabase.from('table').select()...
if (error) return c.json({ error: 'Fout' }, 500)
// 3. Fire-and-forget async (cache invalidatie, webhooks)
c.executionCtx.waitUntil(asyncWork())
// 4. Response
return c.json({ data })

Error handling patroon:

  • 400: Validatie (user input)
  • 401: Niet geauthenticeerd
  • 404: Niet gevonden (RLS filtert automatisch)
  • 429: Rate limited
  • 500: Server error (met console.error voor Sentry)

Dashboard Patterns — kopieer architectuur

Section titled “Dashboard Patterns — kopieer architectuur”
PatroonBeam ImplementatieWerkbon Gebruik
Auth flowSupabase AuthProvider + onAuthStateChangeIdentiek
Data fetchingSWR hooks + service layer → SupabaseIdentiek
API callsTyped Hono client (api-client.ts)Identiek
ModalsAnimatePresence + scroll lock + escape stackIdentiek
Toastreact-hot-toast + custom animationsIdentiek
Error boundarySentry + deferred initIdentiek
LayoutSidebar + main content, auth checkAanpassen UI
RoutingReact Router 7, lazy loading, 3 layoutsAanpassen routes
StateSWR voor data, useState voor forms, localStorage voor prefsIdentiek
ListsClient-side filter/sort, view mode toggle, bulk actionsAanpassen kolommen

Data flow patroon:

Component → useSWR hook → service functie → Supabase query
mutate() voor optimistic updates

SWR configuratie (alle hooks):

  • revalidateOnFocus: false
  • revalidateOnReconnect: false
  • dedupingInterval: 30000 (30s cache)
  • keepPreviousData: true

Supabase Schema Patterns — kopieer conventies

Section titled “Supabase Schema Patterns — kopieer conventies”
ConventieBeam PatroonWerkbon
Primary keysUUID (gen_random_uuid())Zelfde
Timestampscreated_at, updated_at TIMESTAMPTZ + triggerZelfde
Soft deletedeleted_at TIMESTAMPTZ + partial indexZelfde
EnumsTEXT met CHECK constraint (niet PG ENUM)Zelfde
Flexible dataJSONB kolommen (blocks, design_tokens)materials, description als JSONB
Ownershipsite_id → sites → owner_id → auth.usersZelfde keten
RLSHelper functies (get_team_owner_id, are_teammates)Kopieer + uitbreid
IndexesFK indexes + composite op (site_id, status, date)Zelfde patroon
NamingPlural snake_case tabellen, snake_case kolommenZelfde

De huidige WP-implementatie heeft 7 structurele problemen die in het nieuwe model worden opgelost. Daarnaast is de “melding/job” gesplitst in twee entiteiten: melding (binnenkomend signaal) en klus (geaccepteerd werk).

#Probleem (WP)Oplossing (Supabase)
1Contact data 3x gekopieerdAlleen contact_id FK, data via JOIN
2Melding + klus in 1 entiteitGesplitst: reports (triage) + jobs (uitvoering)
3Melding:Werkbon geforceerd 1:11:N:N (melding → klussen → werkbonnen)
4Status model te simpel (0/1/2)Per entiteit eigen model (5 + 7 + 3 statussen)
5Geen audit trailactivity_log met triggers
6Werkbon kan niet als conceptstatus: 'draft' met auto-save
7Adres duplicatieAdres op contact, optionele override op klus

Zie Datamodel (Nieuw) voor het volledige schema met alle tabellen, indexes, RLS policies, triggers, views, flows en overerving patronen.

contacts (klanten) → Single source of truth voor adres + contactinfo
reports (meldingen) → Triage: new → accepted/rejected/merged/on_hold
↑ (optioneel)
jobs (klussen) → Uitvoering: open → assigned → scheduled → in_progress → completed
work_orders (werkbonnen) → Documentatie: draft → submitted → approved
activity_log → Audit trail voor alle entiteiten
addresses → Lookup database voor adres autocomplete

Volledig schema: Datamodel (Nieuw)

Het oude schema hieronder is vervangen door het 4-entiteiten model in het Datamodel document.

CREATE TABLE contacts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
site_id UUID NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
type TEXT NOT NULL DEFAULT 'particulier'
CHECK (type IN ('maasdelta', 'particulier', 'zakelijk')),
company TEXT,
firstname TEXT,
lastname TEXT,
email TEXT,
phone TEXT, -- E.164 format
zipcode TEXT,
streetname TEXT,
house_number TEXT,
addition TEXT,
city TEXT,
moneybird_id TEXT,
note TEXT,
origin TEXT DEFAULT 'app'
CHECK (origin IN ('app', 'api', 'moneybird')),
created_by UUID REFERENCES auth.users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);

Verschil met WP: origin is een echte enum i.p.v. vrij tekstveld. house_number i.p.v. number (duidelijker). created_by toegevoegd.

CREATE TABLE jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
site_id UUID NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
-- Relaties: alleen FK, geen gekopieerde data
contact_id UUID REFERENCES contacts(id) ON DELETE SET NULL,
-- Status: uitgebreid model
status TEXT NOT NULL DEFAULT 'open'
CHECK (status IN ('open', 'assigned', 'scheduled', 'in_progress', 'completed', 'on_hold', 'cancelled')),
-- Type erft van contact, kan overschreven worden
type TEXT NOT NULL DEFAULT 'particulier'
CHECK (type IN ('maasdelta', 'particulier', 'zakelijk')),
-- Adres: kan afwijken van contact (ander werkadres)
address_override BOOLEAN DEFAULT false,
streetname TEXT,
house_number TEXT,
addition TEXT,
zipcode TEXT,
city TEXT,
-- Planning
scheduled_date DATE,
scheduled_time_start TIME,
scheduled_time_end TIME,
-- Inhoud
description TEXT,
note TEXT,
-- Toewijzing
assigned_to UUID[] DEFAULT '{}',
-- Meta
created_by UUID REFERENCES auth.users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);

Verschillen met WP:

  • address_override: als false, wordt het adres van het contact gebruikt. Geen duplicatie tenzij expliciet overschreven.
  • status: 7 waarden i.p.v. 3. Maakt planning en tracking mogelijk.
  • scheduled_time_start/end: tijdslots voor planning.
  • Geen contact velden (firstname, email, etc.) — alleen contact_id.
CREATE TABLE work_orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
site_id UUID NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
-- Relaties: 1 melding kan N werkbonnen hebben
job_id UUID NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
-- Status: concept mogelijk
status TEXT NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'submitted', 'approved')),
-- Datum & tijd
date DATE NOT NULL DEFAULT CURRENT_DATE,
-- Werkzaamheden (gestructureerd)
work_items JSONB NOT NULL DEFAULT '[]',
-- [{location: [...], work: "...", time: 1.5, date: "2026-04-05"}]
-- Materiaal (gestructureerd)
materials JSONB NOT NULL DEFAULT '[]',
-- [{id: "mat_001", label: "PVC Buis 110mm", qty: 2}]
-- Overig
note TEXT,
signature_url TEXT,
-- Toewijzing (erft van job maar kan afwijken)
completed_by UUID REFERENCES auth.users(id),
-- Meta
created_by UUID REFERENCES auth.users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);

Verschillen met WP:

  • job_id NOT NULL + ON DELETE CASCADE: werkbon kan niet los bestaan.
  • status: 'draft': auto-save mogelijk, monteur kan later terugkomen.
  • contact_id is optioneel: als NULL, erft de werkbon het contact van de melding. Als gevuld, overschrijft het (bijv. ander contactpersoon op locatie).
  • Geen type veld: erft van job → contact.
  • work_items vervangt zowel description als description_json (1 bron van waarheid).
  • materials vervangt zowel material als material_json.
  • completed_by i.p.v. assigned_to (wie heeft het daadwerkelijk gedaan).

Het contact is de single source of truth. Elke entiteit kan optioneel een ander contact gebruiken:

Contact (bron van waarheid)
Job (melding)
├─ contact_id → primair contact (verplicht)
└─ Work Order (werkbon)
├─ contact_id → NULL: erft van job
└─ contact_id → gevuld: ander contact (bijv. contactpersoon op locatie)

Resolutie in de API/frontend:

function getEffectiveContact(workOrder: WorkOrder, job: Job): Contact {
// Werkbon heeft eigen contact? Gebruik dat.
if (workOrder.contact_id) return workOrder.contact;
// Anders: erft van melding
return job.contact;
}

In Supabase query (view):

CREATE VIEW work_order_details AS
SELECT
wo.*,
-- Effectief contact: werkbon override OF melding contact
COALESCE(wo.contact_id, j.contact_id) AS effective_contact_id,
-- Contact data via JOIN
c.firstname, c.lastname, c.company, c.email, c.phone,
c.streetname, c.house_number, c.addition, c.zipcode, c.city
FROM work_orders wo
JOIN jobs j ON wo.job_id = j.id
JOIN contacts c ON c.id = COALESCE(wo.contact_id, j.contact_id);

UX in het formulier:

  1. Bij werkbon aanmaken: contact wordt getoond (overgeeerfd van melding)
  2. “Ander contact gebruiken” toggle → ContactCombobox verschijnt
  3. Bij selectie: work_order.contact_id wordt gevuld
  4. Bij toggle uit: work_order.contact_id wordt NULL (terug naar overerving)

Dit patroon werkt ook voor toekomstige entiteiten (facturen, offertes) zonder extra complexiteit.

Zelfde patroon als contact:

Contact
├─ adres (standaard locatie klant)
Job (melding)
├─ address_override = false → adres van contact
└─ address_override = true → eigen adres (ander werkadres)
├─ streetname, house_number, addition, zipcode, city

Resolutie:

-- Effectief adres: job override OF contact adres
SELECT
CASE WHEN j.address_override THEN j.streetname ELSE c.streetname END AS streetname,
CASE WHEN j.address_override THEN j.house_number ELSE c.house_number END AS house_number,
CASE WHEN j.address_override THEN j.zipcode ELSE c.zipcode END AS zipcode,
CASE WHEN j.address_override THEN j.city ELSE c.city END AS city
FROM jobs j
JOIN contacts c ON c.id = j.contact_id;

Werkbonnen hebben geen eigen adres — ze erven altijd van de melding.

CREATE TABLE activity_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
site_id UUID NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
entity_type TEXT NOT NULL
CHECK (entity_type IN ('job', 'work_order', 'contact')),
entity_id UUID NOT NULL,
action TEXT NOT NULL
CHECK (action IN (
'created', 'updated', 'deleted',
'status_changed', 'assigned', 'unassigned',
'work_order_added', 'work_order_submitted',
'webhook_sent', 'webhook_failed'
)),
old_value JSONB,
new_value JSONB,
meta JSONB, -- Extra context (bijv. webhook response)
user_id UUID REFERENCES auth.users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Nieuw: Elke statuswijziging, toewijzing, en werkbon-toevoeging wordt gelogd. Maakt audit trail, tijdlijn weergave, en debugging mogelijk.

CREATE TABLE addresses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
site_id UUID NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
streetname TEXT NOT NULL,
house_number TEXT NOT NULL,
addition TEXT DEFAULT '',
zipcode TEXT NOT NULL,
city TEXT NOT NULL,
is_maasdelta BOOLEAN DEFAULT false,
UNIQUE (site_id, zipcode, house_number, addition)
);

Ongewijzigd — dit model werkte al goed.

-- Auto-update timestamps
CREATE TRIGGER update_contacts_updated_at
BEFORE UPDATE ON contacts FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- Idem voor jobs, work_orders
-- Activity log bij status wijziging
CREATE OR REPLACE FUNCTION log_job_status_change()
RETURNS TRIGGER AS $$
BEGIN
IF OLD.status IS DISTINCT FROM NEW.status THEN
INSERT INTO activity_log (site_id, entity_type, entity_id, action, old_value, new_value, user_id)
VALUES (
NEW.site_id, 'job', NEW.id, 'status_changed',
jsonb_build_object('status', OLD.status),
jsonb_build_object('status', NEW.status),
auth.uid()
);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public;
CREATE TRIGGER job_status_change_trigger
AFTER UPDATE ON jobs FOR EACH ROW
EXECUTE FUNCTION log_job_status_change();
-- Contacts
CREATE INDEX idx_contacts_site ON contacts(site_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_contacts_email ON contacts(site_id, email) WHERE deleted_at IS NULL;
CREATE INDEX idx_contacts_moneybird ON contacts(moneybird_id) WHERE moneybird_id IS NOT NULL;
CREATE INDEX idx_contacts_zipcode ON contacts(site_id, zipcode) WHERE deleted_at IS NULL;
CREATE INDEX idx_contacts_type ON contacts(site_id, type) WHERE deleted_at IS NULL;
-- Jobs
CREATE INDEX idx_jobs_site_status ON jobs(site_id, status, created_at DESC) WHERE deleted_at IS NULL;
CREATE INDEX idx_jobs_contact ON jobs(contact_id);
CREATE INDEX idx_jobs_assigned ON jobs(site_id, status) WHERE status IN ('assigned', 'scheduled', 'in_progress');
CREATE INDEX idx_jobs_scheduled ON jobs(site_id, scheduled_date) WHERE scheduled_date IS NOT NULL AND status NOT IN ('completed', 'cancelled');
-- Work Orders
CREATE INDEX idx_work_orders_job ON work_orders(job_id);
CREATE INDEX idx_work_orders_draft ON work_orders(created_by, status) WHERE status = 'draft';
CREATE INDEX idx_work_orders_site_date ON work_orders(site_id, date DESC) WHERE deleted_at IS NULL;
-- Activity Log
CREATE INDEX idx_activity_entity ON activity_log(entity_type, entity_id, created_at DESC);
CREATE INDEX idx_activity_site ON activity_log(site_id, created_at DESC);
-- Addresses
CREATE INDEX idx_addresses_site_zip ON addresses(site_id, zipcode);
CREATE INDEX idx_addresses_street ON addresses(site_id, streetname);

Partial indexes (WHERE clause) besparen opslagruimte en versnellen queries door verwijderde/voltooide records uit te sluiten.

Hergebruikt Beam helper functies: get_team_owner_id(), are_teammates(), get_user_role().

-- Helper: sites waar de gebruiker toegang tot heeft
CREATE OR REPLACE FUNCTION user_site_ids()
RETURNS SETOF UUID AS $$
SELECT s.id FROM sites s
WHERE s.owner_id = get_team_owner_id(auth.uid())
AND s.deleted_at IS NULL;
$$ LANGUAGE sql SECURITY DEFINER STABLE SET search_path = public;
-- Contacts: team kan lezen en schrijven
CREATE POLICY "Team read contacts" ON contacts
FOR SELECT USING (site_id IN (SELECT user_site_ids()) AND deleted_at IS NULL);
CREATE POLICY "Team manage contacts" ON contacts
FOR ALL USING (site_id IN (SELECT user_site_ids()));
-- Jobs: team kan lezen, alleen owner/admin kan verwijderen
CREATE POLICY "Team read jobs" ON jobs
FOR SELECT USING (site_id IN (SELECT user_site_ids()) AND deleted_at IS NULL);
CREATE POLICY "Team create jobs" ON jobs
FOR INSERT WITH CHECK (site_id IN (SELECT user_site_ids()));
CREATE POLICY "Team update jobs" ON jobs
FOR UPDATE USING (site_id IN (SELECT user_site_ids()));
CREATE POLICY "Admin delete jobs" ON jobs
FOR DELETE USING (
site_id IN (SELECT user_site_ids())
AND get_user_role(auth.uid(), get_team_owner_id(auth.uid())) IN ('owner', 'admin')
);
-- Work Orders: iedereen in team kan CRUD, drafts alleen zichtbaar voor eigenaar
CREATE POLICY "Team read work_orders" ON work_orders
FOR SELECT USING (
site_id IN (SELECT user_site_ids())
AND deleted_at IS NULL
AND (status != 'draft' OR created_by = auth.uid())
);
CREATE POLICY "Team manage work_orders" ON work_orders
FOR ALL USING (site_id IN (SELECT user_site_ids()));
-- Activity Log: read-only voor team, insert via triggers
CREATE POLICY "Team read activity" ON activity_log
FOR SELECT USING (site_id IN (SELECT user_site_ids()));
-- Addresses: read-only voor team, admin kan importeren
CREATE POLICY "Team read addresses" ON addresses
FOR SELECT USING (site_id IN (SELECT user_site_ids()));
CREATE POLICY "Admin manage addresses" ON addresses
FOR ALL USING (
site_id IN (SELECT user_site_ids())
AND get_user_role(auth.uid(), get_team_owner_id(auth.uid())) IN ('owner', 'admin')
);

Belangrijke RLS details:

  • Drafts zijn alleen zichtbaar voor de eigenaar (created_by = auth.uid())
  • Delete op jobs is beperkt tot owner/admin (vervangt het ACF admin veld)
  • Activity log is read-only via RLS, writes gaan via triggers (SECURITY DEFINER)
  • Addresses import is admin-only
WP (WPForms)Nieuw (React)ComplexiteitVerbetering
Form 40 (werkbon)WorkOrderFormMediumErft adres/contact van job — veel minder velden
Form 3493 (melding)JobFormMediumcreate/edit + uitgebreid status model
Contact search modalContactComboboxLaagSupabase full-text search, geen apart modal
Adres Select2 chainAddressAutocompleteMediumZelfde 4-staps cascade maar in React
Werkzaamheden modalWorkItemsEditorMedium@dnd-kit i.p.v. jQuery sortable
Materiaal tabelMaterialEditorLaagCombobox + qty, geen aparte modal
Overview panelLive in form (React state)LaagGeen polling nodig, React re-render
Verify checkboxZod schema validatieLaagInline errors i.p.v. scroll-to-field
Auto-save draftLaagNieuw: periodic save, monteur kan later terugkomen
Job context sidebarMediumNieuw: melding beschrijving + eerdere werkbonnen
WP (show-jobs.php)Nieuw (React)Basis in BeamVerbetering
Meldingen lijstJobsPage + useJobs hookPagesPage.tsxFilter op uitgebreide statussen
Rapporten lijstReportsPage + useWorkOrders hookPagesPage.tsxGroeperen op maand + export
Status filtersStatusFilter (checkboxes)PagesPage.tsx7 statussen i.p.v. 3
Type selectTypeFilter (select)PagesPage.tsxZelfde
PaginatieServer-side cursorNieuwBeam doet client-side, Werkbon heeft meer data
Modal (status/delete)JobDetailSheetConfirmModalVerbeterd: toont activity log + werkbonnen
JobTimelineNieuwNieuw: chronologisch overzicht per melding
Dashboard KPIsNieuwNieuw: open/in behandeling/afgerond tellers
WP (Make.com webhooks)NieuwAanpak
Contact → Make → MoneybirdSupabase webhook of CF Worker cronDatabase trigger of API route
File → Make → factuurSupabase webhook of CF Worker cronZelfde
Debounce (3s transient)Supabase function of KV flagEenvoudiger met database
Website-origin checkKolom origin op contactsDirect in RLS/query
WP (roles.php)Nieuw (Supabase)Mapping
Beheerder (administrator)team_members role: ‘owner’ of ‘admin’Direct
Monteur (subscriber)team_members role: ‘member’Direct
ACF admin veldteam_members role checkIn RLS policy
ACF employer velduser_metadata of aparte tabelSupabase user metadata
30-dagen sessieSupabase auth configConfigureerbaar
Beam ComponentReden
Block editor (Zustand store)Werkbon heeft geen pagebuilder
Pattern systemNiet relevant
Brand managementNiet relevant
Media library (R2 + thumbnails)Werkbon heeft alleen handtekeningen
Site domains (CF Pages)Werkbon is single-domain
Public site (Astro SSR)Werkbon is een gesloten app
Stock photos (Unsplash/Pexels)Niet relevant
AI content generationNiet relevant
Video (Bunny Stream)Niet relevant
apps/werkbon/ # Nieuwe app in monorepo
├── src/
│ ├── main.tsx # Entry + Sentry (kopieer van dashboard)
│ ├── routes.tsx # React Router 7 (vereenvoudigd)
│ ├── layouts/
│ │ ├── AuthLayout.tsx # Login/register (kopieer)
│ │ └── AppLayout.tsx # Sidebar + main (aanpassen)
│ ├── pages/
│ │ ├── DashboardPage.tsx # KPIs + snelkoppelingen
│ │ ├── ReportsPage.tsx # Meldingen triage (new/accepted/rejected)
│ │ ├── JobsPage.tsx # Klussen lijst + filters + planning
│ │ ├── JobFormPage.tsx # Klus create/edit + melding koppelen
│ │ ├── WorkOrderFormPage.tsx # Werkbon invullen + auto-save draft
│ │ ├── ReportsPage.tsx # Rapporten + filters + export
│ │ ├── ContactsPage.tsx # Contacten CRUD
│ │ └── SettingsPage.tsx # Gebruikers + instellingen
│ ├── components/
│ │ ├── ui/ # Modal, Toast, Skeleton (kopieer)
│ │ ├── forms/
│ │ │ ├── AddressAutocomplete.tsx # 4-staps cascade
│ │ │ ├── ContactCombobox.tsx # Inline zoeken (geen apart modal)
│ │ │ ├── WorkItemsEditor.tsx # @dnd-kit drag-drop
│ │ │ └── MaterialEditor.tsx # Categorie + qty
│ │ ├── jobs/
│ │ │ ├── JobList.tsx
│ │ │ ├── JobFilters.tsx # 7 statussen
│ │ │ ├── JobDetailSheet.tsx # Detail + timeline + werkbonnen
│ │ │ └── JobTimeline.tsx # Activity log chronologisch
│ │ └── work-orders/
│ │ ├── WorkOrderPreview.tsx # Live preview (React state)
│ │ ├── WorkOrderDraftBanner.tsx # "Je hebt een concept" banner
│ │ └── JobContextSidebar.tsx # Melding info + eerdere werkbonnen
│ ├── lib/
│ │ ├── auth-context.tsx # Kopieer van dashboard
│ │ ├── api-client.ts # Kopieer, pas endpoints aan
│ │ ├── hooks/
│ │ │ ├── use-reports.ts # SWR: meldingen lijst + triage
│ │ │ ├── use-jobs.ts # SWR: klussen lijst + planning
│ │ │ ├── use-work-orders.ts
│ │ │ ├── use-contacts.ts
│ │ │ ├── use-addresses.ts
│ │ │ └── use-activity-log.ts
│ │ └── supabase/
│ │ ├── reports.ts # Service: meldingen CRUD
│ │ ├── jobs.ts # Service: klussen CRUD
│ │ ├── work-orders.ts
│ │ ├── contacts.ts
│ │ ├── addresses.ts
│ │ └── activity.ts
│ └── types.ts
├── vite.config.ts # Kopieer, pas proxy aan
├── tailwind.config.js # Kopieer, pas kleuren aan
├── tsconfig.json # Kopieer
└── package.json # Subset van dashboard deps
apps/werkbon-api/ # Nieuwe API in monorepo
├── src/
│ ├── index.ts # Kopieer middleware stack
│ ├── types.ts # Aanpassen bindings
│ ├── middleware/ # 100% kopieer
│ ├── routes/
│ │ ├── reports.ts # CRUD meldingen + POST /incoming (webhook marketingsite)
│ │ ├── jobs.ts # CRUD klussen
│ │ ├── work-orders.ts # CRUD werkbonnen
│ │ ├── contacts.ts # CRUD + upsert + search + matching
│ │ ├── addresses.ts # Lookup chain (Select2 cascade)
│ │ └── webhooks.ts # Outgoing: Make.com / Moneybird
│ ├── lib/
│ │ ├── supabase.ts # Kopieer
│ │ └── kv-cache.ts # Kopieer, pas keys aan
│ └── scheduled/
│ ├── webhook-sync.ts # Contact/file sync
│ └── cleanup.ts # Soft delete purge
├── wrangler.toml # Nieuwe bindings
└── package.json
FaseWatBasisComplexiteit
1Monorepo setup (werkbon + werkbon-api)pnpm workspacesLaag
2Supabase schema + migraties + RLSBeam migratie patroonMedium
3API middleware stack + auth100% kopieerLaag
4Dashboard auth + layout + routingKopieer + aanpassenLaag
5Contacts CRUD + zoekenPagesPage patroonMedium
6Address autocompleteNieuwMedium
7Jobs CRUD + uitgebreid status model + timelinePagesPage + activity logMedium
8Work order form + auto-save draftNieuwHoog
9WorkItemsEditor + MaterialEditor@dnd-kitHoog
10Contact/adres overerving in formsCOALESCE patternMedium
11Job context sidebar bij werkbonSWR queryLaag
12Dashboard KPIsSupabase aggregateLaag
13Make.com / Moneybird webhook integratieCF Worker cronMedium
14Data migratie van WP → SupabaseScriptMedium
15Rapporten + exportNieuwMedium
Week -2: Nieuwe app draait read-only naast WP (data sync test)
Week -1: Dual-write: WP schrijft + sync naar Supabase
Dag 0: Marketing site webhook → nieuwe API
WP in read-only modus
Team test 1 dag op nieuwe app
Dag +1: WP offline, nieuwe app is primary
Dag +7: Bevestiging → WP shutdown
Dag +30: WP database backup verwijderen
Fase 1: contacts (basis, geen afhankelijkheden)
└─ wp_posts + wp_postmeta WHERE post_type='contact' → contacts tabel
└─ moneybird_id 1:1 meenemen (CRITICAL)
└─ Bewaar mapping: wp_post_id → supabase_uuid
Fase 2: reports aanmaken vanuit bestaande jobs met source='website'
└─ Jobs met _acs_origin='website' → reports tabel
Fase 3: jobs → jobs tabel (contact_id FK via mapping)
└─ Status mapping: 0→'open', 1→'in_progress', 2→'completed'
Fase 4: files → work_orders (job_id + contact_id via mapping)
└─ Transform file-description-json → work_items JSONB
└─ Transform file-material-json → materials JSONB
Fase 5: addresses seed vanuit wp_werkbon_addresses
└─ Direct kopie, is_maasdelta behouden
Fase 6: activity_log seed vanuit WP timestamps
-- 1. Moneybird ID continuïteit
SELECT count(*) FROM contacts WHERE moneybird_id IS NOT NULL;
-- Moet overeenkomen met WP count
-- 2. Referentiële integriteit
SELECT count(*) FROM jobs WHERE contact_id NOT IN (SELECT id FROM contacts);
-- Moet 0 zijn
SELECT count(*) FROM work_orders WHERE job_id NOT IN (SELECT id FROM jobs);
-- Moet 0 zijn
-- 3. JSON data structuur
SELECT count(*) FROM work_orders
WHERE work_items != '[]'::jsonb
AND NOT (work_items->0 ? 'work');
-- Moet 0 zijn (alle items hebben 'work' key)
-- 4. Totalen
-- wp_jobs_count == supabase_jobs_count + supabase_reports_count
  1. WP database backup bewaren tot dag +30
  2. WP blijft 30 dagen bereikbaar (read-only)
  3. Bij rollback: WP reactiveren + marketing site webhook terugzetten
  4. Supabase data na cut-over: sync script naar WP klaarhebben (ontwerp, niet bouwen)

De incoming webhook (POST /reports/incoming) moet buiten de auth middleware chain vallen:

apps/werkbon-api/src/index.ts
// 1. Publieke routes (VOOR auth middleware)
const publicRoutes = new Hono()
publicRoutes.post('/reports/incoming', incomingWebhookHandler)
// → eigen rate limiting (20/min), shared secret check, geen Bearer auth
app.route('/', publicRoutes)
// 2. Auth middleware (voor alle overige routes)
app.use('*', authMiddleware)
// 3. Authenticated routes
app.route('/reports', reports) // CRUD (niet /incoming)
app.route('/jobs', jobs)
app.route('/work-orders', workOrders)
app.route('/contacts', contacts)

Routes zijn dun (validatie + response). Business logic zit in services:

routes/ services/
├── reports.ts ──calls──→ ├── contact.service.ts
├── jobs.ts ──calls──→ │ └─ findOrCreate, matching, normalisatie
├── work-orders.ts ─calls──→ ├── webhook.service.ts
├── contacts.ts ──calls──→ │ └─ outgoing webhook + Queue
└── (incoming) ──calls──→ ├── moneybird.service.ts
│ └─ detailsAttributes builder, contact sync
├── address.service.ts
│ └─ lookup, normalisatie, Maasdelta detectie
└── phone.service.ts
└─ E.164 normalisatie (port van WP)

Dit voorkomt dat routes/reports.ts (incoming webhook) en routes/contacts.ts (CRUD) dezelfde findOrCreate logica dupliceren.

Features die minimaal moeten werken voor de WP-app vervangen kan worden:

FeatureWP BronStatus
Contact CRUD + zoekenapi-contact.phpGedocumenteerd
Contact matching (email + adres)wpforms-add-or-update-contact.phpGedocumenteerd
Contact upsert (Moneybird terugkoppeling)api-contact.php /upsertGedocumenteerd
Melding aanmaken (handmatig + webhook)api-job.php + form 3493Gedocumenteerd
Klus inplannen + toewijzenshow-jobs.php modalGedocumenteerd
Werkbon invullen + materiaal + werkzaamhedenform 40 + form-work.phpGedocumenteerd
Werkbon handtekeningWPForms signature fieldVia attachments tabel
Maasdelta type detectiedb-addresses.phpVia addresses tabel
Adres autocomplete (4-staps cascade)form-addresses.phpGedocumenteerd
Telefoon E.164 normalisatiewpforms-normalize-phone.phpphone.service.ts
Make.com contact webhookapi-contact.php webhookVia Queue
Make.com file webhook + factuurregelsapi-file.php webhookVia Queue + moneybird.service
Website-origin sync delayapi-contact.php regel 48-54Gedocumenteerd
Employer namen op werkbonapi-file.php payloadVia job_assignments JOIN
Debounce bij meervoudige savesapi-contact.php 3s transientNiet nodig (Queue dedupliceert)
ServicePlanKosten/maandToelichting
Cloudflare WorkersPaid$510M requests, 30ms CPU
Cloudflare R2Free$0< 10GB jarenlang
Cloudflare KVFree$0< 100K reads/dag
Cloudflare QueuesFree/Paid~$0< 1M berichten/maand
SupabasePro (gedeeld met Beam)$0 extraAl betaald
Totaal~$5/maand
apps/dashboard/src/lib/auth-context.tsx → Auth provider
apps/dashboard/src/lib/auth.ts → Login/logout helpers
apps/dashboard/src/lib/api-client.ts → Typed Hono wrapper
apps/dashboard/src/lib/hooks/use-pages.ts → SWR hook patroon
apps/dashboard/src/lib/supabase/pages.ts → Service layer patroon
apps/dashboard/src/components/ui/modal.tsx → Modal + AnimatePresence
apps/dashboard/src/components/toast.tsx → Toast systeem
apps/dashboard/src/main.tsx → Entry + Sentry
apps/dashboard/vite.config.ts → Build config
apps/api/src/index.ts → Middleware ordering
apps/api/src/middleware/* → Alle 5 middleware
apps/api/src/lib/supabase.ts → Client helpers
apps/api/src/lib/kv-cache.ts → Cache pattern
apps/api/src/routes/pages.ts → Route pattern
packages/shared/src/utils.ts → cn() helper