Migratie naar Beam Stack
Doelarchitectuur
Section titled “Doelarchitectuur”Werkbon migreert van WordPress + WPForms + ACF naar de Beam stack:
Beam Stack (bestaand) Werkbon (nieuw) ───────────────────── ───────────────Dashboard app Vite + React 19 + SWR Zelfde stackAPI Hono op CF Workers Zelfde stackDatabase Supabase (PostgreSQL + RLS) Zelfde instanceAuth Supabase Auth Zelfde authStorage Cloudflare R2 Werkbon-media bucketCache Cloudflare KV Werkbon-cache namespaceMonitoring Sentry (toucan-js) Zelfde projectWat direct herbruikbaar is (80-90%)
Section titled “Wat direct herbruikbaar is (80-90%)”API Middleware Stack — 100% kopieerbaar
Section titled “API Middleware Stack — 100% kopieerbaar”De volledige middleware chain uit /apps/api/src/middleware/:
| Middleware | Bestand | Wat het doet | Aanpassing nodig |
|---|---|---|---|
| Sentry | sentry.ts | Error tracking met toucan-js, strips auth headers | Geen |
| Logger | logger.ts | Structured JSON logging, requestId, alleen slow/errors | Geen |
| CORS | cors.ts | Single origin + localhost dev, 24h preflight cache | DASHBOARD_ORIGIN aanpassen |
| Auth | auth.ts | Bearer token → Supabase getUser → userId + 2 clients | Geen |
| Rate Limit | rate-limit.ts | Sliding window 100/min, in-memory Map | Limieten 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)API Route Pattern — kopieer structuur
Section titled “API Route Pattern — kopieer structuur”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. Responsereturn 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”| Patroon | Beam Implementatie | Werkbon Gebruik |
|---|---|---|
| Auth flow | Supabase AuthProvider + onAuthStateChange | Identiek |
| Data fetching | SWR hooks + service layer → Supabase | Identiek |
| API calls | Typed Hono client (api-client.ts) | Identiek |
| Modals | AnimatePresence + scroll lock + escape stack | Identiek |
| Toast | react-hot-toast + custom animations | Identiek |
| Error boundary | Sentry + deferred init | Identiek |
| Layout | Sidebar + main content, auth check | Aanpassen UI |
| Routing | React Router 7, lazy loading, 3 layouts | Aanpassen routes |
| State | SWR voor data, useState voor forms, localStorage voor prefs | Identiek |
| Lists | Client-side filter/sort, view mode toggle, bulk actions | Aanpassen kolommen |
Data flow patroon:
Component → useSWR hook → service functie → Supabase query ↑ mutate() voor optimistic updatesSWR configuratie (alle hooks):
revalidateOnFocus: falserevalidateOnReconnect: falsededupingInterval: 30000(30s cache)keepPreviousData: true
Supabase Schema Patterns — kopieer conventies
Section titled “Supabase Schema Patterns — kopieer conventies”| Conventie | Beam Patroon | Werkbon |
|---|---|---|
| Primary keys | UUID (gen_random_uuid()) | Zelfde |
| Timestamps | created_at, updated_at TIMESTAMPTZ + trigger | Zelfde |
| Soft delete | deleted_at TIMESTAMPTZ + partial index | Zelfde |
| Enums | TEXT met CHECK constraint (niet PG ENUM) | Zelfde |
| Flexible data | JSONB kolommen (blocks, design_tokens) | materials, description als JSONB |
| Ownership | site_id → sites → owner_id → auth.users | Zelfde keten |
| RLS | Helper functies (get_team_owner_id, are_teammates) | Kopieer + uitbreid |
| Indexes | FK indexes + composite op (site_id, status, date) | Zelfde patroon |
| Naming | Plural snake_case tabellen, snake_case kolommen | Zelfde |
Verbeteringen t.o.v. Huidige Architectuur
Section titled “Verbeteringen t.o.v. Huidige Architectuur”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) |
|---|---|---|
| 1 | Contact data 3x gekopieerd | Alleen contact_id FK, data via JOIN |
| 2 | Melding + klus in 1 entiteit | Gesplitst: reports (triage) + jobs (uitvoering) |
| 3 | Melding:Werkbon geforceerd 1:1 | 1:N:N (melding → klussen → werkbonnen) |
| 4 | Status model te simpel (0/1/2) | Per entiteit eigen model (5 + 7 + 3 statussen) |
| 5 | Geen audit trail | activity_log met triggers |
| 6 | Werkbon kan niet als concept | status: 'draft' met auto-save |
| 7 | Adres duplicatie | Adres 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.
Schema Overzicht
Section titled “Schema Overzicht”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 entiteitenaddresses → Lookup database voor adres autocompleteVolledig schema: Datamodel (Nieuw)
Het oude schema hieronder is vervangen door het 4-entiteiten model in het Datamodel document.
Contacten (oud — zie Datamodel Nieuw)
Section titled “Contacten (oud — zie Datamodel Nieuw)”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.
Meldingen (Jobs)
Section titled “Meldingen (Jobs)”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.
Werkbonnen (Work Orders)
Section titled “Werkbonnen (Work Orders)”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_idis optioneel: als NULL, erft de werkbon het contact van de melding. Als gevuld, overschrijft het (bijv. ander contactpersoon op locatie).- Geen
typeveld: erft van job → contact. work_itemsvervangt zoweldescriptionalsdescription_json(1 bron van waarheid).materialsvervangt zowelmaterialalsmaterial_json.completed_byi.p.v.assigned_to(wie heeft het daadwerkelijk gedaan).
Contact Overerving
Section titled “Contact Overerving”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 ASSELECT 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.cityFROM work_orders woJOIN jobs j ON wo.job_id = j.idJOIN contacts c ON c.id = COALESCE(wo.contact_id, j.contact_id);UX in het formulier:
- Bij werkbon aanmaken: contact wordt getoond (overgeeerfd van melding)
- “Ander contact gebruiken” toggle → ContactCombobox verschijnt
- Bij selectie:
work_order.contact_idwordt gevuld - Bij toggle uit:
work_order.contact_idwordt NULL (terug naar overerving)
Dit patroon werkt ook voor toekomstige entiteiten (facturen, offertes) zonder extra complexiteit.
Adres Overerving
Section titled “Adres Overerving”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, cityResolutie:
-- Effectief adres: job override OF contact adresSELECT 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 cityFROM jobs jJOIN contacts c ON c.id = j.contact_id;Werkbonnen hebben geen eigen adres — ze erven altijd van de melding.
Activity Log
Section titled “Activity Log”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.
Adressen Database
Section titled “Adressen Database”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.
Triggers
Section titled “Triggers”-- Auto-update timestampsCREATE 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 wijzigingCREATE 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();Indexes
Section titled “Indexes”-- ContactsCREATE 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;
-- JobsCREATE 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 OrdersCREATE 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 LogCREATE 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);
-- AddressesCREATE 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.
RLS Policies
Section titled “RLS Policies”Hergebruikt Beam helper functies: get_team_owner_id(), are_teammates(), get_user_role().
-- Helper: sites waar de gebruiker toegang tot heeftCREATE 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 schrijvenCREATE 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 verwijderenCREATE 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 eigenaarCREATE 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 triggersCREATE POLICY "Team read activity" ON activity_log FOR SELECT USING (site_id IN (SELECT user_site_ids()));
-- Addresses: read-only voor team, admin kan importerenCREATE 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
adminveld) - Activity log is read-only via RLS, writes gaan via triggers (SECURITY DEFINER)
- Addresses import is admin-only
Feature Mapping: WP → Nieuwe Stack
Section titled “Feature Mapping: WP → Nieuwe Stack”Formulieren
Section titled “Formulieren”| WP (WPForms) | Nieuw (React) | Complexiteit | Verbetering |
|---|---|---|---|
| Form 40 (werkbon) | WorkOrderForm | Medium | Erft adres/contact van job — veel minder velden |
| Form 3493 (melding) | JobForm | Medium | create/edit + uitgebreid status model |
| Contact search modal | ContactCombobox | Laag | Supabase full-text search, geen apart modal |
| Adres Select2 chain | AddressAutocomplete | Medium | Zelfde 4-staps cascade maar in React |
| Werkzaamheden modal | WorkItemsEditor | Medium | @dnd-kit i.p.v. jQuery sortable |
| Materiaal tabel | MaterialEditor | Laag | Combobox + qty, geen aparte modal |
| Overview panel | Live in form (React state) | Laag | Geen polling nodig, React re-render |
| Verify checkbox | Zod schema validatie | Laag | Inline errors i.p.v. scroll-to-field |
| — | Auto-save draft | Laag | Nieuw: periodic save, monteur kan later terugkomen |
| — | Job context sidebar | Medium | Nieuw: melding beschrijving + eerdere werkbonnen |
Lijsten & Filters
Section titled “Lijsten & Filters”| WP (show-jobs.php) | Nieuw (React) | Basis in Beam | Verbetering |
|---|---|---|---|
| Meldingen lijst | JobsPage + useJobs hook | PagesPage.tsx | Filter op uitgebreide statussen |
| Rapporten lijst | ReportsPage + useWorkOrders hook | PagesPage.tsx | Groeperen op maand + export |
| Status filters | StatusFilter (checkboxes) | PagesPage.tsx | 7 statussen i.p.v. 3 |
| Type select | TypeFilter (select) | PagesPage.tsx | Zelfde |
| Paginatie | Server-side cursor | Nieuw | Beam doet client-side, Werkbon heeft meer data |
| Modal (status/delete) | JobDetailSheet | ConfirmModal | Verbeterd: toont activity log + werkbonnen |
| — | JobTimeline | Nieuw | Nieuw: chronologisch overzicht per melding |
| — | Dashboard KPIs | Nieuw | Nieuw: open/in behandeling/afgerond tellers |
Integraties
Section titled “Integraties”| WP (Make.com webhooks) | Nieuw | Aanpak |
|---|---|---|
| Contact → Make → Moneybird | Supabase webhook of CF Worker cron | Database trigger of API route |
| File → Make → factuur | Supabase webhook of CF Worker cron | Zelfde |
| Debounce (3s transient) | Supabase function of KV flag | Eenvoudiger met database |
| Website-origin check | Kolom origin op contacts | Direct in RLS/query |
Auth & Rollen
Section titled “Auth & Rollen”| WP (roles.php) | Nieuw (Supabase) | Mapping |
|---|---|---|
| Beheerder (administrator) | team_members role: ‘owner’ of ‘admin’ | Direct |
| Monteur (subscriber) | team_members role: ‘member’ | Direct |
| ACF admin veld | team_members role check | In RLS policy |
| ACF employer veld | user_metadata of aparte tabel | Supabase user metadata |
| 30-dagen sessie | Supabase auth config | Configureerbaar |
Wat NIET hergebruikt wordt
Section titled “Wat NIET hergebruikt wordt”| Beam Component | Reden |
|---|---|
| Block editor (Zustand store) | Werkbon heeft geen pagebuilder |
| Pattern system | Niet relevant |
| Brand management | Niet 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 generation | Niet relevant |
| Video (Bunny Stream) | Niet relevant |
Voorgestelde App Structuur
Section titled “Voorgestelde App Structuur”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.jsonMigratie Volgorde
Section titled “Migratie Volgorde”| Fase | Wat | Basis | Complexiteit |
|---|---|---|---|
| 1 | Monorepo setup (werkbon + werkbon-api) | pnpm workspaces | Laag |
| 2 | Supabase schema + migraties + RLS | Beam migratie patroon | Medium |
| 3 | API middleware stack + auth | 100% kopieer | Laag |
| 4 | Dashboard auth + layout + routing | Kopieer + aanpassen | Laag |
| 5 | Contacts CRUD + zoeken | PagesPage patroon | Medium |
| 6 | Address autocomplete | Nieuw | Medium |
| 7 | Jobs CRUD + uitgebreid status model + timeline | PagesPage + activity log | Medium |
| 8 | Work order form + auto-save draft | Nieuw | Hoog |
| 9 | WorkItemsEditor + MaterialEditor | @dnd-kit | Hoog |
| 10 | Contact/adres overerving in forms | COALESCE pattern | Medium |
| 11 | Job context sidebar bij werkbon | SWR query | Laag |
| 12 | Dashboard KPIs | Supabase aggregate | Laag |
| 13 | Make.com / Moneybird webhook integratie | CF Worker cron | Medium |
| 14 | Data migratie van WP → Supabase | Script | Medium |
| 15 | Rapporten + export | Nieuw | Medium |
Migratie Strategie
Section titled “Migratie Strategie”Blue-Green Deployment
Section titled “Blue-Green Deployment”Week -2: Nieuwe app draait read-only naast WP (data sync test)Week -1: Dual-write: WP schrijft + sync naar SupabaseDag 0: Marketing site webhook → nieuwe API WP in read-only modus Team test 1 dag op nieuwe appDag +1: WP offline, nieuwe app is primaryDag +7: Bevestiging → WP shutdownDag +30: WP database backup verwijderenData Migratie Script (TypeScript, lokaal)
Section titled “Data Migratie Script (TypeScript, lokaal)”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 timestampsValidatie Queries (post-migratie)
Section titled “Validatie Queries (post-migratie)”-- 1. Moneybird ID continuïteitSELECT count(*) FROM contacts WHERE moneybird_id IS NOT NULL;-- Moet overeenkomen met WP count
-- 2. Referentiële integriteitSELECT 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 structuurSELECT count(*) FROM work_ordersWHERE work_items != '[]'::jsonbAND NOT (work_items->0 ? 'work');-- Moet 0 zijn (alle items hebben 'work' key)
-- 4. Totalen-- wp_jobs_count == supabase_jobs_count + supabase_reports_countRollback Plan
Section titled “Rollback Plan”- WP database backup bewaren tot dag +30
- WP blijft 30 dagen bereikbaar (read-only)
- Bij rollback: WP reactiveren + marketing site webhook terugzetten
- Supabase data na cut-over: sync script naar WP klaarhebben (ontwerp, niet bouwen)
Webhook Routing (auth)
Section titled “Webhook Routing (auth)”De incoming webhook (POST /reports/incoming) moet buiten de auth middleware chain vallen:
// 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 routesapp.route('/reports', reports) // CRUD (niet /incoming)app.route('/jobs', jobs)app.route('/work-orders', workOrders)app.route('/contacts', contacts)Service Layer
Section titled “Service Layer”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.
Feature Parity Checklist
Section titled “Feature Parity Checklist”Features die minimaal moeten werken voor de WP-app vervangen kan worden:
| Feature | WP Bron | Status |
|---|---|---|
| Contact CRUD + zoeken | api-contact.php | Gedocumenteerd |
| Contact matching (email + adres) | wpforms-add-or-update-contact.php | Gedocumenteerd |
| Contact upsert (Moneybird terugkoppeling) | api-contact.php /upsert | Gedocumenteerd |
| Melding aanmaken (handmatig + webhook) | api-job.php + form 3493 | Gedocumenteerd |
| Klus inplannen + toewijzen | show-jobs.php modal | Gedocumenteerd |
| Werkbon invullen + materiaal + werkzaamheden | form 40 + form-work.php | Gedocumenteerd |
| Werkbon handtekening | WPForms signature field | Via attachments tabel |
| Maasdelta type detectie | db-addresses.php | Via addresses tabel |
| Adres autocomplete (4-staps cascade) | form-addresses.php | Gedocumenteerd |
| Telefoon E.164 normalisatie | wpforms-normalize-phone.php | phone.service.ts |
| Make.com contact webhook | api-contact.php webhook | Via Queue |
| Make.com file webhook + factuurregels | api-file.php webhook | Via Queue + moneybird.service |
| Website-origin sync delay | api-contact.php regel 48-54 | Gedocumenteerd |
| Employer namen op werkbon | api-file.php payload | Via job_assignments JOIN |
| Debounce bij meervoudige saves | api-contact.php 3s transient | Niet nodig (Queue dedupliceert) |
Kosten Schatting
Section titled “Kosten Schatting”| Service | Plan | Kosten/maand | Toelichting |
|---|---|---|---|
| Cloudflare Workers | Paid | $5 | 10M requests, 30ms CPU |
| Cloudflare R2 | Free | $0 | < 10GB jarenlang |
| Cloudflare KV | Free | $0 | < 100K reads/dag |
| Cloudflare Queues | Free/Paid | ~$0 | < 1M berichten/maand |
| Supabase | Pro (gedeeld met Beam) | $0 extra | Al betaald |
| Totaal | ~$5/maand |
Bestandsreferenties
Section titled “Bestandsreferenties”Kopieer direct uit Beam
Section titled “Kopieer direct uit Beam”apps/dashboard/src/lib/auth-context.tsx → Auth providerapps/dashboard/src/lib/auth.ts → Login/logout helpersapps/dashboard/src/lib/api-client.ts → Typed Hono wrapperapps/dashboard/src/lib/hooks/use-pages.ts → SWR hook patroonapps/dashboard/src/lib/supabase/pages.ts → Service layer patroonapps/dashboard/src/components/ui/modal.tsx → Modal + AnimatePresenceapps/dashboard/src/components/toast.tsx → Toast systeemapps/dashboard/src/main.tsx → Entry + Sentryapps/dashboard/vite.config.ts → Build config
apps/api/src/index.ts → Middleware orderingapps/api/src/middleware/* → Alle 5 middlewareapps/api/src/lib/supabase.ts → Client helpersapps/api/src/lib/kv-cache.ts → Cache patternapps/api/src/routes/pages.ts → Route pattern
packages/shared/src/utils.ts → cn() helper