# RFC-TIMETABLE — Artist Timetable & Engagement Module ## 1. Status - **State:** Draft for review - **Created:** 2026-05-08 - **Version:** v0.2 - **Owner:** Bert Hausmans - **Replaces:** v0.1 (2026-05-08) - **Origin:** v0.1 + brainstorm session 2026-05-08 (Claude Chat) + prototype audit (commit `a57437a`, `dev-docs/audits/PROTOTYPE-AUDIT- ARTIST-TIMETABLE.md`) + decisions Bert 2026-05-08 - **Related:** - `SCHEMA.md` §3.5.7 (will be substantially rewritten by this RFC) - `ARCH-PLANNED-MODULES.md` §3.5.7 (will move to SCHEMA.md as it lands) - `BACKLOG.md` ARCH-09 (resolved by Session 1), ART-03 (absorbed), new ART-04…ART-13, FIN-01, FIN-02 - `ARCH-FORM-BUILDER.md` §3.2.5 (`artist_advance` purpose), §17.3 (purpose registry), §4 (form_schemas), §6 (binding semantics) - `AUTH_ARCHITECTURE.md` §6 (portal-token flow) - `UX_SPEC_FESTIVAL_HIERARCHY.md` (festival/sub-event invariants) - `CLAUDE.md` "Order of work" - `VUEXY_COMPONENTS.md` (frontend conventions) ## 2. Why this RFC exists The Artist Timetable is the central planning surface for festival programming. It is operationally critical: production managers spend hours per week on it, last-minute changes are common, a bug here visibly breaks the show, and bookers will not adopt a tool that mishandles the deal-info layer (Buma, holds, contracts). Five properties make this module non-trivial and warrant up-front spec: 1. It depends on a model (Artist) that does not yet exist (ARCH-09). 2. It introduces a new architectural layer — `ArtistEngagement` — that did not exist in v0.1: artists are now org-level, engagements are per-event. This is a deliberate departure from SCHEMA §3.5.7's single-artists-per-event model and resolves backlog ART-03. 3. It bridges concerns festival operators conflate but Crewli must keep distinct: artists (master), engagements (per-event), stages (physical), sub-events (program units), advancing (per-engagement workflow), and Form Builder (the rider/advance data layer). 4. It introduces interaction patterns (drag-drop Gantt with server-transactional cascade-bump, cross-day stage variance, popover with advancing aggregate, keyboard-accessible blocks) that do not exist off-the-shelf in the current frontend stack. 5. The booker-experience features (Buma handling, option expiry, crew & guestlist contingent, fee deal info) are not optional polish — they are what make the module usable at festival scale. v0.1 underscoped these. This RFC captures every architectural decision so implementation does not re-litigate them under time pressure. The PoC iterations (prototype at `./resources/Crewli - Artist Timetable Management/`) are visual references — this document is authoritative. ## 3. Scope & non-scope ### 3.1 In scope (v1) **Backend (Sessions 1–3):** - Org-level `artists` table + `artist_engagements` per-event table - Org-level `genres` configurable list - `stages` (festival-level or flat-event-level), `stage_days` (pivot to sub-events or flat event), `performances` (sub-event-scoped) - 9-status enum on engagements (incl. `rejected` vs `declined`) - Buma + VAT + deal info (fee, deposit, payment status, crew/guests) - Option-expiry mechanism (scheduled job) - Form Builder integration via `artist_advance` purpose, section-level submit, portal_token per engagement - Server-side transactional cascade-bump endpoint - Server-side lane resolution on read - Optimistic locking via `version` column - Conflict detection (same-stage, same-day, same-lane), B2B detection (≤5 min gap), capacity warning (`draw > capacity * 1.1`) - Wachtrij = nullable `stage_id` filter (no separate table) - Activity log on all mutations - Multi-tenancy via `OrganisationScope` (FK-chain) **Frontend (Sessions 4–6):** - Horizontal Gantt timetable with day-tabs (one tab per sub-event, no tabs for flat events) - Drag/drop performance: re-time, re-stage, re-lane in one gesture - Resize duration (snap 15 min, min 15 min) - Click-to-add performance (modal) and drag-to-create (inline ghost) - Detail popover with advancing aggregate, status switch, action buttons - Wachtrij sidebar with search, status multi-select, genre filter - "Stages per dag" matrix bulk editor - Stage editor (single-stage CRUD with cascade-to-wachtrij on delete) - Stage reorder (drag stage rows) - Empty-day copy-from-other-day affordance - Engagement detail page (`/events/{event}/artists/{engagement}`) with tabs: Overview, Performances, Advancing, Contacts, Crew & Guests, Activity log - Portal page (`/p/artist/{token}`) for tour manager - **Master Artist Library** (`/organisation/artists`) — list with search, filter, sort, pagination, "Toon verwijderde" toggle - **Master Artist Detail** (`/organisation/artists/{artist}`) — tabs: Overview (master fields), Engagements (cross-event history), Contacts (master CRUD), Activity log - Trashed-artist visual markers on all engagement / timetable surfaces (D27) - Keyboard a11y model (focusable blocks, arrow nudges, etc.) - CSS-tokens for status colours (theme-overridable) - Optimistic updates with version-check rollback + toast ### 3.2 Out of scope (v1) — backlog items | ID | Item | Notes | |---|---|---| | ART-04 | Tech-rider warnings vs stage-spec | Requires parsed-rider fields + stage tech-spec columns | | ART-05 | PDF/print A3 running order | | | ART-06 | Bulk shift on canvas (multi-select +30 min) | | | ART-07 | Undo/redo stack | | | ART-08 | Cross-stage artist double-booking warning | Surfaces on artist detail, not timetable | | ART-10 | Venue-as-row variant (cultuurnacht model) | Schema accommodates this without changes | | ART-11 | Mission Control artist check-in | Separate module on show-day | | ART-12 | Counter-offers / negotiation log | | | ART-13 | Routing intelligence (home-base clustering) | | | ART-14 | Per-tenant status colour overrides | CSS-tokens enable this; activation deferred | | ART-15 | Realtime broadcasts (Reverb) | Wait until notification framework | | ART-16 | Hospitality auto-aggregation dashboard | Per-day catering totals | | ART-17 | Mobile-optimised view | Read-only list on mobile | | ART-18 | Stage templates across events | Parallel to ARCH-10 sectie templates | | ART-19 | Public reveal scheduling (line-up phasing) | | | ART-20 | A1 / work permit tracking for international acts | | | ART-21 | Cost-control dashboard | Total fee budget vs draw projection | | ART-22 | Cross-edition analytics | "Mau P 2025 vs 2026 metrics" | | ART-23 | Mailbox-thread per booking | Agent correspondence in-app | | ART-24 | GDPR hard-erasure flow voor artist + contacten | Alleen op explicit data subject request; runbook-driven | | FIN-01 | Buma export per festival (CSV) | | | FIN-02 | Buma forecast widget on org dashboard | | ## 4. Locked design decisions 26 decisions, organised into **architecture** (D10–D17, D22), **interaction** (D1, D5, D7, D13, D18, D20), **schema details** (D9, D23, D24, D26), **integration** (D2, D3, D11, D15), **rendering** (D6, D8, D21, D25), and **technical contracts** (D4, D14, D16, D19). ### D1 — Block visual: stage-stripe + status-fill Each performance block carries: - Background fill = engagement.booking_status colour (9 colours, see §10.1) - 3 px left stripe = `stages.color` for stage-grouping cue - Right grab handle for resize Rationale unchanged from v0.1. ### D2 — Advancing aggregate on `artist_engagement`, not `artist` **Revised from v0.1.** The aggregate `{advancing_completed_count, advancing_total_count}` is denormalised on `artist_engagements`, not on `artists`. An artist can have multiple engagements (org-level master); advancing is per-event. Computed via observer on `advance_section` create/update/delete: ``` n = count(advance_sections WHERE engagement_id = X AND submission_status = 'approved') m = count(advance_sections WHERE engagement_id = X) ``` Stored as `advancing_completed_count` + `advancing_total_count` int columns on `artist_engagements`. Avoids N+1 on timetable load. ### D3 — Manage booking opens engagement page (not artist master) **Revised from v0.1.** Click "Open detailpagina →" in popover → navigate to `/events/{event}/artists/{engagement}?return=timetable &day=fri&t=210000`. The page shows event-specific data: deal info + Buma, performances within this event, advancing sections + status, contacts, crew & guestlist, activity log. A small "→ Master profile" link in the header goes to `/organisation/artists/{artist}` for cross-event view (power-user only, not v1 daily driver). The URL path uses `/artists/` for SEO-friendly naming; the parameter itself is the engagement ULID. Bookmarkability + cmd-click-to-new-tab preserved as in v0.1. ### D4 — Stage-day filtering enforced everywhere Performance for sub-event D shows only stages where `stage_days (stage_id, event_id=D)` exists. Performances scheduled on `(stage_id, sub_event_id=D)` where the pivot does not exist are **hidden but not deleted** — toggling a stage off must be reversible without data loss. Enforced at: API list endpoint (SQL filter), frontend rendering (defensive trust-but-verify), FormRequest on create (`StageActiveOnSubEvent` rule rejects with 422). ### D5 — Conflict detection scope: same-stage, same-day, same-lane **Refined.** Two non-cancelled performances with the same `stage_id`, same `sub_event_id`, same `lane`, and overlapping time (`p1.end_at > p2.start_at AND p1.start_at < p2.end_at`) → conflict. - Different lanes on same stage = NO conflict (lane semantics permit parallel acts; e.g. one DJ + one MC) - Different stages = NO conflict (cross-stage warning sits on artist detail page per ART-08) - Cancelled performances participate in NO conflict check - Conflict produces a 200 response with `warnings: ["overlap"]` — the booker decides whether to resolve Warning rendering: red border + warn icon + count badge on stage row + total in header pill + footer stat (per prototype audit §4.8). ### D6 — B2B marker rule Two consecutive non-cancelled performances on the same stage with `p2.start_at - p1.end_at ∈ [0, 5]` minutes get a B2B marker (small dot at the boundary). Lane-aware (changeover is per-lane, not per-stage). Pure rendering — no schema impact. ### D7 — Drag-drop interaction model - Drag block horizontally → re-time (snap to 15 min) - Drag block vertically → re-lane within stage OR re-stage to adjacent row - Drag right edge → resize duration (snap to 15 min, min 15 min) - Drag onto wachtrij column → park (set `stage_id = null`) - Drag from wachtrij card onto canvas → schedule (set `stage_id + start_at + end_at + lane`) - Drag is committed on mouseup with single transactional API call (see D18) - Failed call (validation error, version mismatch, transactional conflict) reverts block to its origin position with a toast and refetches the day's timetable Click-after-drag suppression: 3 px Manhattan threshold in the move case, 4 px in the wachtrij case. Capture-phase one-shot listener suppresses the synthetic click. Implemented once as `useDragOrClick` composable (not three times like the prototype). ### D8 — Custom Vue components, not FullCalendar Confirmed unchanged from v0.1. Three blockers: licence, render-hook fighting, cross-day stage filtering mismatch with FC's resource model. Custom Vue components on top of HTML5 PointerEvents give total control. Estimate: ~5-6 frontend days for the rendering core (prototype audit confirms this is realistic). ### D9 — `engagement.booking_status` is 9-state PHP enum **Revised from v0.1's 6-state.** Eight statuses + `option_expires_at` mechanism: ```php namespace App\Enums\Artist; enum ArtistEngagementStatus: string { case Draft = 'draft'; // interne wens, nog geen contact case Requested = 'requested'; // aangevraagd bij agent case Option = 'option'; // hold gezet (require option_expires_at) case Offered = 'offered'; // offer uit (fee + rider verstuurd) case Confirmed = 'confirmed'; // mondeling/email bevestigd case Contracted = 'contracted'; // contract getekend case Cancelled = 'cancelled'; // was rond, nu niet meer case Rejected = 'rejected'; // wij wijzen af (eindstatus) case Declined = 'declined'; // agent zei nee (eindstatus) } ``` `Pending` from the prototype = `Requested` in this enum (per D22). `Cancelled` ≠ `Declined` ≠ `Rejected` — three distinct end-states for reporting. Options must have an `option_expires_at` datetime (validated by `UpdateArtistEngagementRequest` when status=Option). Scheduled job `DemoteExpiredOptions` runs daily, demotes expired options to `Draft` with activity log entry + notification to project leader. ### D10 — Org-level Artist + per-event ArtistEngagement **The fundamental architectural shift.** Replaces v0.1's "artists.event_id" model. ``` artists # Master record (org-level) id ULID PK organisation_id ULID FK → organisations name string slug string (unique per org, used for URLs) default_genre_id ULID FK nullable → genres default_draw int nullable # expected pull star_rating tinyint nullable (1–5) home_base_country string(2) nullable # ISO 3166-1 alpha-2 agent_company_id ULID FK nullable → companies (type=agency) notes text nullable deleted_at timestamp nullable artist_engagements # Per-event booking id ULID PK organisation_id ULID FK → organisations (denormalised) artist_id ULID FK → artists event_id ULID FK → events (festival or flat event) booking_status enum (D9) project_leader_id ULID FK nullable → users fee_amount decimal(10,2) nullable fee_currency string(3) default 'EUR' fee_type enum nullable buma_applicable bool default true buma_percentage decimal(5,2) default 7.00 buma_handled_by enum (D26) vat_applicable bool default true vat_percentage decimal(5,2) default 21.00 deal_breakdown JSON nullable deposit_percentage decimal(5,2) nullable deposit_due_date date nullable balance_due_date date nullable payment_status enum default 'none' crew_count int default 0 guests_count int default 0 requested_at datetime nullable option_expires_at datetime nullable advance_open_from datetime nullable advance_open_to datetime nullable portal_token ULID unique nullable # tour-manager portal advancing_completed_count int default 0 # cached, observer-maintained advancing_total_count int default 0 # cached, observer-maintained notes text nullable deleted_at timestamp nullable ``` **Field-allocation rationale (master vs engagement):** - On `artists` (master): name, default genre/draw, star_rating, agent company, home base, notes that travel across events - On `artist_engagements` (per-event): booking_status, fee + deal info, milestones, advancing, portal_token, project leader, crew/ guests counts **`milestone_flags`** from SCHEMA.md §3.5.7's planned design is **replaced** by status enum transitions + activity log. The eight boolean toggles in the original schema were a state-machine in disguise. `UNIQUE(artist_id, event_id)` — one engagement per artist per event. Multiple performances under one engagement (D17). ### D11 — Stages festival-level, performances sub-event-level For a festival (`event_type='festival'`, has children): - Stages hang on `stages.event_id = festival.id` (the parent) - `stage_days.event_id` references sub-events (the children) - Performances hang on `performances.event_id = sub_event.id` For a flat event (`event_type='event'`, no parent, no children): - Stages hang on `stages.event_id = event.id` (itself) - `stage_days.event_id = event.id` (one row per stage activating the event itself — kept for schema uniformity) - Performances hang on `performances.event_id = event.id` For a series (`event_type='series'`, has children): same as festival, each edition is a "show host" that parallels a sub-event. The timetable view route is always at the highest level (`/events/{festival}/timetable` or `/events/{event}/timetable` for flat). Day-tabs are an in-page filter UI element, not separate routes. ### D12 — Wachtrij = nullable `stage_id` filter (no separate table) Confirmed from prototype audit §7.5. `performances.stage_id` is nullable. `stage_id IS NULL` ⇔ parked/wachtrij. Drag from wachtrij to canvas = `PATCH` with `stage_id + start_at + end_at + lane`. Drag from canvas to wachtrij = `PATCH` with `stage_id = NULL`. Indexing: `INDEX (event_id, stage_id, start_at, end_at)` — works with `WHERE stage_id IS NULL` for wachtrij query. No separate `parked_performances` table. No origin field. The activity log carries provenance. ### D13 — Lanes IN v1 with cascade-bump `performances.lane` (`unsigned tinyint`, default 0). 0-indexed, grows downward inside a stage row. Two-pass lane resolution: - **Pass 1 (explicit):** items with non-null `lane` go to that lane (bumped down on conflict) - **Pass 2 (auto):** items with null `lane` go to lowest free lane Both passes run **server-side** on read (per D19), result is exposed as `lane_resolved` on the `PerformanceResource`. Client runs Pass 2 only for optimistic-update preview during drag; the authoritative value comes from the server response after PATCH. Cascade-bump: when a block is dropped onto a busy lane × time slot, the existing block bumps to `lane + 1`, recursively. Implemented **transactionally on the server** (per D18) — not as N sequential client PATCHes like the prototype. ### D14 — Optimistic locking via `version` column `performances.version` (int, default 0, bumped on every UPDATE). Client sends `version` in PATCH body. Server returns: - 200 + new resource (with bumped version) on success - 409 Conflict with `{conflict: 'version_mismatch', current_version: N, server_data: {…}}` on mismatch Client on 409: shows toast "Iemand anders heeft dit zojuist aangepast — venster ververst"; refetches day timetable; reverts block to origin position. Necessary because two programmers in the same timetable is reality at festival scale. ### D15 — Form Builder integration via `artist_advance` purpose The advance/rider data layer is **not** a new `artist_riders` table. Reuse the existing `artist_advance` purpose (ARCH-FORM-BUILDER §3.2.5): - One `form_schema` per organisation, purpose=`artist_advance`, `section_level_submit=true`, seeded from `ArtistAdvanceDefault` (Session 3) - Sections: General Info, Contacts, Production, Technical Rider, Hospitality (organisation may add custom sections) - Per `artist_engagement`: one `form_submission` keyed by `engagement.portal_token` - Tour manager fills sections via `/p/artist/{token}` portal - `advance_sections` rows link to `engagement_id` (not `artist_id` as currently planned in §3.5.7) — this is the schema correction - `advancing_completed_count` on engagement = count of `advance_sections WHERE submission_status='approved'` for that submission `form_submissions.subject_type='artist'` + `subject_id=artist.id` (master) is preserved. `form_submissions.event_id` (denormalised per WS-4) provides the engagement context. The `ArtistResolver:: fromPortalToken` resolver looks up the engagement from `portal_token`, returns the artist as subject, populates `event_id` from engagement. ### D16 — Realtime broadcasts out of v1 No Echo channels, no Reverb broadcasts in v1. Defer to ART-15 (after notification framework lands, after Accreditation Engine). Single-user happy path is the v1 target. Multi-user collision is caught by D14 optimistic locking. ### D17 — Engagement → Performance is 1:N One `artist_engagement` can have multiple `performances`. Booker decides whether multi-show bookings are one engagement (single deal, one fee, one advancing trajectory) or multiple engagements (separate deals, separate fees). Common scenarios: | Scenario | Engagements | Performances | |---|---|---| | Single set | 1 | 1 | | Two sets same day, different stages, one weekend deal | 1 | 2 | | Friday + Saturday under one combined deal | 1 | 2 | | Friday via Mojo + Saturday via Friendly Fire (separate fees) | 2 | 2 | Cancel engagement → all performances cascade soft-delete. Cancel single performance within active engagement → that performance soft-deleted, engagement keeps running. Same-artist conflict warnings (per ART-08, deferred): - Cross-stage overlap within same engagement → warn - Cross-stage overlap across engagements with same artist_id → warn ### D18 — Server-side transactional move endpoint The cascade-bump algorithm runs in a single DB transaction on the backend, not as N sequential PATCHes. New endpoint: ``` POST /api/v1/events/{event}/timetable/move Body: { performance_id: ULID, target_stage_id: ULID | null, # null = park target_start_at: datetime | null, target_end_at: datetime | null, target_lane: int | null, # null = auto-pack server-side version: int # optimistic lock on the dragged perf } Response 200: { performance: PerformanceResource, cascade: PerformanceResource[] # all bumped siblings (incl. new versions) } Response 409: { conflict: 'version_mismatch', current_version, server_data } Response 422: validation errors ``` Server logic: 1. `BEGIN TRANSACTION` 2. `SELECT … FOR UPDATE` on the dragged perf and all stage-day siblings (lock to prevent concurrent cascades) 3. Validate `version` matches → 409 if not 4. Apply `target_*` fields, run cascade-bump algorithm 5. Bump `version` on every modified row 6. Activity-log entries: one parent `performance.moved` event with cascade-count metadata, linked child entries for bumped siblings 7. `COMMIT` Idempotency-Key required (per ARCH §10), 24-hex from UUID v4. Ordinary `PATCH /performances/{id}` remains for status/notes/etc. edits that do not affect placement. ### D19 — Server-side lane resolution on read `PerformanceResource` returns both `lane` (raw, persisted) and `lane_resolved` (after Pass 1 + Pass 2 for the entire stage-day). Two clients viewing the same timetable see identical layouts. Client uses `lane_resolved` for rendering; `lane` only for editing (in StageEditor advanced view, never v1 default). ### D20 — Keyboard a11y model The prototype is mouse-only. Crewli production requires a11y. The v1 keyboard model: | Key | Action | |---|---| | Tab | Move focus through stages → blocks → wachtrij → header controls | | Arrow ←/→ | Selected block: nudge ±15 min | | Shift+Arrow ←/→ | Nudge ±60 min | | Arrow ↑/↓ | Selected block: change lane within stage; at lane boundary, jump to adjacent stage row (skip if not active on day) | | Enter / Space | Selected block: open popover | | Escape | Close popover OR cancel drag (if active) OR blur block focus | | Delete | Selected block: confirm dialog → delete | | `[` / `]` | Selected block: move to previous/next stage (preserves time + lane) | | Cmd/Ctrl+Z | Undo last move (deferred to ART-07; v1 toast says "Undo: Cmd+Z (binnenkort)") | `aria-label` on blocks: `"{artist}, {stage}, {start_time}–{end_time}, status {status}, advancing {n}/{m}"`. `role="application"` on the canvas with screen-reader instructions hidden via `cw-sr-only`. Focus ring uses `--accent` colour, 2 px solid, 2 px offset. Keyboard-driven drag is implemented as a discrete "move mode": Enter on a focused block enters move mode (announced via aria-live); arrows move block; Enter commits, Escape cancels. ### D21 — Status colours via CSS tokens Per-status colours live in `apps/app/src/styles/tokens/timetable.scss` as CSS custom properties: ```scss :root { --tt-status-draft-bg: #f1efe9; --tt-status-draft-border: #dcd9d1; --tt-status-draft-fg: #3a3830; --tt-status-draft-dot: #a09c92; --tt-status-requested-bg: #fff6e0; --tt-status-requested-border:#f0d99a; --tt-status-requested-fg: #5d4612; --tt-status-requested-dot: #d9a93c; --tt-status-option-bg: #f3eefa; /* … */ --tt-status-confirmed-bg: #e8f8f0; /* … */ --tt-status-contracted-bg: #e6f1fb; /* … */ --tt-status-cancelled-bg: #f5f3ef; /* hatched overlay via CSS gradient */ /* … */ --tt-status-rejected-bg: #fbeaec; /* … */ --tt-status-declined-bg: #f7eee9; /* … */ --tt-status-offered-bg: #fef5e7; /* … */ } ``` `PerformanceBlock.vue` consumes via `style="background-color: var (--tt-status-{{status}}-bg)"`. Per-tenant override (ART-14) deferred: the system can later inject org-specific colours by setting the custom properties on a parent `[data-org-id]` selector. ### D22 — Pending = `booking_status='requested'` (no separate table) The prototype's `pending` collection (a third runtime set besides performances and parked) is NOT modelled in production. What the prototype calls "pending" is conceptually identical to "we have asked the agent, awaiting answer" — that is exactly status `requested` in the 9-state enum (D9). `requested_at` datetime + `notes` text on `artist_engagements` captures everything the prototype's `pending` row carried (id, artist_id, requested_on, note). Wachtrij filtering: a `requested`-status engagement with no performances yet shows up in wachtrij as a card. Drag onto canvas = PATCH adds the first performance with `stage_id + start_at + end_at + lane`. Status stays `requested` until booker manually moves it to `option`/`confirmed`. ### D23 — `stages.sort_order` for persistent reorder Stages can be drag-reordered (drag the stage row's left handle). Reorder persists via `stages.sort_order` (int, default 0). Endpoint: `PATCH /events/{event}/stages/order` with `{stage_ids: ULID[]}` replaces the order atomically. Default order: by creation timestamp. Bookers reorder to match physical walk-through, headliner priority, or genre clustering. ### D24 — Genre via org-level `genres` table ``` genres id ULID PK organisation_id ULID FK → organisations name string(40) color string(7) nullable # optional accent sort_order int default 0 is_active bool default true UNIQUE(organisation_id, name) ``` Each organisation maintains its own genre list. System-seeds nothing — org chooses what is relevant (Hardstyle / Techno / Hollands / classical / cabaret / DJ-set / live-band / podcast-recording). Configurable via `/organisation/settings/genres`. `artists.default_genre_id` is the master assignment. Engagements do NOT override genre per event (an artist's genre doesn't change between bookings). Modal "Add performance" pre-fills genre from the master artist; the modal does not allow genre editing here (genre edits happen on the artist master page). ### D25 — Capacity warning + B2B detection IN v1 Both included. They are visible features in the prototype; bookers will miss them otherwise. - **Capacity warning:** computed client-side per block via `artist.default_draw > stage.capacity * 1.1`. Renders an orange triangle icon in the block's warn cluster + tooltip "Verwachte trekkracht (X) overschrijdt zaalcapaciteit (Y)". - **B2B marker:** computed client-side per stage-day via the `findB2B` algorithm (≤5 min gap between consecutive performances same lane). Renders a small dot at the boundary edge. Both are derivations only — no schema impact, no API additions. For festivals with 100+ performances per day, computation runs in ~1ms (per prototype audit §7.16). Server-side computation deferred to ART-04 if performance issues arise. ### D26 — Buma + VAT handling per engagement Buma (rights collection society NL) is 7% of fee. VAT is 21%. Three configurations matter: ```php namespace App\Enums\Artist; enum BumaHandledBy: string { case Organisation = 'organisation'; // wij dragen 7% af case BookingAgency = 'booking_agency'; // boekingskantoor doet het case NotApplicable = 'not_applicable'; // non-music acts } ``` **Defaults on engagement creation:** `buma_applicable=true`, `buma_percentage=7.00`, `buma_handled_by=Organisation`, `vat_applicable=true`, `vat_percentage=21.00`. **Auto-flip rule:** when `agent_company_id` on the artist points to a `companies` row with `handles_buma=true`, the engagement form defaults `buma_handled_by=BookingAgency`. Booker can override. **Derived calculations (live, not stored):** ``` buma_amount = fee_amount × buma_percentage / 100 (only if buma_applicable AND buma_handled_by=Organisation) vat_grondslag = fee_amount + (buma_amount if Organisation else 0) vat_amount = vat_grondslag × vat_percentage / 100 (if vat_applicable) total_cost = fee_amount + buma_amount + vat_amount + sum(deal_breakdown) ``` `companies` table gets a new column `handles_buma` (bool, default false). Migration adds it; existing rows default to false. Backlog: FIN-01 (Buma export per festival), FIN-02 (forecast widget). ### D27 — Soft-delete preserves engagements; visual marker Soft-deleting a master `Artist` (set `deleted_at`) does **NOT** cascade to `artist_engagements`. Historical engagements remain fully visible and functional — those bookings happened in the past, that history must be preserved. Future engagements of a soft-deleted artist also remain visible and editable, but render with a clear "verwijderd" banner so the booker knows the master record has been archived. **Behavior matrix:** | Surface | Trashed artist behaviour | |---|---| | Master Artist Library list | Default hidden. Toggle "Toon verwijderde" reveals with red strike-through name + "Verwijderd op {date}" badge. | | Master Artist Detail page | Accessible via direct URL. Prominent banner: "Deze artiest is verwijderd op {date} door {user}. {N} historische boekingen blijven beschikbaar." | | Engagement detail page (organizer) | Banner above artist info: "⚠ Deze artiest is verwijderd uit de organisatie-bibliotheek." Engagement remains fully editable. | | Performance block (timetable) | Trashed-artist marker: dashed border + small archive-icon overlay in the warn cluster. Hover tooltip explains. | | Add Engagement modal — artist dropdown | Filter `WHERE deleted_at IS NULL`. No checkbox to include trashed (deliberate friction; restore via Master Library is the correct path). | | Cross-event analytics, "active roster" counts | Trashed artists excluded. | | Cross-event analytics, historical aggregates | Trashed artists included (the bookings happened). | **Restore:** `POST /organisations/{org}/artists/{artist}/restore` unsets `deleted_at`. Permission: `organisations.manage_artists`. Activity log captures both delete and restore events. Once restored, all banners disappear immediately on next render. **Hard-delete blocking:** there is no hard-delete endpoint for artists in v1. `deleted_at` is the terminal state. GDPR erasure is a separate flow (handled via the existing Person erasure runbook pattern, scoped to the artist's contact rows when needed — backlog ART-24). ## 5. Schema impact This is a substantial migration. Sessions 1 sets up new tables; the existing planned `§3.5.7` schema is **partly replaced**. ### 5.1 New tables ``` artists # NEW — org-level master artist_engagements # NEW — per-event booking genres # NEW — org-level configurable list stage_days # NEW — pivot stage × event (sub-event or flat) performances # NEW — per-engagement scheduled show advance_sections # NEW — engagement-scoped (was artist-scoped in §3.5.7 plan) advance_submissions # NEW artist_contacts # NEW — master contacts (FK to artist, not engagement) ``` Tables NOT created (from §3.5.7's planned design): ``` artist_riders — replaced by Form Builder (artist_advance purpose) itinerary_items — out of scope v1 (could become Form Builder later) ``` ### 5.2 Modified tables ``` companies + handles_buma bool default false (existing 'agency' value in type enum is reused unchanged) ``` ### 5.3 Detailed table specs #### `artists` (replaces planned `artists` in §3.5.7) | Column | Type | Notes | |---|---|---| | `id` | ULID | PK | | `organisation_id` | ULID FK | → organisations | | `name` | string(120) | | | `slug` | string(120) | for URL paths | | `default_genre_id` | ULID FK nullable | → genres | | `default_draw` | int nullable | expected pull (capacity-warn input) | | `star_rating` | tinyint nullable | 1–5 | | `home_base_country` | string(2) nullable | ISO 3166-1 alpha-2 | | `agent_company_id` | ULID FK nullable | → companies (type=agency) | | `notes` | text nullable | | | `created_at`, `updated_at`, `deleted_at` | | soft delete | **Unique:** `UNIQUE(organisation_id, slug)` **Indexes:** `(organisation_id, name)`, `(default_genre_id)`, `(agent_company_id)` **Soft delete:** yes **Scope:** `OrganisationScope` (direct FK) #### `artist_engagements` | Column | Type | Notes | |---|---|---| | `id` | ULID | PK | | `organisation_id` | ULID FK | → organisations (denormalised for scope) | | `artist_id` | ULID FK | → artists | | `event_id` | ULID FK | → events (festival or flat event) | | `booking_status` | enum | D9 — 9 values | | `project_leader_id` | ULID FK nullable | → users | | `fee_amount` | decimal(10,2) nullable | | | `fee_currency` | string(3) default 'EUR' | | | `fee_type` | enum nullable | flat/door_split/guarantee_plus_split | | `buma_applicable` | bool default true | | | `buma_percentage` | decimal(5,2) default 7.00 | | | `buma_handled_by` | enum | D26 — 3 values | | `vat_applicable` | bool default true | | | `vat_percentage` | decimal(5,2) default 21.00 | | | `deal_breakdown` | JSON nullable | opaque line-items | | `deposit_percentage` | decimal(5,2) nullable | | | `deposit_due_date` | date nullable | | | `balance_due_date` | date nullable | | | `payment_status` | enum default 'none' | none/deposit_paid/paid_in_full | | `crew_count` | int default 0 | | | `guests_count` | int default 0 | | | `requested_at` | datetime nullable | when status set to Requested | | `option_expires_at` | datetime nullable | required when status=Option | | `advance_open_from` | datetime nullable | | | `advance_open_to` | datetime nullable | | | `portal_token` | ULID unique nullable | tour-manager portal | | `advancing_completed_count` | int default 0 | observer-maintained | | `advancing_total_count` | int default 0 | observer-maintained | | `notes` | text nullable | | | `created_at`, `updated_at`, `deleted_at` | | soft delete | **Unique:** `UNIQUE(artist_id, event_id)`, `UNIQUE(portal_token)` **Indexes:** `(organisation_id)`, `(event_id, booking_status)`, `(option_expires_at)` (for the demote-job) **Soft delete:** yes (cascades to performances, advance_sections via observer) **Scope:** `OrganisationScope` (direct FK on `organisation_id`) #### `stages` (replaces planned `stages` in §3.5.7) | Column | Type | Notes | |---|---|---| | `id` | ULID | PK | | `event_id` | ULID FK | → events (festival or flat event) | | `name` | string(120) | | | `color` | string(7) | hex | | `capacity` | int nullable | for capacity-warn | | `sort_order` | int default 0 | D23 | | `created_at`, `updated_at` | | | **Unique:** `UNIQUE(event_id, name)` **Indexes:** `(event_id, sort_order)` **Soft delete:** no (per v0.1 §5; deleting a stage is rare and destructive; soft-delete adds query complexity without safety win) **Scope:** `OrganisationScope` (FK-chain via event) #### `stage_days` (revised: event_id instead of day_date) | Column | Type | Notes | |---|---|---| | `id` | int AI | PK — integer for join performance | | `stage_id` | ULID FK | → stages | | `event_id` | ULID FK | → events (sub-event or flat event = "show host") | **Unique:** `UNIQUE(stage_id, event_id)` **Indexes:** `(event_id)` **Soft delete:** no (pure pivot) The pivot semantics: for a festival, each stage has multiple `stage_days` rows (one per active sub-event). For a flat event, each stage has exactly one `stage_days` row referencing the event itself. This uniformity simplifies the query layer. #### `performances` (replaces planned `performances` in §3.5.7) | Column | Type | Notes | |---|---|---| | `id` | ULID | PK | | `engagement_id` | ULID FK | → artist_engagements | | `event_id` | ULID FK | → events (sub-event or flat event = "show host") | | `stage_id` | ULID FK nullable | → stages; NULL = parked/wachtrij | | `lane` | unsigned tinyint default 0 | D13 | | `start_at` | datetime | ⩾ event.start_at, < event.end_at | | `end_at` | datetime | > start_at, ⩽ event.end_at | | `version` | int default 0 | D14 — optimistic lock | | `notes` | text nullable | | | `created_at`, `updated_at`, `deleted_at` | | soft delete | **Indexes:** `(event_id, stage_id, start_at, end_at)`, `(engagement_id)`, `(stage_id, start_at)` for the lane resolver **Soft delete:** yes (cascade with engagement via observer) **Scope:** `OrganisationScope` (FK-chain via event → engagement) #### `genres` Spec in D24. #### `advance_sections` (revised: engagement_id, not artist_id) | Column | Type | Notes | |---|---|---| | `id` | ULID | PK | | `engagement_id` | ULID FK | → artist_engagements **(was artist_id)** | | `name` | string(80) | | | `type` | enum | guest_list/contacts/production/custom | | `is_open` | bool default false | | | `open_from` | datetime nullable | | | `open_to` | datetime nullable | | | `sort_order` | int default 0 | | | `submission_status` | enum | open/pending/submitted/approved/declined | | `last_submitted_at` | timestamp nullable | | | `last_submitted_by` | string nullable | tour manager name from form | | `submission_diff` | JSON nullable | | **Indexes:** `(engagement_id, is_open)`, `(engagement_id, submission_status)` #### `advance_submissions` Unchanged from §3.5.7 plan, FK adjusted to `advance_section_id` which now traces to engagement, not artist. #### `artist_contacts` (master-scoped) | Column | Type | Notes | |---|---|---| | `id` | ULID | PK | | `artist_id` | ULID FK | → artists (master) | | `name` | string(120) | | | `email` | string nullable | | | `phone` | string nullable | | | `role` | string(60) | tour manager / agent / booker / etc. | | `is_primary` | bool default false | one primary per role | | `receives_briefing` | bool default false | | | `receives_infosheet` | bool default false | | **Indexes:** `(artist_id, role)` V1 keeps contacts on the master artist. Engagement-specific contacts deferred (most contacts persist across events for the same artist). ### 5.4 Soft delete strategy | Table | Soft delete | Cascade behaviour | |---|---|---| | `artists` | Yes | **NO cascade** to engagements (D27). Master record marked `deleted_at`; engagements remain fully functional with visual "verwijderd" markers. | | `artist_engagements` | Yes | Cascade to performances + advance_sections via observer | | `performances` | Yes | Direct cascade with engagement | | `advance_sections` | No | Hard delete with engagement | | `advance_submissions` | No | Audit-immutable; remain for retention compliance | | `artist_contacts` | No | Hard delete with artist master | | `stages` | No | Performances cascade-park (set stage_id=null) | | `stage_days` | No | Pure pivot, hard delete | | `genres` | No (use `is_active`) | Re-link or block depending on referencing artists | ### 5.5 Cross-doc updates - `SCHEMA.md` §3.5.7 fully rewritten in Session 1 (move from `ARCH-PLANNED-MODULES.md` per the established pattern) - `ARCH-FORM-BUILDER.md` §3.2.5 (`artist_advance` lifecycle) updated to reflect engagement-scoped sections instead of artist-scoped - `ARCH-FORM-BUILDER.md` §17.3 (purpose registry) gets a footnote about engagement context resolution - `BACKLOG.md`: ARCH-09 + ART-02 + ART-03 closed; ART-04 through ART-23 + FIN-01 + FIN-02 added ## 6. Routes & API All routes scoped to `events.{event}` with `OrganisationScope` enforced via FK-chain. ### 6.1 Org-level resources ``` GET /api/v1/organisations/{org}/artists # paginated, search by name/slug, filter by genre/agent_company/has_upcoming_engagements, sort by name|last_engagement_at|first_engagement_at, ?with_trashed=true|trashed_only=true to include soft-deleted POST /api/v1/organisations/{org}/artists # master create GET /api/v1/organisations/{org}/artists/{artist} # full detail incl. engagements summary (lifetime + upcoming) PATCH /api/v1/organisations/{org}/artists/{artist} # master update DELETE /api/v1/organisations/{org}/artists/{artist} # soft-delete; engagements untouched (D27) POST /api/v1/organisations/{org}/artists/{artist}/restore # unset deleted_at; logged GET /api/v1/organisations/{org}/genres # config list POST /api/v1/organisations/{org}/genres PATCH /api/v1/organisations/{org}/genres/{genre} DELETE /api/v1/organisations/{org}/genres/{genre} # block if referenced ``` ### 6.2 Event-level resources ``` GET /api/v1/events/{event}/engagements # paginated, search, status filter POST /api/v1/events/{event}/engagements # create from existing or new artist GET /api/v1/events/{event}/engagements/{engagement} # full detail PATCH /api/v1/events/{event}/engagements/{engagement} # status, deal info, etc. DELETE /api/v1/events/{event}/engagements/{engagement} # cancel + soft-delete GET /api/v1/events/{event}/stages # ordered by sort_order POST /api/v1/events/{event}/stages PATCH /api/v1/events/{event}/stages/{stage} DELETE /api/v1/events/{event}/stages/{stage} # cascade-park performances PATCH /api/v1/events/{event}/stages/order # {stage_ids: ULID[]} PUT /api/v1/events/{event}/stages/{stage}/days # {event_ids: ULID[]} replaces matrix PUT /api/v1/events/{event}/lineup # bulk matrix replacement GET /api/v1/events/{event}/performances?day={subevent} # filter by sub-event GET /api/v1/events/{event}/performances?stage_id=null # wachtrij POST /api/v1/events/{event}/performances # create (or park if stage_id null) GET /api/v1/events/{event}/performances/{perf} PATCH /api/v1/events/{event}/performances/{perf} # status/notes only DELETE /api/v1/events/{event}/performances/{perf} POST /api/v1/events/{event}/timetable/move # D18 — transactional cascade ``` ### 6.3 Engagement sub-resources ``` GET /api/v1/engagements/{engagement}/advance-sections POST /api/v1/engagements/{engagement}/advance-sections PATCH /api/v1/advance-sections/{section} # toggle is_open, etc. DELETE /api/v1/advance-sections/{section} GET /api/v1/engagements/{engagement}/contacts # via artist master POST /api/v1/engagements/{engagement}/portal-token # generate or rotate DELETE /api/v1/engagements/{engagement}/portal-token # revoke ``` ### 6.4 Portal endpoints (token-authenticated) Existing `/p/*` routes get the artist portal: ``` GET /api/v1/p/artist/{token} # event + engagement context GET /api/v1/p/artist/{token}/advance # form_schema + sections POST /api/v1/p/artist/{token}/advance/sections/{section} # submit one section GET /api/v1/p/artist/{token}/crew-list # placeholder rows PATCH /api/v1/p/artist/{token}/crew-list/{person} # tour manager edits crew ``` `PortalTokenMiddleware` resolves engagement context from `portal_token` and exposes `portal_engagement` + `portal_event` attributes. ### 6.5 Resource shapes `PerformanceResource`: ```json { "id": "01HX...", "engagement_id": "01HX...", "event_id": "01HX...", "stage_id": "01HX..." | null, "lane": 0, "lane_resolved": 0, // D19 "start_at": "2026-07-10T22:00:00+02:00", "end_at": "2026-07-11T00:00:00+02:00", "version": 3, "warnings": ["overlap", "capacity"], // populated server-side "engagement": { "id": "01HX...", "booking_status": "confirmed", "advancing_completed_count": 4, "advancing_total_count": 5, "artist": { "id": "01HX...", "name": "Devin Wild", "slug": "devin-wild", "default_genre": { "id": "...", "name": "Hardstyle" }, "default_draw": 4200, "initials": "DW" // computed by resource } }, "stage": { "id": "01HX...", "name": "Hardstyle District", "color": "#e85d75", "capacity": 4500 } | null } ``` `Idempotency-Key` header required on `POST /performances`, `POST /timetable/move`, `POST /engagements`. Same pattern as form_submissions. ## 7. Frontend architecture ``` apps/app/src/pages/events/[id]/timetable/ index.vue — page entry, day-tab state, ?day query sync TimetableGrid.vue — scroll container, time axis, stage rows StageRow.vue — left cell + draggable performance row + reorder handle StageHeaderCell.vue — swatch + name + capacity + day chips PerformanceBlock.vue — single block; emits drag/resize/click; CSS-tokens for status PerformancePopover.vue — floating detail; teleported; closes on Escape Wachtrij.vue — sidebar with search + status multi-select + genre pills WachtrijCard.vue — single parked card AddPerformanceDialog.vue — modal create StageEditor.vue — single-stage modal CRUD with cascade preview LineupMatrix.vue — bulk stages × sub-events editor modal EmptyDayState.vue — "no stages on this day, copy from..." apps/app/src/pages/events/[id]/artists/[engagement]/ index.vue — engagement detail page with tabs OverviewTab.vue — deal info + Buma + crew/guests + status timeline PerformancesTab.vue — list of performances within event AdvancingTab.vue — section list with Form Builder section components ContactsTab.vue — master contacts (read-only mostly) CrewGuestsTab.vue — crowd_list view + tour-manager-fillable ActivityLogTab.vue — Spatie activity log filtered by subject apps/app/src/pages/p/artist/[token]/ index.vue — portal landing PerformanceCard.vue — show details (time, stage, load-in) AdvanceFormSection.vue — reuse Form Builder section render apps/app/src/pages/organisation/artists/ index.vue — Master Artist Library: search, filter sidebar, paginated VDataTable, "Toon verwijderde" toggle ArtistRow.vue — list row: avatar, name, genre, last engagement, # engagements, status badge if trashed AddArtistDialog.vue — create master (or pick existing — also reused inline from "+ Performance") RestoreArtistDialog.vue — restore-confirm with summary of N engagements apps/app/src/pages/organisation/artists/[artist]/ index.vue — Master Artist Detail with 4 tabs OverviewTab.vue — name, default_genre, default_draw, star_rating, home_base, agent_company, notes EngagementsTab.vue — cross-event history table: event, dates, status, fee, performances count, link ContactsTab.vue — master `artist_contacts` CRUD ActivityLogTab.vue — Spatie activity log filtered by Artist subject TrashedBanner.vue — shared banner component (also rendered by EngagementOverviewTab when artist is trashed) apps/app/src/composables/timetable/ useTimetable.ts — TanStack queries: stages, performances per day useTimetableMutations.ts — performance CRUD + the move endpoint with optimistic update + rollback useStageDays.ts — matrix replacement useDragOrClick.ts — single composable for threshold + suppression (replaces 3-place duplicate) useLaneCascade.ts — pure preview-only logic during drag (server is authoritative) usePointerDrag.ts — PointerEvents-based drag (modern replacement for mousedown stack) useTimetableKeyboard.ts — D20 keyboard model apps/app/src/composables/engagements/ useEngagement.ts — single-engagement TanStack query useEngagementMutations.ts — status transitions, deal info edits apps/app/src/lib/timetable/ snap.ts, conflicts.ts, b2b.ts — pure logic ports of helpers.js laneAssign.ts — Pass 2 logic for client-side preview capacity.ts — capacity-warn computation apps/app/src/types/ artist.ts, engagement.ts, performance.ts, stage.ts (zod schemas) apps/app/src/api/ artists.ts, engagements.ts, stages.ts, performances.ts, timetable.ts ``` State pattern: - TanStack Query owns canonical server state (per-event timetable cached with stale-time 30s; mutations invalidate selectively) - Pinia store `useTimetableStore` owns ONLY UI state (selectedDay, selectedPerformanceId, popoverPosition, dragState, keyboardFocusId) — never duplicate server data - Optimistic updates on drag/move via TanStack `onMutate` rollback pattern; on 409 toast + refetch - Server response from `POST /timetable/move` patches both the moved perf AND every cascade entry into the cache in one operation Form validation: VeeValidate + Zod on all dialogs + the engagement detail forms. Schemas mirrored from `apps/app/src/types/`. ## 8. Activity log Spatie ActivityLog with `LogsActivity` trait on: - `Artist` (master), `ArtistEngagement`, `Stage`, `Performance` - `Genre` (config-level changes) Logged events: - `artist.created`, `artist.updated`, `artist.deleted` - `artist_engagement.created`, `artist_engagement.status_changed`, `artist_engagement.deal_info_updated`, `artist_engagement.cancelled`, `artist_engagement.option_expired` (system-causer for the daily job) - `stage.created`, `stage.updated`, `stage.deleted`, `stage.day_added`, `stage.day_removed`, `stage.reordered` - `performance.created`, `performance.updated`, `performance.deleted` - `performance.moved` — D18 cascade — single parent entry with `properties.cascade_count`, `properties.cascaded_ids[]` - `performance.parked`, `performance.unparked` - `genre.created`, `genre.updated`, `genre.deactivated` Capture before/after on: name, color, capacity (stages); booking_status, fee_amount, buma_handled_by, option_expires_at (engagements); start_at, end_at, stage_id, lane, version (performances). Activity log entries scoped to `organisation` + `event` for the event audit log filter. ## 9. Authorization (Policy) ``` ArtistPolicy: viewAny(User, Organisation) — member of organisation view(User, Artist) — same + organisation match create(User, Organisation) — 'organisations.manage_artists' update(User, Artist) — same as create delete(User, Artist) — same as create + no active engagements ArtistEngagementPolicy: viewAny(User, Event) — event member + 'events.view_program' view(User, ArtistEngagement) — viewAny + scope match create(User, Event) — 'events.manage_program' update(User, ArtistEngagement) — same as create delete(User, ArtistEngagement) — same as create StagePolicy: same as v0.1 §9 PerformancePolicy: same as v0.1 §9 GenrePolicy: viewAny, view: any org member create/update/delete: 'organisations.manage_settings' ``` New permissions: - `events.view_program` — read-only timetable access - `events.manage_program` — full CRUD on engagements/performances/stages - `organisations.manage_artists` — master artist CRUD - `organisations.manage_settings` — genre config New roles: - `program_manager` — gets `events.manage_program` + `organisations.manage_artists` - `production_assistant` — gets `events.view_program` only ## 10. Validation rules ### 10.1 Status enum (D9) ```php PerformanceBookingStatus::cases() = [ Draft, Requested, Option, Offered, Confirmed, Contracted, Cancelled, Rejected, Declined ] ``` State-transition rules (FormRequest validates): - `Option` requires `option_expires_at` in the future - `Contracted` requires `fee_amount` set - `Rejected` and `Declined` are terminal (no further transitions) - `Cancelled` cascade-soft-deletes performances (observer) ### 10.2 `CreatePerformanceRequest` / `UpdatePerformanceRequest` ``` engagement_id required ulid exists:artist_engagements,id (scoped to event via engagement.event_id chain) event_id required ulid exists:events,id (must equal engagement.event_id; must be flat event OR a sub-event of the engagement.event_id festival) stage_id nullable ulid exists:stages,id (if non-null: must satisfy StageActiveOnEvent rule) start_at required date format Y-m-d H:i:s must be ⩾ event.start_at must be < event.end_at (sub-event bounds enforced via WithinSubEventBounds rule) end_at required date format Y-m-d H:i:s must be > start_at must be ⩽ event.end_at (no cross-event-window — naturally bounded by event-config) lane nullable integer min:0 max:9 (10-lane ceiling; can be raised, but ten lanes is already pathological in practice) notes nullable string max:1000 ``` Overlap, B2B, capacity-warn are NOT validation errors — they produce 200 with `warnings: [...]` array. The booker decides. ### 10.3 `CreateArtistEngagementRequest` ``` artist_id required ulid exists:artists,id (org-scoped) event_id required ulid exists:events,id (org-scoped, must be flat event OR festival; cannot be a sub-event) booking_status required enum (D9) if Option → option_expires_at required + future if Contracted → fee_amount required project_leader_id nullable ulid exists:users,id (must be event member) fee_amount nullable decimal min:0 max:9999999.99 fee_currency nullable string size:3 in:EUR,USD,GBP,... buma_applicable boolean default true buma_percentage decimal min:0 max:100 default 7.00 buma_handled_by enum (D26) crew_count integer min:0 max:200 guests_count integer min:0 max:1000 ``` ### 10.4 `MoveTimetablePerformanceRequest` (D18) ``` performance_id required ulid exists:performances,id (event-scoped) target_stage_id nullable ulid exists:stages,id (StageActiveOnEvent or null=park) target_start_at nullable date format Y-m-d H:i:s required_unless target_stage_id null WithinEventBounds rule target_end_at nullable date format Y-m-d H:i:s required_unless target_stage_id null after:target_start_at target_lane nullable integer min:0 max:9 version required integer (optimistic lock) ``` ### 10.5 Stage-day matrix replacement (`PUT /stages/{stage}/days`) ``` event_ids required array min:1 event_ids.* ulid exists:events,id (each must be sub-event of stage.event_id, OR if stage.event_id is flat event → must equal stage.event_id) ``` If the request would remove an event_id with scheduled non-cancelled performances on that stage, return **409 Conflict** with body `{ performances_on_removed_events: [...] }`. Frontend shows a matrix-editor confirmation dialog and resends with `?force_orphan=true`. Orphaned performances persist (soft references, hidden from views) — recovered when the day re-activates. ## 11. Open questions / future work **No open questions remain at v0.2.** All material decisions are locked. Recorded resolutions: - **R1 — Idempotency-Key window on `POST /timetable/move`:** 60-second window in Redis (not 12-hour MySQL per ARCH §10 default). Rationale: a stale 12-hour replay of a cascade-bump can corrupt timetable state in non-trivial ways. Implementation in Session 2. - **R2 — Hard-delete artists:** not in v1. `deleted_at` is terminal. Real GDPR erasure is a separate flow (backlog ART-24). - **R3 — Engagement-specific contacts (not master-scoped):** deferred. V1 keeps all `artist_contacts` on the master `artists` record. If per-event contact divergence becomes operational, split via `artist_engagement_contacts` polymorphic table in v2. - **R4 — Per-tenant status colour overrides:** infrastructure ready via D21 CSS-tokens; activation deferred to ART-14. Out-of-v1 backlog items are listed in §3.2. ## 12. Implementation order Six Claude Code sessions, sequential. Estimated **22-26 days total**. ### 12.0 — Authoritative reference materials for ALL frontend sessions The interactive prototype at `./resources/Crewli - Artist Timetable Management/` and the prototype audit at `dev-docs/audits/PROTOTYPE-AUDIT-ARTIST-TIMETABLE.md` (commit `a57437a`) are the **authoritative UX/UI blueprint** for the frontend. Claude Code prompts for **Sessions 4, 5, and 6** explicitly require both as required input before writing any component. What is to be **ported, not reinvented:** - Visual layouts (timetable grid, blocks, popover, modals, wachtrij) - Drag-and-drop snap math and interaction semantics - Click-vs-drag threshold and click-suppression timing - Lane assignment + cascade-bump algorithms (verbatim per audit §5) - Conflict / B2B / capacity-warn detection (verbatim per audit §5) - Status filter UX (multi-select with counts, "Alle aan/uit", default cancelled-off) - Stage editor + lineup matrix layout - Empty-day copy-from-other-day affordance - Design tokens (colours, dimensions, typography, animations) per audit §6 What is **net new for production** (not in prototype): - Multi-tenancy (`OrganisationScope`, no cross-tenant data in store) - Real backend persistence with TanStack Query + version-conflict rollback - Optimistic updates with onError revert - Server-transactional cascade-bump (D18) + server-side lane resolution (D19) - Keyboard a11y model (D20) — from-scratch work, not port - CSS-tokens for status colours (D21), Vuetify/Vuexy integration - Master Artist Library (Session 6) — no prototype reference exists - Activity log integration The audit document quotes verbatim code for the five pure-logic functions and the cascade-bump algorithm. Sessions 4+ port these functions to TypeScript with line-for-line correspondence; new TypeScript types replace JavaScript implicit types but the algorithm shape is identical. ### Session 1 — Foundation (3-4 days) 1. Migrations (in order): - `create_genres_table` - `create_artists_table` - `add_handles_buma_to_companies_table` - `create_artist_contacts_table` - `create_stages_table` (with sort_order) - `create_stage_days_table` (event_id-based) - `create_artist_engagements_table` (with all 26 columns from D10) - `create_performances_table` (with engagement_id, lane, version, nullable stage_id) - `create_advance_sections_table` (engagement_id-scoped) - `create_advance_submissions_table` 2. PHP enums (5 enums per D9, D26, fee_type, payment_status, advance section types) 3. Eloquent models with `HasUlids`, `OrganisationScope`, `LogsActivity` traits 4. Observers: `ArtistEngagementObserver` (advancing aggregate recompute), `PerformanceObserver` (version bump, cascade soft-delete) 5. Factories + DevSeeder integration (festival with 4 stages, 6 artists, ~12 engagements, ~13 performances reproducing the prototype's fixture) 6. Update `AppServiceProvider::PURPOSE_SUBJECT_FQCN`: string-literal `'artist' => 'App\\Models\\Artist'` → `Artist::class` 7. SCHEMA.md update (move §3.5.7 from `ARCH-PLANNED-MODULES.md` to `SCHEMA.md`, fully rewrite to reflect engagement model) 8. ARCH-FORM-BUILDER §3.2.5 update (engagement-scoped advance) ### Session 2 — Backend API + business logic (3-4 days) 1. Spatie permissions + roles (`events.view_program`, `events.manage_program`, `organisations.manage_artists`, `organisations.manage_settings`; roles `program_manager`, `production_assistant`) 2. Policies (5 of them per §9) 3. Services: - `ArtistService` (master CRUD, duplicate detection by name + slug) - `ArtistEngagementService` (status transitions with state machine validation, defaults from artist master, Buma auto-flip) - `StageService`, `StageDayService` (atomic matrix replace with orphan-performance handling) - `PerformanceService` (CRUD, park/unpark) - `LaneCascadeService` (the D18 transactional move algorithm with `SELECT … FOR UPDATE` locks) - `GenreService` (org-level config) 4. FormRequests (§10) 5. Custom validation rules: `StageActiveOnEvent`, `WithinEventBounds`, `OptionExpiresInFuture`, `ContractRequiresFee` 6. API Resources with `lane_resolved` computation (D19) 7. Controllers (thin) 8. Routes (§6) 9. Activity log integration (§8) 10. Scheduled command `DemoteExpiredOptions` (daily, 03:00) 11. Tests: - Feature tests for all endpoints (200/201/204/401/403/404/409/422) - Policy tests (positive + negative) - Validation tests - Cascade-bump transaction tests (concurrent moves return 409) - Status transition tests (rejected → contracted blocked) - Buma calculation tests - Option expiry job test 12. API.md update ### Session 3 — Form Builder integration (1-2 days) 1. `ArtistAdvanceDefault` template seeder with 5 sections (General Info, Contacts, Production, Technical Rider, Hospitality) 2. Update `ArtistResolver::fromPortalToken` to resolve via engagement (was via artist; engagement carries the portal_token now) 3. `EngagementPortalController` with the `/p/artist/{token}/*` endpoint family 4. Wire engagement.event_id into form_submissions via the resolver 5. Update `PurposeRegistry` documentation footnote about engagement context 6. Tests for portal-flow round-trip ### Session 4 — Frontend Timetable (5-6 days) 1. Types + Zod schemas (per §7 file structure) 2. API client modules 3. TanStack composables + Pinia store 4. Page entry (`index.vue` with day-tab state + ?day query sync) 5. Components in dependency order: - TimeAxis, GridBg - StageHeaderCell, StageRow - PerformanceBlock (with CSS tokens for status, capacity icon, B2B dots, conflict ring) - PerformancePopover (with Form Builder advancing aggregate fetch) - Wachtrij + WachtrijCard - AddPerformanceDialog - StageEditor + LineupMatrix - EmptyDayState 6. Composables: - `useDragOrClick` (threshold + click-suppression, single source) - `usePointerDrag` (PointerEvents replacement of mousedown stack) - `useTimetableMutations` (move + park + unpark + version-rollback) - `useTimetableKeyboard` (D20 model) 7. Pure logic ports from prototype `helpers.js` to `lib/timetable/` 8. CSS-tokens file (D21) 9. Tests: - Vitest unit on pure logic (snap, conflicts, B2B, lane preview) - Component tests on PerformanceBlock (status rendering, drag invokes mutation, optimistic + rollback) - Integration: full add → drag → resize → park → delete flow with mocked API - Keyboard a11y: tab order, arrow nudges, escape to cancel drag - Screen-reader test on focused block aria-labels (axe-core) ### Session 5 — Frontend Engagement Detail + Portal (3-4 days) 1. `/events/{event}/artists/{engagement}` detail page with 6 tabs 2. Each tab as separate component (Overview, Performances, Advancing, Contacts, CrewGuests, ActivityLog) 3. Buma + VAT live calculation in OverviewTab 4. Status timeline component with transition-history rendering 5. `/p/artist/{token}` portal pages (reuse Form Builder section render components for Advancing form) 6. Portal-side: performance card, locatie, hospitality summary, crew/guestlist editor, contact-organisation list 7. TrashedBanner shared component (also rendered when artist master is `deleted_at` non-null per D27) 8. Tests: - E2E: organizer creates engagement → opens timetable → drags → opens detail → updates status → opens portal as tour manager → fills advance section → returns to organizer view - Portal-token validation tests - Tab switching state preservation - Trashed-banner appears when artist soft-deleted, disappears on restore ### Session 6 — Frontend Master Artist Library (2-3 days) 1. `/organisation/artists` list page: - Vuetify VDataTable with pagination + search by name/slug + filter sidebar (genre multi-select, agent_company, has_upcoming_engagements) - Sort: name (default), last_engagement_at desc, first_engagement_at - Action column: edit, soft-delete (with confirm dialog), restore (when trashed) - "Toon verwijderde" toggle in header (default off) - "+ Nieuwe artiest" button → AddArtistDialog 2. `/organisation/artists/{artist}` detail page with 4 tabs: - **Overview:** master fields (name, default_genre, default_draw, star_rating, home_base_country, agent_company, notes); inline edit; TrashedBanner if soft-deleted - **Engagements:** cross-event history table — event name + dates + booking_status + fee_amount + performance count + link to engagement detail; sortable; future engagements visually distinguished from past - **Contacts:** master `artist_contacts` CRUD inline editing - **Activity log:** Spatie activity log filtered by Artist subject 3. AddArtistDialog (also reusable inline from "+ Performance" flow when booker types a new act name in the modal) 4. RestoreArtistDialog with confirm + summary of "{N} historische boekingen, {M} toekomstige boekingen worden hersteld" 5. TrashedBanner shared component (also imported by Session 5) 6. Tests: - Search + filter combinations - Soft-delete flow: confirm dialog, banner appears on engagement views - Restore flow: banner disappears - Trashed artist hidden from AddPerformanceDialog dropdown - Cannot create new engagement for trashed artist (modal disables) - Trashed artist visible in EngagementHistoryTable on master detail - Cross-event analytics correctness ## 13. Test strategy ### 13.1 Backend coverage targets - 100% on `LaneCascadeService` (the D18 transactional algorithm) - 100% on `ArtistEngagementService::transition` (the 9-state machine) - 100% on Buma calculation derivation - All policy methods (positive + negative) - All endpoints (success + 4 error cases each) Specific scenarios: - Two concurrent move requests on same performance → second returns 409 - Move-with-cascade where cascade hits a `lane=9` block (max boundary) - Cascade-bump on a busy stage where 5+ blocks need bumping - Option expiry job with mixed expired/active/no-option engagements - Stage-day matrix replace with orphan-performance handling - Form Builder section submit via portal_token with engagement context ### 13.2 Frontend coverage - Pure: snap math, conflict detection, B2B detection, lane preview, capacity warn, day-filter - Component: PerformanceBlock renders correctly per booking_status, shows version-conflict toast, optimistic rollback works - Integration: full add → drag → edit → delete flow with mocked API - Keyboard a11y: focused block + arrow nudges; escape cancels move - Screen reader: aria-label on blocks; aria-live on status changes - Visual: snapshot tests on per-status block rendering ### 13.3 E2E (Playwright) - Booker workflow: create engagement → schedule performance → drag to new time → open popover → change status to Contracted → see fee-required validation - Tour manager workflow: receive portal link → open advance form → fill technical rider section → submit → see updated advancing % in organizer view (after polling) - Concurrency: two browsers, same timetable, simultaneous drags on different blocks → no data loss ## 14. Dependencies ### 14.1 Hard - ARCH-09 (Artist model) — resolved by Session 1 - New permission `events.manage_program` and role `program_manager` — Session 2 prerequisite - New permission `organisations.manage_artists` — Session 2 ### 14.2 Soft - ART-15 (realtime broadcasts) — would enable multi-user live update but version conflict (D14) covers correctness in v1 - ART-04 (tech-rider parsing) — would deepen capacity warning; not blocking - Notification framework (planned post-Accreditation) — needed for option-expiry email + advance-due reminders (v1 sends via existing EmailService directly) ### 14.3 None - WS-3 (SPA merge) — already complete (verified 2026-05-08) - Accreditation Engine — fully independent (auto-cascade from Confirmed status to accreditation reservations is a v2 nice-to-have) - Briefings module — independent ## 15. Cross-references - `dev-docs/audits/PROTOTYPE-AUDIT-ARTIST-TIMETABLE.md` (commit `a57437a`) — exhaustive prototype reference for UX/algorithm details - `dev-docs/SCHEMA.md` — will be updated by Session 1 - `dev-docs/ARCH-FORM-BUILDER.md` §3.2.5, §17.3 — will be footnoted by Session 3 - `dev-docs/AUTH_ARCHITECTURE.md` §6 — portal-token flow already in place - `dev-docs/ARCH-BINDINGS.md` v1.2 — for the Form Builder integration invariants - `BACKLOG.md` — closing ARCH-09, ART-02, ART-03; opening ART-04 through ART-24, FIN-01, FIN-02 ## 16. Glossary - **Artist (master)** — org-level record describing a performer; one per organisation per real-world act. Persists across events. - **ArtistEngagement** — per-event booking record; owns deal info, status, advancing, portal_token. Multiple per artist over time. - **Performance** — scheduled show on a stage at a time, belonging to an engagement. 1:N with engagement. - **Wachtrij** — runtime view of performances with `stage_id IS NULL`; not a separate table. - **Show host** — the event a performance hangs on: a sub-event for festivals/series, or the flat event itself. - **Lane** — within-stage vertical slot for parallel performances. 0-indexed. - **Cascade-bump** — algorithm that pushes existing blocks down a lane when a new block is dropped on top. Server-transactional. - **Buma** — Dutch rights-collection society. 7% of fee, with three handling modes (organisation / booking_agency / not_applicable). - **Advancing** — the process of collecting performer data (rider, hospitality, technical needs) via the artist_advance Form Builder purpose, section by section. --- End of RFC v0.2. This document is the authoritative spec for the Artist Timetable & Engagement module across **6 implementation sessions, 22-26 days total**. Implementation prompts will reference specific decisions (e.g. "per RFC v0.2 D18") rather than restating them. Frontend prompts (Sessions 4, 5, 6) additionally require the prototype + audit doc as authoritative UX/UI input — see §12.0.