Skip to content

Implementatieplan

Dit plan beschrijft de exacte stappen om de Werkbon app te bouwen op de Beam stack. Elke stap verwijst naar concrete bestanden uit de Beam codebase die gekopieerd of aangepast worden.

Fase 0: Infra + regels
├─ Supabase project aanmaken + auth config
├─ Cloudflare setup (Workers, Pages, KV)
├─ DNS: app.acspascal.nl
├─ Sentry project + alerting → angelo.vaudo@gmail.com
└─ Project regels (CLAUDE.md, .claude/rules/)
Fase 1: Fundament (week 1-2)
├─ Monorepo setup
├─ Supabase schema + migraties
└─ API middleware stack
Fase 2: CRUD (week 3-4)
├─ Dashboard auth + layout
├─ Contacten CRUD
└─ Adres autocomplete
Fase 3: Kernflow (week 5-7)
├─ Meldingen + triage
├─ Klussen + inplannen
└─ Werkbon invullen (mobile-first)
Fase 4: Integraties (week 8)
├─ Incoming webhook (marketingsite)
├─ Outgoing webhooks (Moneybird via Queue)
└─ Klant notificaties (email)
Fase 5: Migratie (week 9-10)
├─ Data migratie script
├─ Parallel draai
└─ Cut-over

Nieuw eigen Supabase project aanmaken (niet gedeeld met Beam).

Setup stappen:

  1. Supabase dashboard → New Project
  2. Region: EU West (Frankfurt)
  3. Auth configuratie:
    • Email/password enabled
    • Redirect URLs: https://app.acspascal.nl/auth/callback, http://localhost:5173/auth/callback
    • Session duration: 30 dagen (configureerbaar, vervangt de WP hardcoded 30d)
    • Disable signups (alleen invite-based, beheerder maakt accounts)
  4. Noteer: SUPABASE_URL, SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY
ResourceNaamDoel
Workers projectwerkbon-apiHono API
Pages projectwerkbon-appReact dashboard
KV NamespaceWERKBON_CACHEAdres cache, KPIs
QueueWERKBON_WEBHOOKSOutgoing webhook retry

DNS (app.acspascal.nl):

  • app.acspascal.nl → CNAME naar werkbon-app.pages.dev
  • api.acspascal.nl → Route in wrangler.toml

Wrangler secrets (set via CLI):

Terminal window
wrangler secret put SUPABASE_ANON_KEY
wrangler secret put SUPABASE_SERVICE_ROLE_KEY
wrangler secret put RESEND_API_KEY
wrangler secret put WEBHOOK_SECRET
wrangler secret put SENTRY_DSN
  1. Resend account aanmaken
  2. Domein verificatie: acspascal.nl
    • DKIM record toevoegen aan DNS
    • SPF record toevoegen aan DNS
  3. From address: Werkbon <noreply@acspascal.nl>
  4. API key → wrangler secret put RESEND_API_KEY
  5. Invite email template (dag-1 vereiste, hardcoded HTML zoals Beam):
    • Subject: [naam] heeft je uitgenodigd voor Werkbon
    • Body: uitnodigingslink + korte uitleg
    • Fallback plain text

Dashboard (.env):

VITE_SUPABASE_URL=https://[project].supabase.co
VITE_SUPABASE_ANON_KEY=[anon_key]
VITE_API_URL=https://api.acspascal.nl
VITE_APP_URL=https://app.acspascal.nl
VITE_SENTRY_DSN=[sentry_dsn]
VITE_SENTRY_ENVIRONMENT=production

API (wrangler secrets):

SUPABASE_ANON_KEY
SUPABASE_SERVICE_ROLE_KEY
RESEND_API_KEY
RESEND_FROM_EMAIL=Werkbon <noreply@acspascal.nl>
WEBHOOK_SECRET
SENTRY_DSN

.env.example in repo root:

# Dashboard
VITE_SUPABASE_URL=
VITE_SUPABASE_ANON_KEY=
VITE_API_URL=http://localhost:8787
VITE_APP_URL=http://localhost:5173
VITE_SENTRY_DSN=
VITE_SENTRY_ENVIRONMENT=development

Dashboard (React):

  • @sentry/react met deferred init: requestIdleCallback(() => initSentry(), { timeout: 3000 })
  • SentryErrorBoundary wrapping hele app met fallback UI
  • Error sample rate: 100%, traces: 10%
  • Session replays: 0% normaal, 100% bij errors (maskAllText: true, blockAllMedia: true)
  • PII scrubbing: strip Authorization headers in beforeSend
  • Browser extension noise filter via denyUrls

API (CF Workers):

  • toucan-js (Sentry SDK voor Workers)
  • Strip Authorization/Cookie headers
  • Attach userId + route context bij errors
MetricTargetMeting
Dashboard laden (FCP)< 1.5sLighthouse
Pagina navigatie< 500msSWR cache hit
Werkbon submit< 2sAPI response time
Adres autocomplete< 300msKV cache + Supabase
Auto-save draft< 1sBackground save
Werkbon invullen (monteur)< 60sUser testing
Triage per melding (beheerder)< 15sUser testing
PlatformMinimumDoelgroep
iOS SafariiOS 16+Monteurs (primair)
Chrome Android100+Monteurs (secundair)
Chrome Desktop100+Beheerders
Safari Desktop16+Beheerders

Mobile-first voor monteur flows. Desktop-first voor beheerder flows. Wordt uiteindelijk PWA (service worker + offline cache + homescreen icon).

Kopieer en pas de Beam project regels aan voor Werkbon:

# Werkbon — Project Instructions
Werkbon is een field service management app. Monorepo met twee apps op Cloudflare.
| App | Stack | Domein |
|-----|-------|--------|
| Dashboard | Vite 6 + React 19 + SWR | `app.acspascal.nl` |
| API | Hono 4.7 op CF Workers | `api.acspascal.nl` |
| Shared | Types + utilities | Library |
**Docs:** `docs/` · **Regels:** `.claude/rules/` · **Per-app:** `apps/*/CLAUDE.md`
## Quick Reference
\`\`\`bash
pnpm dev # Dashboard (5173) + API (8787)
pnpm dev:app # Dashboard only
pnpm dev:api # API only
pnpm typecheck # TypeScript alle apps
pnpm lint # ESLint alle apps
pnpm build # Build alle apps
\`\`\`
## Quality Gates
1. `pnpm typecheck` — 0 errors
2. `pnpm lint` — 0 errors, 0 warnings
3. `pnpm build` — voor geraakt(e) app(s)
4. Security review — `.claude/rules/security.md`
5. Documentatie update bij structurele wijzigingen

.claude/rules/ (kopieer van Beam, pas aan)

Section titled “.claude/rules/ (kopieer van Beam, pas aan)”
BestandKopieer vanAanpassing
code-quality.mdBeam code-quality.mdVerwijder Zustand regels (geen editor), voeg Werkbon-specifieke patronen toe
security.mdBeam security.mdVoeg webhook secret check toe, verwijder SSRF (geen externe URL uploads)
documentation.mdBeam documentation.mdPas docs structuur aan voor Werkbon
agents.mdBeam agents.mdVervang beam-* agents door werkbon-* agents
# Werkbon Dashboard — App Instructions
Vite 6 + React 19 SPA op Cloudflare Pages.
## Stack
- React 19 + React Router v7 (lazy routes)
- SWR 2.3 (data fetching, dedup 30s)
- React Hook Form + Zod (forms)
- Tailwind 3.4 + tailwind-merge
- @dnd-kit (drag-drop werkzaamheden)
- Motion 12.6 (animaties)
- Lucide React (iconen)
## Patronen
### SWR Hooks
Alle hooks: `dedupingInterval: 30_000`, `revalidateOnFocus: false`
### API Client
Alle calls via `api-client.ts` — nooit directe fetch
### Error Handling
toast.error() voor API fouten, nooit error.message aan gebruiker tonen
## Routing
- AuthLayout: `/login`, `/forgot-password`, `/reset-password`
- AppLayout: `/`, `/meldingen`, `/klussen`, `/werkbonnen`, `/contacten`, `/instellingen`
- Standalone: `/auth/callback`, `/invite/accept`
## Taal
- UI: Nederlands
- Code comments: Engels
- Variabelen: Engels
# Werkbon API — App Instructions
Hono 4.7 op Cloudflare Workers.
## Middleware volgorde (CRITICAL)
Sentry → Logger → CORS → Health → [Public routes] → Auth → Rate limit → Routes
## Patronen
- Zod validatie op alle input
- supabase = user-scoped (RLS), adminSupabase = service role
- Error: console.error() + generieke JSON response
- Webhook: Queue voor betrouwbare delivery
## Routes
- POST /reports/incoming — webhook (VOOR auth, met secret)
- /reports, /jobs, /work-orders, /contacts, /addresses — CRUD (NA auth)

Kopieer de Beam monorepo structuur en pas aan:

Bron: Beam root package.json + pnpm-workspace.yaml

pnpm-workspace.yaml
packages:
- 'apps/*'
- 'packages/*'
- 'supabase'
// package.json (root)
{
"name": "werkbon",
"private": true,
"scripts": {
"dev": "pnpm --parallel -r dev",
"dev:app": "pnpm --filter @werkbon/app dev",
"dev:api": "pnpm --filter @werkbon/api dev",
"build": "pnpm -r build",
"typecheck": "pnpm -r typecheck",
"lint": "pnpm -r lint"
},
"devDependencies": {
"typescript": "^5.7.0"
},
"engines": {
"node": ">=20",
"pnpm": ">=10"
}
}

Directory structuur aanmaken:

werkbon/ (of: pagebuilder/apps/werkbon/)
├── apps/
│ ├── app/ # React dashboard (Vite)
│ └── api/ # Hono API (CF Workers)
├── packages/
│ └── shared/ # Gedeelde types + utilities
├── supabase/
│ └── migrations/ # SQL migraties
├── pnpm-workspace.yaml
├── package.json
└── tsconfig.json

Beslissing: eigen repo of in Beam monorepo?

Aanbeveling: eigen repo. Redenen:

  • Onafhankelijke deploy cycles
  • Eigen Supabase project (of gedeeld, maar eigen migraties)
  • Eigen CI/CD workflows
  • Geen risico dat Beam changes Werkbon breken

Volg exact het Beam migratie patroon. Maak deze bestanden:

Migration 001: Fundament

supabase/migrations/001_foundation.sql

Inhoud:

  • update_updated_at_column() trigger functie (kopieer uit Beam 001)
  • team_members tabel (kopieer uit Beam 010, pas rollen aan: owner, admin, planner, monteur)
  • get_team_owner_id(), get_user_role(), are_teammates() helper functies (kopieer uit Beam 011)
  • Bootstrap trigger: on_auth_user_created → maak team_members entry

Migration 002: Contacts

CREATE TABLE contacts (
-- Zie datamodel-nieuw.md voor volledig schema
);
-- + indexes + RLS + updated_at trigger

Migration 003: Reports (meldingen) Migration 004: Jobs (klussen) + job_assignments Migration 005: Work Orders (werkbonnen) Migration 006: Activity Log Migration 007: Attachments Migration 008: Addresses (lookup) Migration 009: Material Catalog Migration 010: Views (job_details, work_order_details) Migration 011: Status transition triggers

Alle schema’s staan in Datamodel (Nieuw).

Kopieer volledig uit Beam (apps/api/src/):

BestandKopieer vanAanpassing
src/index.tsBeam index.tsRoutes wijzigen, webhook route VOOR auth
src/types.tsBeam types.tsEnv bindings aanpassen (geen R2 media bucket nodig in v1)
src/middleware/auth.tsBeam auth.tsGeen aanpassing
src/middleware/cors.tsBeam cors.tsDASHBOARD_ORIGIN aanpassen
src/middleware/logger.tsBeam logger.tsGeen aanpassing
src/middleware/rate-limit.tsBeam rate-limit.tsLimieten aanpassen
src/middleware/sentry.tsBeam sentry.tsGeen aanpassing
src/lib/supabase.tsBeam supabase.tsGeen aanpassing

Nieuwe bestanden:

src/routes/
reports.ts # CRUD meldingen
jobs.ts # CRUD klussen
work-orders.ts # CRUD werkbonnen
contacts.ts # CRUD + search + matching
addresses.ts # Autocomplete cascade
src/services/
contact.service.ts # findOrCreate, matching, normalisatie
address.service.ts # Lookup, Maasdelta detectie
phone.service.ts # E.164 normalisatie (port van WP)
webhook.service.ts # Outgoing naar Make.com/Moneybird
moneybird.service.ts # Invoice details builder
src/scheduled/
cleanup.ts # Soft delete purge (30 dagen)

wrangler.toml:

name = "werkbon-api"
main = "src/index.ts"
compatibility_date = "2026-04-01"
compatibility_flags = ["nodejs_compat"]
[vars]
SUPABASE_URL = ""
DASHBOARD_ORIGIN = "https://app.acspascal.nl"
ENVIRONMENT = "production"
[[kv_namespaces]]
binding = "WERKBON_CACHE"
[triggers]
crons = ["0 3 * * *"]

Webhook routing (CRITICAL):

// src/index.ts — webhook VOOR auth middleware
const publicRoutes = new Hono<{ Bindings: Env }>()
// Incoming webhook van marketingsite (geen Bearer auth, wel secret)
publicRoutes.post('/reports/incoming', incomingWebhookHandler)
// Health check
publicRoutes.get('/health', (c) => c.json({ status: 'ok' }))
app.route('/', publicRoutes)
// Auth middleware (alle routes hierna vereisen Bearer token)
app.use('*', authMiddleware)
// Authenticated routes
app.route('/reports', reports)
app.route('/jobs', jobs)
app.route('/work-orders', workOrders)
app.route('/contacts', contacts)
app.route('/addresses', addresses)
packages/shared/
├── src/
│ ├── index.ts # cn() utility (kopieer van Beam)
│ ├── types.ts # Contact, Report, Job, WorkOrder interfaces
│ └── status.ts # Status enums + transition validatie
├── package.json
└── tsconfig.json

Types (gedeeld tussen app en api):

packages/shared/src/types.ts
export type ContactType = 'maasdelta' | 'particulier' | 'zakelijk'
export type ReportStatus = 'new' | 'accepted' | 'rejected' | 'merged' | 'on_hold'
export type JobStatus = 'open' | 'assigned' | 'scheduled' | 'in_progress' | 'completed' | 'on_hold' | 'cancelled'
export type WorkOrderStatus = 'draft' | 'submitted' | 'approved' | 'revision_requested'
export interface Contact {
id: string
type: ContactType
company: string | null
firstname: string | null
lastname: string | null
email: string | null
phone: string | null
streetname: string | null
house_number: string | null
addition: string | null
zipcode: string | null
city: string | null
moneybird_id: string | null
}
export interface Job {
id: string
contact_id: string
report_id: string | null
status: JobStatus
type: ContactType
address_override: boolean
scheduled_date: string | null
description: string | null
// ...
}
export interface WorkOrder {
id: string
job_id: string
contact_id: string | null // null = erft van job
status: WorkOrderStatus
date: string
work_items: WorkItem[]
materials: Material[]
signature_url: string | null
}
export interface WorkItem {
location: string[]
work: string
time: number | null
date: string | null
}
export interface Material {
id: string
label: string
qty: number
}

Kopieer uit Beam dashboard (apps/dashboard/src/):

BestandKopieer vanAanpassing
src/main.tsxBeam main.tsxSentry DSN, imports
src/lib/auth-context.tsxBeam auth-context.tsxGeen
src/lib/auth.tsBeam auth.tsGeen
src/lib/api-client.tsBeam api-client.tsEndpoints aanpassen
src/components/ui/modal.tsxBeam modal.tsxGeen
src/components/toast.tsxBeam toast.tsxGeen
vite.config.tsBeam vite.config.tsProxy + chunks
tailwind.config.jsBeam tailwind.config.jsKleuren
index.htmlBeam index.htmlTitel

Nieuwe layout:

src/layouts/AppLayout.tsx
// Gebaseerd op Beam DashboardLayout.tsx maar met Werkbon navigatie:
// - Dashboard
// - Meldingen
// - Klussen
// - Werkbonnen (rapporten)
// - Contacten
// - Instellingen

Routes:

src/routes.tsx
const router = createBrowserRouter([
{
element: <AuthLayout />,
children: [
{ path: '/login', lazy: () => import('./pages/LoginPage') },
],
},
{
element: <AppLayout />,
children: [
{ path: '/', lazy: () => import('./pages/DashboardPage') },
{ path: '/meldingen', lazy: () => import('./pages/ReportsPage') },
{ path: '/klussen', lazy: () => import('./pages/JobsPage') },
{ path: '/klussen/nieuw', lazy: () => import('./pages/JobFormPage') },
{ path: '/klussen/:id', lazy: () => import('./pages/JobDetailPage') },
{ path: '/werkbonnen', lazy: () => import('./pages/WorkOrdersPage') },
{ path: '/werkbonnen/nieuw/:jobId', lazy: () => import('./pages/WorkOrderFormPage') },
{ path: '/werkbonnen/:id', lazy: () => import('./pages/WorkOrderFormPage') },
{ path: '/contacten', lazy: () => import('./pages/ContactsPage') },
{ path: '/instellingen', lazy: () => import('./pages/SettingsPage') },
],
},
])

Werkbon is invite-only. De volledige invite flow moet werken voor de eerste monteur kan inloggen.

Database: team_invitations tabel (in Migration 001)

CREATE TABLE team_invitations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
site_id UUID NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
email TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'monteur' CHECK (role IN ('admin', 'planner', 'monteur')),
token UUID NOT NULL DEFAULT gen_random_uuid(),
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'revoked', 'expired')),
expires_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '7 days'),
accepted_by UUID REFERENCES auth.users(id),
responded_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (token)
);

Bootstrap trigger (bij invite-accept, NIET bij signup):

-- Werkbon: users worden aangemaakt via invite, niet via signup
-- De bootstrap trigger moet NIET automatisch een 'owner' team_members row aanmaken
-- Alleen de eerste beheerder (handmatig aangemaakt) krijgt de 'owner' rol
-- Genodigden krijgen hun rol via de invite flow (team.ts → POST /team/invitations/accept)

API endpoints (kopieer van Beam team.ts, pas aan):

EndpointDoel
POST /team/inviteUitnodiging sturen (beheerder/admin)
DELETE /team/invitations/:idIntrekken
POST /team/invitations/:id/resendOpnieuw versturen
POST /team/invitations/acceptAccepteren (via magic link)
POST /team/invitations/declineWeigeren
GET /team/invitations/infoInfo voor accept pagina
PATCH /team/members/:id/roleRol wijzigen
DELETE /team/members/:idLid verwijderen

Pagina’s:

PaginaRouteDoel
AuthCallbackPage/auth/callbackVerwerkt magic link tokens van Resend
InviteAcceptPage/invite/acceptToont invite info, accept/decline knoppen
ForgotPasswordPage/forgot-passwordWachtwoord reset aanvragen
ResetPasswordPage/reset-passwordNieuw wachtwoord instellen
UsersPage/instellingen/gebruikersBeheerder beheert team + invites

Ghost user cleanup: Beam roept delete_ghost_user RPC aan bij invite revoke. Kopieer deze Supabase functie.

Patroon kopieer van Beam PagesPage.tsx:

src/lib/hooks/use-contacts.ts # SWR hook (patroon: use-pages.ts)
src/lib/supabase/contacts.ts # Service layer (patroon: pages.ts)
src/pages/ContactsPage.tsx # Lijst + zoeken + filters (patroon: PagesPage.tsx)
src/components/contacts/
ContactList.tsx # Tabel met sortering
ContactFilters.tsx # Type filter, zoekbalk
ContactFormModal.tsx # Create/edit modal

Nieuw component, geen Beam equivalent:

src/components/forms/
AddressAutocomplete.tsx # 4-staps cascade

API route addresses.ts met 4 endpoints:

  • GET /addresses/streets?q=hoofd → straatnamen
  • GET /addresses/numbers?street=Hoofdstraat&city=Rotterdam → huisnummers
  • GET /addresses/additions?street=...&number=42 → toevoegingen
  • GET /addresses/resolve?zipcode=3029AK&number=42&addition=A → full address

Adres data uit KV cache (statisch, 30 dagen TTL).

src/pages/ReportsPage.tsx # Lijst met triage acties
src/lib/hooks/use-reports.ts
src/lib/supabase/reports.ts
src/components/reports/
ReportCard.tsx # Compact kaartje voor triage
ReportTriageActions.tsx # Accepteren/Afwijzen/On hold knoppen
ReportDuplicateWarning.tsx # "Er staat al een melding op dit adres"

Triage view: Card-based, alle info in 1 oogopslag, 1-klik acties.

src/pages/JobsPage.tsx # Lijst met 7 status filters
src/pages/JobFormPage.tsx # Create/edit + melding koppelen
src/pages/JobDetailPage.tsx # Detail + timeline + werkbonnen
src/lib/hooks/use-jobs.ts
src/lib/supabase/jobs.ts
src/components/jobs/
JobList.tsx
JobFilters.tsx # 7 statussen, type, monteur
JobDetailSheet.tsx # Side panel met details
JobTimeline.tsx # Activity log chronologisch
JobAssignModal.tsx # Monteur toewijzen + datum

Dagplanning monteur (v1 must-have):

src/pages/MijnKlussenPage.tsx # "Mijn klussen vandaag"

Query: job_assignments WHERE user_id = auth.uid() AND jobs.scheduled_date = today

Dit is de make-or-break feature. Mobile-first, 30 seconden doel.

src/pages/WorkOrderFormPage.tsx # Hoofdformulier
src/lib/hooks/use-work-orders.ts
src/lib/hooks/use-auto-save.ts # Debounced save naar Supabase
src/lib/supabase/work-orders.ts
src/components/work-orders/
WorkItemsEditor.tsx # @dnd-kit drag-drop
MaterialEditor.tsx # Zoeken + qty
SignatureCapture.tsx # Canvas handtekening
WorkOrderPreview.tsx # Live preview
JobContextSidebar.tsx # Melding info + eerdere werkbonnen
DraftBanner.tsx # "Je hebt een onafgeronde werkbon"

Auto-save:

src/lib/hooks/use-auto-save.ts
export function useAutoSave(workOrderId: string, data: Partial<WorkOrder>) {
const save = useDebouncedCallback(async (d) => {
await supabase.from('work_orders').update(d).eq('id', workOrderId)
}, 5000) // 5 seconden debounce
useEffect(() => { save(data) }, [data])
// Bewaar ook lokaal voor offline scenario
useEffect(() => {
localStorage.setItem(`draft:${workOrderId}`, JSON.stringify(data))
}, [data])
}

Frequent combo’s:

const FREQUENT_COMBOS: WorkItem[] = [
{ location: ['Badkamer'], work: 'Lekkage verholpen', time: 1, date: null },
{ location: ['Keuken'], work: 'Riool ontstopt met hogedruk', time: 1.5, date: null },
{ location: ['Toilet'], work: 'WC ontstopt', time: 0.5, date: null },
// Configureerbaar per site via material_catalog of aparte tabel
]

Deze items zijn niet gekoppeld aan 1 fase maar moeten klaar zijn voor productie.

Werkbon handtekeningen opslaan via Supabase Storage (eenvoudiger dan R2 voor v1):

Bucket: werkbon-signatures
Pad: {site_id}/{work_order_id}/signature.png
  • Upload via API: POST /work-orders/:id/signature (multipart form)
  • Opslag: Supabase Storage bucket (public read, authenticated write)
  • signature_url op work_orders tabel → Supabase Storage URL
  • Max grootte: 500KB (handtekening is ~50-200KB)
  • Later (v2 foto’s): migreer naar R2 als volumes groeien

Alle list endpoints moeten server-side paginatie ondersteunen:

// API pattern
app.get('/jobs', async (c) => {
const page = parseInt(c.req.query('page') || '1')
const limit = Math.min(parseInt(c.req.query('limit') || '25'), 100)
const offset = (page - 1) * limit
const { data, count } = await supabase
.from('jobs')
.select('*', { count: 'exact' })
.range(offset, offset + limit - 1)
.order('created_at', { ascending: false })
return c.json({ items: data, total: count, page, limit })
})
// SWR hook pattern
export function useJobs(page: number, filters: JobFilters) {
return useSWR(
`jobs-${page}-${JSON.stringify(filters)}`,
() => getJobs(page, filters),
{ keepPreviousData: true }
)
}

Kritisch voor het 15-seconden triage doel. Implementeer voor:

  • Melding accepteren/afwijzen (triage)
  • Klus status wijzigen
  • Werkbon goedkeuren/terugsturen
// Pattern (kopieer van Beam use-pages.ts)
await mutateJobs(
async () => {
await updateJobStatus(jobId, newStatus)
return optimisticData
},
{ optimisticData, revalidate: false }
)
  1. Bij Supabase seed: script laadt adressen uit CSV → addresses tabel
  2. Bij eerste API start: cron job of init functie vult KV cache
  3. Fallback: als KV miss → query Supabase direct, cache resultaat
  4. Verversen: admin actie “Cache verversen” of automatisch na 30 dagen TTL
// Cache warming functie
async function warmAddressCache(env: Env) {
const supabase = createAdminClient(env)
const { data } = await supabase.from('addresses').select('*')
await env.WERKBON_CACHE.put('addresses:all', JSON.stringify(data), {
expirationTtl: 86400 * 30 // 30 dagen
})
}

Consistente conventie voor alle endpoints:

// Success
{ items: [...], total: 123, page: 1, limit: 25 } // lijst
{ data: {...} } // enkel item
{ success: true } // mutatie zonder data
// Error
{ error: 'Beschrijving in het Nederlands' } // 4xx/5xx

Zie Datamodel (Nieuw) Flow 2 voor de volledige implementatie.

src/scheduled/
queue-consumer.ts # Verwerkt webhook berichten
// Bij werkbon goedkeuring:
await c.env.WEBHOOK_QUEUE.send({
type: 'moneybird_invoice',
work_order_id: workOrder.id,
attempt: 0,
})
// Queue consumer:
export default {
async queue(batch, env) {
for (const msg of batch.messages) {
try {
await processWebhook(msg.body, env)
msg.ack()
} catch {
if (msg.body.attempt < 5) {
msg.retry({ delaySeconds: 60 * (msg.body.attempt + 1) })
} else {
// Dead letter: log + notificeer beheerder
await logFailedWebhook(msg.body, env)
msg.ack()
}
}
}
}
}

Minimaal 3 email triggers:

  1. Melding ontvangen → bevestigingsmail met status-check URL
  2. Klus ingepland → “Monteur komt [datum] tussen [tijd]”
  3. Werkbon goedgekeurd → “Het werk is afgerond”

Via Resend (al in Beam stack) of SendGrid.

Zie Migratie Stack voor de volledige strategie:

  • 6-fasen migratie script
  • Blue-green deployment (week-voor-week)
  • Validatie queries
  • Rollback plan

GitHub Actions (kopieer van Beam, pas aan):

.github/workflows/deploy-app.yml
name: Deploy Werkbon App
on:
push:
paths: ['apps/app/**', 'packages/shared/**']
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- run: pnpm install --frozen-lockfile
- run: pnpm --filter @werkbon/app build
- run: pnpm exec wrangler pages deploy apps/app/dist --project-name werkbon-app
# .github/workflows/deploy-api.yml
name: Deploy Werkbon API
on:
push:
paths: ['apps/api/**', 'packages/shared/**']
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- run: pnpm install --frozen-lockfile
- run: pnpm --filter @werkbon/api deploy
WeekWatUren (schatting)
1Monorepo setup + Supabase migraties16
2API middleware + routes (contacts, addresses)16
3Dashboard auth + layout + contacten CRUD16
4Adres autocomplete + meldingen triage16
5Klussen CRUD + inplannen + dagplanning20
6Werkbon formulier (mobile-first)24
7Werkbon: werkzaamheden editor + materiaal + handtekening24
8Webhooks (incoming + outgoing) + email notificaties16
9Data migratie script + validatie16
10Parallel draai + testing + cut-over16
Totaal~180 uur

Zelfde aanpak als Beam: Playwright E2E tests + TypeScript typecheck + ESLint.

TestWatPrioriteit
Auth: loginEmail + wachtwoord → dashboardv1
Auth: logoutUitloggen → redirect /loginv1
Contacten: CRUDAanmaken, bewerken, zoeken, verwijderenv1
Meldingen: triageAanmaken, accepteren, afwijzenv1
Klussen: CRUDAanmaken, toewijzen, inplannenv1
Klussen: statusStatus wijzigen via modalv1
Werkbon: invullenWerkzaamheden + materiaal + submitv1
Werkbon: auto-saveDraft opslaan + herstellenv1
Werkbon: goedkeurenBeheerder keurt goed / stuurt terugv1
Adres: autocompleteStraat → nummer → postcode chainv1
Webhook: incomingPOST /reports/incoming met secretv1
Mobile: responsiveWerkbon op 375px viewportv1
# In elke PR:
- pnpm typecheck # 0 TypeScript errors
- pnpm lint # 0 ESLint errors/warnings
- pnpm build # Succesvolle build
- pnpm test:e2e # Alle Playwright tests groen
Beheerder: beheerder@acspascal.nl / [test wachtwoord]
Monteur: monteur@acspascal.nl / [test wachtwoord]

Aanmaken via Supabase auth admin na project setup.

Items die niet in v1 zitten maar wel gepland zijn:

  • Veld-voor-veld mapping WP → Supabase (per ACF field → kolom)
  • Make.com scenario’s updaten naar nieuwe API endpoints
  • Adres CSV import naar Supabase addresses tabel
  • Materiaal CSV import naar material_catalog tabel
  • Monteur training / handleiding
  • Email templates ontwerpen (melding ontvangen, klus ingepland, werk afgerond)
  • Direct Moneybird API i.p.v. Make.com (fase 2)
  • Backup strategie documenteren (Supabase PITR)
  • PWA: service worker + offline cache + homescreen icon
  • Foto’s bij werkbon (v2)
  • In-app comments beheerder ↔ monteur (v2)
  • Materiaal catalogus met prijzen (v2)
  • Monteur weekplanning / capaciteitsoverzicht (v2)
  • CSV import voor woningcorporatie bulk meldingen (v2)
  • Rapporten export (PDF/Excel) (v2)
  • WhatsApp integratie (v3)
  • Route optimalisatie (v3)
  • Barcode scanner materiaal (v3)
  • Klant portaal (v3)
  • Voorraad beheer (v3)
# Monorepo
package.json → Root workspace config
pnpm-workspace.yaml → Workspace definitie
# API (100% kopieerbaar)
apps/api/src/middleware/auth.ts → Bearer token → Supabase
apps/api/src/middleware/cors.ts → CORS config
apps/api/src/middleware/logger.ts → Structured logging
apps/api/src/middleware/rate-limit.ts → Sliding window
apps/api/src/middleware/sentry.ts → Error tracking
apps/api/src/lib/supabase.ts → Client helpers
apps/api/src/routes/pages.ts → Route pattern (validation + CRUD)
# Dashboard (kopieer + aanpassen)
apps/dashboard/src/main.tsx → Entry + Sentry
apps/dashboard/src/lib/auth-context.tsx → Auth provider
apps/dashboard/src/lib/auth.ts → Login/logout
apps/dashboard/src/lib/api-client.ts → Typed API client
apps/dashboard/src/components/ui/modal.tsx → Modal + AnimatePresence
apps/dashboard/src/components/toast.tsx → Toast notifications
apps/dashboard/src/lib/hooks/use-pages.ts → SWR hook patroon
apps/dashboard/src/lib/supabase/pages.ts → Service layer patroon
apps/dashboard/src/pages/PagesPage.tsx → List + filter patroon
apps/dashboard/vite.config.ts → Build config
apps/dashboard/tailwind.config.js → Tailwind setup
# Shared
packages/shared/src/index.ts → cn() utility
packages/shared/package.json → Export config
# Supabase
supabase/migrations/001_*.sql → Table + trigger patroon
supabase/migrations/010_*.sql → Team + RLS helpers
supabase/migrations/011_*.sql → RLS policy patroon