diff --git a/.claude-sync.conf b/.claude-sync.conf index fee03636..a8d3a18b 100644 --- a/.claude-sync.conf +++ b/.claude-sync.conf @@ -18,4 +18,5 @@ dev-docs/GLITCHTIP.md dev-docs/ARCH-OBSERVABILITY.md dev-docs/runbooks/observability-triage.md dev-docs/runbooks/observability-erasure.md -dev-docs/RFC-WS-6.md \ No newline at end of file +dev-docs/RFC-WS-6.md +dev-docs/RFC-TIMETABLE-Artist-Timetable-Module.md \ No newline at end of file diff --git a/dev-docs/RFC-TIMETABLE-Artist-Timetable-Module.md b/dev-docs/RFC-TIMETABLE-Artist-Timetable-Module.md new file mode 100644 index 00000000..7f67f61b --- /dev/null +++ b/dev-docs/RFC-TIMETABLE-Artist-Timetable-Module.md @@ -0,0 +1,1654 @@ +# 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. diff --git a/resources/Crewli - Artist Timetable Management/Crewli Timetable.html b/resources/Crewli - Artist Timetable Management/Crewli Timetable.html new file mode 100644 index 00000000..d3071c34 --- /dev/null +++ b/resources/Crewli - Artist Timetable Management/Crewli Timetable.html @@ -0,0 +1,28 @@ + + +
+ +