From c9863ee4f86d1b0f224d0b3ecb9d303dbf0c3bf7 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 8 May 2026 16:57:03 +0200 Subject: [PATCH] Add design en information for developing the Artist Management module --- .claude-sync.conf | 3 +- .../RFC-TIMETABLE-Artist-Timetable-Module.md | 1654 +++++++++++++++++ .../Crewli Timetable.html | 28 + .../app.jsx | 517 ++++++ .../data.js | 159 ++ ...RFC-TIMETABLE - Artist Timetable Module.md | 530 ++++++ .../docs/timetable-module.md | 470 +++++ .../helpers.js | 144 ++ .../modals.jsx | 379 ++++ .../popover.jsx | 264 +++ .../styles.css | 1220 ++++++++++++ .../timetable.jsx | 1133 +++++++++++ .../tweaks-panel.jsx | 568 ++++++ .../uploads/pasted-1778201672092-0.png | Bin 0 -> 510622 bytes 14 files changed, 7068 insertions(+), 1 deletion(-) create mode 100644 dev-docs/RFC-TIMETABLE-Artist-Timetable-Module.md create mode 100644 resources/Crewli - Artist Timetable Management/Crewli Timetable.html create mode 100644 resources/Crewli - Artist Timetable Management/app.jsx create mode 100644 resources/Crewli - Artist Timetable Management/data.js create mode 100644 resources/Crewli - Artist Timetable Management/docs/RFC-TIMETABLE - Artist Timetable Module.md create mode 100644 resources/Crewli - Artist Timetable Management/docs/timetable-module.md create mode 100644 resources/Crewli - Artist Timetable Management/helpers.js create mode 100644 resources/Crewli - Artist Timetable Management/modals.jsx create mode 100644 resources/Crewli - Artist Timetable Management/popover.jsx create mode 100644 resources/Crewli - Artist Timetable Management/styles.css create mode 100644 resources/Crewli - Artist Timetable Management/timetable.jsx create mode 100644 resources/Crewli - Artist Timetable Management/tweaks-panel.jsx create mode 100644 resources/Crewli - Artist Timetable Management/uploads/pasted-1778201672092-0.png 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 @@ + + + + + Crewli · Timetable + + + + + + + + + + + + + + +
+ + + + + + + + diff --git a/resources/Crewli - Artist Timetable Management/app.jsx b/resources/Crewli - Artist Timetable Management/app.jsx new file mode 100644 index 00000000..d74d125e --- /dev/null +++ b/resources/Crewli - Artist Timetable Management/app.jsx @@ -0,0 +1,517 @@ +// Main app — wires data, state, modals, popover, drawer, tweaks together. + +const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ + "zoom": 1, + "density": "regular", + "showPercent": true +}/*EDITMODE-END*/; + +function App() { + const D = window.CrewliData; + const H = window.CrewliHelpers; + const M = window.CrewliModals; + const P = window.CrewliPopover; + const TT = window.CrewliTimetable; + + // ─── Mutable state (local, no backend) ──────────────────────────── + const [stages, setStages] = React.useState(D.STAGES); + const [stageDays, setStageDays] = React.useState(D.STAGE_DAYS); + const [artists, setArtists] = React.useState(D.ARTISTS); + const [performances, setPerformances] = React.useState(D.PERFORMANCES); + const [parked, setParked] = React.useState(D.PARKED); + const [pending, setPending] = React.useState(D.PENDING); + const [activeDayId, setActiveDayId] = React.useState(D.EVENT.days[0].id); + + // ─── UI state ───────────────────────────────────────────────────── + const [popover, setPopover] = React.useState(null); // { perf, rect } or { kind: 'queue', queueKey, item, rect } + const [drawer, setDrawer] = React.useState(null); // { perf } + const [perfModal, setPerfModal] = React.useState(null); // { mode, perf } + const [stageModal, setStageModal] = React.useState(null); // stage + const [matrixOpen, setMatrixOpen] = React.useState(false); + + const [t, setTweak] = useTweaks(TWEAK_DEFAULTS); + + const activeDay = D.EVENT.days.find(d => d.id === activeDayId); + + // Stages active on this day + const dayStages = React.useMemo(() => { + const ids = new Set(stageDays.filter(sd => sd.day_id === activeDayId).map(sd => sd.stage_id)); + return stages.filter(s => ids.has(s.id)); + }, [stages, stageDays, activeDayId]); + + const dayPerformances = performances.filter(p => p.day_id === activeDayId); + + // Reducer-ish dispatch + function dispatch(action) { + if (action.type === "update_perf") { + setPerformances(prev => prev.map(p => p.id === action.perf.id ? action.perf : p)); + } else if (action.type === "delete_perf") { + setPerformances(prev => prev.filter(p => p.id !== action.id)); + } else if (action.type === "add_perf") { + setPerformances(prev => [...prev, action.perf]); + } else if (action.type === "update_stage") { + setStages(prev => prev.map(s => s.id === action.stage.id ? action.stage : s)); + } else if (action.type === "delete_stage") { + setStages(prev => prev.filter(s => s.id !== action.id)); + setStageDays(prev => prev.filter(sd => sd.stage_id !== action.id)); + // Move scheduled performances on this stage to the wachtrij (parked) i.p.v. ze te verwijderen. + setPerformances(prev => prev.filter(p => p.stage_id !== action.id)); + setParked(prev => [ + ...prev, + ...performances + .filter(p => p.stage_id === action.id) + .map(p => ({ ...p, stage_id: null, dur: (p.end - p.start) || 60 })), + ]); + } else if (action.type === "add_stage") { + setStages(prev => [...prev, action.stage]); + setStageDays(prev => [...prev, ...action.days.map(did => ({ stage_id: action.stage.id, day_id: did }))]); + } else if (action.type === "reorder_stages") { + // dayStageIds = the new ordering for the day's stages. + // Strategy: keep non-day stages in their relative positions; replace the + // dayStages slice with the new order. + setStages(prev => { + const dayIdSet = new Set(action.dayStageIds); + const dayLookup = Object.fromEntries(prev.filter(s => dayIdSet.has(s.id)).map(s => [s.id, s])); + const orderedDay = action.dayStageIds.map(id => dayLookup[id]).filter(Boolean); + const result = []; + let cursor = 0; + for (const s of prev) { + if (dayIdSet.has(s.id)) { + // Replace with the next entry from orderedDay + if (cursor < orderedDay.length) result.push(orderedDay[cursor++]); + } else { + result.push(s); + } + } + return result; + }); + } else if (action.type === "set_stage_days_for_stage") { + setStageDays(prev => [ + ...prev.filter(sd => sd.stage_id !== action.stageId), + ...action.dayIds.map(did => ({ stage_id: action.stageId, day_id: did })), + ]); + } else if (action.type === "set_stage_days_all") { + setStageDays(action.list); + } else if (action.type === "park_perf") { + const p = performances.find(x => x.id === action.id); + if (!p) return; + setPerformances(prev => prev.filter(x => x.id !== action.id)); + setParked(prev => [...prev, { ...p, stage_id: null }]); + } else if (action.type === "unpark_perf") { + const p = parked.find(x => x.id === action.perf.id); + if (!p) return; + setParked(prev => prev.filter(x => x.id !== action.perf.id)); + setPerformances(prev => [...prev, action.perf]); + } else if (action.type === "schedule_pending") { + setPending(prev => prev.filter(x => x.id !== action.pendingId)); + setPerformances(prev => [...prev, action.perf]); + } else if (action.type === "delete_pending") { + setPending(prev => prev.filter(x => x.id !== action.id)); + } else if (action.type === "delete_parked") { + setParked(prev => prev.filter(x => x.id !== action.id)); + } else if (action.type === "update_pending_status") { + // Pending items don't actually carry a status — promoting their status + // means moving them into parked (no stage_id) with that status. + const pa = pending.find(x => x.id === action.id); + if (!pa) return; + const newParked = { + id: "p_" + Math.random().toString(36).slice(2, 8), + artist_id: pa.artist_id, day_id: pa.day_id, + stage_id: null, start: null, end: null, + status: action.status, + }; + setPending(prev => prev.filter(x => x.id !== action.id)); + setParked(prev => [...prev, newParked]); + } else if (action.type === "update_parked_status") { + setParked(prev => prev.map(p => p.id === action.id ? { ...p, status: action.status } : p)); + } else if (action.type === "add_parked") { + setParked(prev => [...prev, action.parked]); + } else if (action.type === "add_artist") { + setArtists(prev => [...prev, action.artist]); + } + } + + // Move parked → performances when used via drag + React.useEffect(() => { + const movedBack = performances.filter(p => p.stage_id !== null && parked.some(pk => pk.id === p.id)); + if (movedBack.length > 0) { + setParked(prev => prev.filter(pk => !movedBack.some(m => m.id === pk.id))); + } + }, [performances]); + + // Layout numerics + const pxPerMin = 4 * t.zoom; + const rowHeight = t.density === "compact" ? 52 : t.density === "comfy" ? 84 : 64; + + // ─── Conflict counts (for header chip) ───────────────────────────── + const conflictIds = H.findConflicts(dayPerformances); + + // Day counts + const stageCountByDay = {}; + D.EVENT.days.forEach(d => { + stageCountByDay[d.id] = stageDays.filter(sd => sd.day_id === d.id).length; + }); + + // ─── Handlers ───────────────────────────────────────────────────── + function handleSelectPerf(perf, rect) { + setPopover({ kind: "perf", perfId: perf.id, rect }); + setDrawer(null); + } + function handleSelectQueueItem(item, rect) { + // item: { kind, id, artist, status, dur, src } + setPopover({ kind: "queue", queueKey: item.kind + ":" + item.id, item, rect }); + setDrawer(null); + } + function handleClickEmpty(stage, minute, end, lane) { + setPerfModal({ + mode: "add", + perf: { + stage_id: stage.id, + start: minute, + end: typeof end === "number" ? end : minute + 60, + status: "concept", + ...(Number.isInteger(lane) ? { lane } : {}), + }, + }); + setPopover(null); + } + function handleEditStage(stage) { setStageModal(stage); setPopover(null); } + function handleAddStage() { + // Draft stage — niet meteen toevoegen aan state. StageEditor commit'et bij Opslaan. + const id = "s_" + Math.random().toString(36).slice(2, 8); + const draft = { id, name: "Nieuwe stage", color: "#3cc2a8", capacity: 2000, __draft: true }; + setStageModal(draft); + } + + const popoverPerf = popover && popover.kind === "perf" ? performances.find(p => p.id === popover.perfId) : null; + const drawerPerf = drawer ? performances.find(p => p.id === drawer.perfId) : null; + + return ( +
+ + +
+
setMatrixOpen(true)} + onAddStage={handleAddStage} + onAddPerformance={() => setPerfModal({ + mode: "add", + // No stage/time = lands in wachtrij + perf: { status: "requested" } + })} + /> + +
+ { + if (action.type === "update_perf" && parked.some(pk => pk.id === action.perf.id) && action.perf.stage_id) { + dispatch({ type: "unpark_perf", perf: action.perf }); + } else { + dispatch(action); + } + }} + activeDay={activeDay} + stages={dayStages} + performances={dayPerformances} + parked={parked.filter(p => p.day_id === activeDayId)} + pending={pending.filter(p => p.day_id === activeDayId)} + artists={artists} + onSelectPerf={handleSelectPerf} + onClickEmptyCell={handleClickEmpty} + onEditStage={handleEditStage} + onScheduleFromParking={({ pending: pa, stage, minute, lane, openModal }) => { + if (openModal) { + setPerfModal({ + mode: "add", + perf: { artist_id: pa.artist_id, stage_id: stage.id, start: minute, end: minute + 60, status: "requested", lane }, + pendingId: pa.id, + }); + } else { + const newPerf = { + id: "p_" + Math.random().toString(36).slice(2, 8), + artist_id: pa.artist_id, stage_id: stage.id, day_id: pa.day_id, + start: minute, end: minute + 60, status: "requested", + ...(Number.isInteger(lane) ? { lane } : {}), + }; + dispatch({ type: "schedule_pending", pendingId: pa.id, perf: newPerf }); + } + }} + pxPerMin={pxPerMin} + baseRowHeight={rowHeight} + density={t.density} + showPercent={t.showPercent} + onSelectQueueItem={handleSelectQueueItem} + selectedQueueId={popover && popover.kind === "queue" ? popover.queueKey : null} + /> +
+ + +
+ + {/* Popover — performance */} + {popover && popover.kind === "perf" && popoverPerf && (() => { + const stage = stages.find(s => s.id === popoverPerf.stage_id); + const artist = artists.find(a => a.id === popoverPerf.artist_id); + return ( + dispatch({ type: "update_perf", perf: { ...popoverPerf, status: k }})} + onOpenDetailPage={(a, p) => alert(`Navigatie naar /events/${D.EVENT.id || "ezf_2026"}/artists/${a.id} (out of scope voor deze PoC)`)} + onClose={() => setPopover(null)} + /> + ); + })()} + + {/* Popover — queue item (read-only summary, status switch, navigate to detail) */} + {popover && popover.kind === "queue" && (() => { + const it = popover.item; + const artist = artists.find(a => a.id === it.artist.id); + return ( + { + if (it.kind === "pending") dispatch({ type: "update_pending_status", id: it.id, status: k }); + else dispatch({ type: "update_parked_status", id: it.id, status: k }); + }} + onOpenDetailPage={(a) => alert(`Navigatie naar /events/${D.EVENT.id || "ezf_2026"}/artists/${a.id} (out of scope voor deze PoC)`)} + onClose={() => setPopover(null)} + /> + ); + })()} + + {/* Drawer is no longer used — detail navigation is handled via Open detailpagina */} + + {/* Modals */} + {perfModal && ( + { + if (perfModal.mode === "add") { + if (newArtist) dispatch({ type: "add_artist", artist: newArtist }); + if (perf.stage_id) { + dispatch({ type: "add_perf", perf }); + } else { + dispatch({ type: "add_parked", parked: { ...perf, dur: perf.dur || 60 } }); + } + } else { + dispatch({ type: "update_perf", perf }); + } + setPerfModal(null); + }} + onDelete={(id) => { dispatch({ type: "delete_perf", id }); setPerfModal(null); }} + onClose={() => setPerfModal(null)} + /> + )} + {stageModal && ( + { + const { __draft, ...clean } = s; + if (stageModal.__draft) { + dispatch({ type: "add_stage", stage: clean, days: dayIds }); + } else { + dispatch({ type: "update_stage", stage: clean }); + dispatch({ type: "set_stage_days_for_stage", stageId: clean.id, dayIds }); + } + setStageModal(null); + }} + onDelete={(id) => { + const affected = performances.filter(p => p.stage_id === id).length; + const stageName = stages.find(s => s.id === id)?.name || "deze stage"; + const msg = affected > 0 + ? `Weet je zeker dat je "${stageName}" wilt verwijderen?\n\n${affected} ingeplande act${affected === 1 ? "" : "s"} ${affected === 1 ? "wordt" : "worden"} naar de wachtrij verplaatst (niet verwijderd).` + : `Weet je zeker dat je "${stageName}" wilt verwijderen?`; + if (!window.confirm(msg)) return; + dispatch({ type: "delete_stage", id }); + setStageModal(null); + }} + onClose={() => setStageModal(null)} + /> + )} + {matrixOpen && ( + { dispatch({ type: "set_stage_days_all", list }); setMatrixOpen(false); }} + onClose={() => setMatrixOpen(false)} + /> + )} + + {/* Tweaks */} + + + setTweak("density", v)} /> + setTweak("zoom", v)} /> + + setTweak("showPercent", v)} /> + +
+ ); +} + +// ─── Sidebar (matching Crewli look) ───────────────────────────────── +function Sidebar() { + const items = [ + { icon: "home", label: "Dashboard" }, + { icon: "cal", label: "Evenementen", active: true }, + ]; + const orgItems = [ + { icon: "build", label: "Mijn Organisatie" }, + { icon: "users", label: "Leden" }, + { icon: "biz", label: "Bedrijven" }, + { icon: "alert", label: "Form failures" }, + { icon: "cog", label: "Instellingen" }, + ]; + return ( + + ); +} + +// minimal inline icons +function Icon({ name }) { + const common = { width: 16, height: 16, viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: 1.4, strokeLinecap: "round", strokeLinejoin: "round" }; + const paths = { + home: , + cal: , + build: , + users: , + biz: , + alert: , + cog: , + plus: , + edit: , + grid: , + bell: , + sun: , + search:, + arrow: , + share: , + chev: , + }; + return {paths[name] || null}; +} + +// ─── Header ───────────────────────────────────────────────────────── +function Header({ dayCounts, conflicts, activeDayId, onChangeDay, onOpenMatrix, onAddPerformance, onAddStage }) { + const D = window.CrewliData; + return ( +
+
+ +
+
+ {D.EVENT.name} + Festival +
+
10-07-2026 – 11-07-2026 · Timetable
+
+
+ + +
+ +
+ + + + + + + +
+ +
+
+ {D.EVENT.days.map(d => ( + + ))} +
+ +
+ {conflicts > 0 && ( + + + {conflicts} {conflicts === 1 ? "conflict" : "conflicten"} + + )} + +
+ +
+ + + + +
+
+ ); +} + +function FooterToolbar({ stagesActive, performances, conflicts }) { + return ( +
+
+ {stagesActive} stages + {performances} performances + {conflicts} conflicten +
+
+
+ ); +} + +// Mount +ReactDOM.createRoot(document.getElementById("root")).render(); diff --git a/resources/Crewli - Artist Timetable Management/data.js b/resources/Crewli - Artist Timetable Management/data.js new file mode 100644 index 00000000..29ff5347 --- /dev/null +++ b/resources/Crewli - Artist Timetable Management/data.js @@ -0,0 +1,159 @@ +// Echt Zomer Feesten 2026 - demo data +// Schema mirrors Crewli §3.5.7: stages, stage_days, artists, performances + +window.CrewliData = (function () { + const EVENT = { + id: "ezf_2026", + name: "Echt Zomer Feesten", + edition: "2026", + sub_event_label: "dag", // configurable in Crewli — could be "dag", "deelevent", "fase", etc. + days: [ + { id: "d_fr", date: "2026-07-10", label: "Vrijdag", short: "Vr" }, + { id: "d_sa", date: "2026-07-11", label: "Zaterdag", short: "Za" }, + ], + }; + + // Stages — physical platforms. color is the stage swatch (used as left-band on row) + const STAGES = [ + { id: "s_hardstyle", name: "Hardstyle District", color: "#e85d75", capacity: 4500 }, + { id: "s_techno", name: "Techno × House", color: "#7a8af0", capacity: 3000 }, + { id: "s_hollandse", name: "Hollandse Hoek", color: "#f0a04b", capacity: 5000 }, + { id: "s_urban", name: "Echt Urban", color: "#5fc9a8", capacity: 2800 }, + { id: "s_silent", name: "Silent Disco", color: "#c89af0", capacity: 800 }, + { id: "s_schirm", name: "Schirmbar", color: "#e8d05f", capacity: 600 }, + ]; + + // stage_days pivot: which stages run on which day + const STAGE_DAYS = [ + // Vrijdag + { stage_id: "s_hardstyle", day_id: "d_fr" }, + { stage_id: "s_techno", day_id: "d_fr" }, + { stage_id: "s_silent", day_id: "d_fr" }, + { stage_id: "s_schirm", day_id: "d_fr" }, + // Zaterdag + { stage_id: "s_hollandse", day_id: "d_sa" }, + { stage_id: "s_urban", day_id: "d_sa" }, + { stage_id: "s_silent", day_id: "d_sa" }, + { stage_id: "s_schirm", day_id: "d_sa" }, + ]; + + // Advance sections (per artist, configurable in real schema; here we use a + // pragmatic standard set so the popover dots have meaning) + const ADVANCE_SECTIONS = [ + { key: "tour", label: "Tourmanager" }, + { key: "hosp", label: "Hospitality" }, + { key: "travel", label: "Travel party" }, + { key: "flight", label: "Flight" }, + { key: "rider", label: "Tech rider" }, + ]; + + // Artists. `draw` = expected pull (for capacity warnings). `advance` = which + // sections are completed (just demo state for the popover). + // Genres (closed list — drives filters in the wachtrij and the small label on blocks) + const GENRES = ["Hardstyle", "Techno", "House", "Hollands", "Pop", "Urban", "Disco", "Aprés"]; + + const ARTISTS = [ + { id: "a_1", name: "D-Block & S-te-Fan", initials: "DS", genre: "Hardstyle", draw: 4200, advance: { tour: true, hosp: true, travel: true, flight: false, rider: true } }, + { id: "a_2", name: "Sub Zero Project", initials: "SZ", genre: "Hardstyle", draw: 3800, advance: { tour: true, hosp: true, travel: false, flight: false, rider: true } }, + { id: "a_3", name: "Warface", initials: "WF", genre: "Hardstyle", draw: 3000, advance: { tour: true, hosp: false, travel: false, flight: false, rider: false } }, + { id: "a_4", name: "Devin Wild", initials: "DW", genre: "Hardstyle", draw: 2400, advance: { tour: true, hosp: true, travel: true, flight: true, rider: true } }, + + { id: "a_5", name: "Reinier Zonneveld", initials: "RZ", genre: "Techno", draw: 2800, advance: { tour: true, hosp: true, travel: true, flight: true, rider: true } }, + { id: "a_6", name: "Boris Brejcha", initials: "BB", genre: "Techno", draw: 3200, advance: { tour: true, hosp: true, travel: false, flight: true, rider: true } }, + { id: "a_7", name: "Mau P", initials: "MP", genre: "House", draw: 2200, advance: { tour: true, hosp: false, travel: false, flight: false, rider: true } }, + + { id: "a_8", name: "Snollebollekes", initials: "SB", genre: "Hollands", draw: 5200, advance: { tour: true, hosp: true, travel: true, flight: false, rider: true } }, + { id: "a_9", name: "Mart Hoogkamer", initials: "MH", genre: "Hollands", draw: 3600, advance: { tour: true, hosp: true, travel: false, flight: false, rider: true } }, + { id: "a_10", name: "Suzan & Freek", initials: "SF", genre: "Pop", draw: 4800, advance: { tour: false, hosp: false, travel: false, flight: false, rider: false } }, + { id: "a_11", name: "Frans Duijts", initials: "FD", genre: "Hollands", draw: 2600, advance: { tour: true, hosp: true, travel: true, flight: false, rider: true } }, + + { id: "a_12", name: "Bizzey", initials: "BZ", genre: "Urban", draw: 2400, advance: { tour: true, hosp: true, travel: true, flight: false, rider: true } }, + { id: "a_13", name: "Frenna", initials: "FN", genre: "Urban", draw: 2800, advance: { tour: true, hosp: true, travel: false, flight: false, rider: false } }, + { id: "a_14", name: "Kris Kross Amsterdam", initials: "KK", genre: "Pop", draw: 2200, advance: { tour: true, hosp: false, travel: false, flight: false, rider: true } }, + + { id: "a_15", name: "DJ Marlon", initials: "DM", genre: "Disco", draw: 600, advance: { tour: true, hosp: true, travel: true, flight: true, rider: true } }, + { id: "a_16", name: "DJ Senna", initials: "DS", genre: "Disco", draw: 500, advance: { tour: true, hosp: true, travel: true, flight: true, rider: true } }, + + { id: "a_17", name: "Café Catootje", initials: "CC", genre: "Aprés", draw: 400, advance: { tour: true, hosp: true, travel: true, flight: true, rider: true } }, + { id: "a_18", name: "Apré Heroes", initials: "AH", genre: "Aprés", draw: 350, advance: { tour: false, hosp: true, travel: false, flight: true, rider: false } }, + ]; + + // Performances — booking_status: concept | requested | option | confirmed | contracted | cancelled + // Times are minutes since the day's anchor (14:00). End times are minute offsets too. + // To keep math simple we use the same minute-grid for both days. + const PERFORMANCES = [ + // ─── Vrijdag · Hardstyle District (s_hardstyle, d_fr) ───────────── + { id: "p_1", artist_id: "a_3", stage_id: "s_hardstyle", day_id: "d_fr", start: 240, end: 360, status: "confirmed" }, // 18:00-20:00 + { id: "p_2", artist_id: "a_4", stage_id: "s_hardstyle", day_id: "d_fr", start: 360, end: 480, status: "confirmed" }, // 20:00-22:00 + { id: "p_3", artist_id: "a_2", stage_id: "s_hardstyle", day_id: "d_fr", start: 480, end: 600, status: "contracted" }, // 22:00-00:00 + // Concept-fase: 3 parallelle aanvragen voor de 00:00-02:00 slot — pas één wordt vastgelegd + { id: "p_4", artist_id: "a_1", stage_id: "s_hardstyle", day_id: "d_fr", start: 600, end: 720, status: "option" }, // 00:00-02:00 + { id: "p_4b", artist_id: "a_2", stage_id: "s_hardstyle", day_id: "d_fr", start: 600, end: 720, status: "requested" }, // 00:00-02:00 (parallel aanvraag) + { id: "p_4c", artist_id: "a_4", stage_id: "s_hardstyle", day_id: "d_fr", start: 615, end: 705, status: "concept" }, // 00:15-01:45 (alternatief concept) + + // ─── Vrijdag · Techno × House (s_techno, d_fr) ──────────────────── + { id: "p_5", artist_id: "a_7", stage_id: "s_techno", day_id: "d_fr", start: 300, end: 420, status: "confirmed" }, // 19:00-21:00 + { id: "p_6", artist_id: "a_5", stage_id: "s_techno", day_id: "d_fr", start: 420, end: 555, status: "contracted" }, // 21:00-23:15 + { id: "p_7", artist_id: "a_6", stage_id: "s_techno", day_id: "d_fr", start: 555, end: 690, status: "requested" }, // 23:15-01:30 + + // ─── Vrijdag · Silent Disco (s_silent, d_fr) ────────────────────── + { id: "p_8", artist_id: "a_15", stage_id: "s_silent", day_id: "d_fr", start: 360, end: 540, status: "confirmed" }, // 20:00-23:00 + { id: "p_9", artist_id: "a_16", stage_id: "s_silent", day_id: "d_fr", start: 540, end: 720, status: "confirmed" }, // 23:00-02:00 + + // ─── Vrijdag · Schirmbar (s_schirm, d_fr) ───────────────────────── + { id: "p_10", artist_id: "a_17", stage_id: "s_schirm", day_id: "d_fr", start: 180, end: 360, status: "confirmed" }, // 17:00-20:00 + { id: "p_11", artist_id: "a_18", stage_id: "s_schirm", day_id: "d_fr", start: 360, end: 540, status: "concept" }, // 20:00-23:00 + + // ─── Zaterdag · Hollandse Hoek (s_hollandse, d_sa) ──────────────── + { id: "p_12", artist_id: "a_11", stage_id: "s_hollandse", day_id: "d_sa", start: 240, end: 345, status: "confirmed" }, // 18:00-19:45 + { id: "p_13", artist_id: "a_9", stage_id: "s_hollandse", day_id: "d_sa", start: 345, end: 465, status: "confirmed" }, // 19:45-21:45 + { id: "p_14", artist_id: "a_10", stage_id: "s_hollandse", day_id: "d_sa", start: 465, end: 600, status: "option" }, // 21:45-00:00 (cap warning - draw 4800 vs 5000) + { id: "p_15", artist_id: "a_8", stage_id: "s_hollandse", day_id: "d_sa", start: 600, end: 720, status: "contracted" }, // 00:00-02:00 (cap warning — draw 5200 > 5000) + + // ─── Zaterdag · Echt Urban (s_urban, d_sa) ──────────────────────── + { id: "p_16", artist_id: "a_14", stage_id: "s_urban", day_id: "d_sa", start: 300, end: 420, status: "confirmed" }, // 19:00-21:00 + { id: "p_17", artist_id: "a_13", stage_id: "s_urban", day_id: "d_sa", start: 420, end: 540, status: "confirmed" }, // 21:00-23:00 + { id: "p_18", artist_id: "a_12", stage_id: "s_urban", day_id: "d_sa", start: 540, end: 660, status: "requested" }, // 23:00-01:00 + + // Demo conflict: two performances overlapping on Echt Urban + { id: "p_19", artist_id: "a_7", stage_id: "s_urban", day_id: "d_sa", start: 510, end: 600, status: "concept" }, // 22:30-00:00 overlap with Frenna + + // ─── Zaterdag · Silent Disco (s_silent, d_sa) ───────────────────── + { id: "p_20", artist_id: "a_16", stage_id: "s_silent", day_id: "d_sa", start: 360, end: 540, status: "confirmed" }, + { id: "p_21", artist_id: "a_15", stage_id: "s_silent", day_id: "d_sa", start: 540, end: 720, status: "confirmed" }, + + // ─── Zaterdag · Schirmbar (s_schirm, d_sa) ──────────────────────── + { id: "p_22", artist_id: "a_18", stage_id: "s_schirm", day_id: "d_sa", start: 180, end: 360, status: "concept" }, + { id: "p_23", artist_id: "a_17", stage_id: "s_schirm", day_id: "d_sa", start: 360, end: 540, status: "confirmed" }, + ]; + + // ─── Parked performances (stage_id === null) ───────────────────── + // Artists picked up but not yet placed on a stage. Day-id keeps day-context. + const PARKED = [ + { id: "pk_1", artist_id: "a_5", stage_id: null, day_id: "d_fr", start: 480, end: 600, status: "option" }, + { id: "pk_2", artist_id: "a_13", stage_id: null, day_id: "d_sa", start: 540, end: 660, status: "requested" }, + { id: "pk_3", artist_id: "a_7", stage_id: null, day_id: "d_fr", start: 420, end: 540, status: "concept" }, + { id: "pk_4", artist_id: "a_9", stage_id: null, day_id: "d_sa", start: 360, end: 450, status: "confirmed" }, + { id: "pk_5", artist_id: "a_11", stage_id: null, day_id: "d_fr", start: 600, end: 690, status: "contracted" }, + ]; + + // ─── Pending availability requests ─────────────────────────────── + // Artists waarvoor beschikbaarheid is opgevraagd maar nog geen tijdvak gekozen. + const PENDING = [ + { id: "pa_1", artist_id: "a_6", day_id: "d_fr", requested_on: "2026-04-12", note: "Wachten op terugkoppeling agent" }, + { id: "pa_2", artist_id: "a_8", day_id: "d_sa", requested_on: "2026-04-08", note: "Beschikbaar — wacht op fee-onderhandeling" }, + { id: "pa_3", artist_id: "a_12", day_id: "d_sa", requested_on: "2026-04-15", note: "Boeking via Top Notch" }, + { id: "pa_4", artist_id: "a_10", day_id: "d_fr", requested_on: "2026-04-18", note: "Optie tot 30 april" }, + ]; + + // Time grid: 14:00 -> 03:00 next day = 13 hours = 780 minutes + // start=0 means 14:00, start=600 means 00:00, start=780 means 03:00 + const TIME = { + startHour: 14, // grid starts at 14:00 + totalMinutes: 780, // 13 hours + snapMinutes: 15, // drag snap + cellMinutes: 30, // grid cell width + }; + + return { EVENT, STAGES, STAGE_DAYS, ADVANCE_SECTIONS, ARTISTS, GENRES, PERFORMANCES, PARKED, PENDING, TIME }; +})(); diff --git a/resources/Crewli - Artist Timetable Management/docs/RFC-TIMETABLE - Artist Timetable Module.md b/resources/Crewli - Artist Timetable Management/docs/RFC-TIMETABLE - Artist Timetable Module.md new file mode 100644 index 00000000..2ed8182f --- /dev/null +++ b/resources/Crewli - Artist Timetable Management/docs/RFC-TIMETABLE - Artist Timetable Module.md @@ -0,0 +1,530 @@ +# RFC-TIMETABLE — Artist Timetable Module + +## 1. Status + +- **State:** Draft for review +- **Created:** 2026-05-08 +- **Version:** v0.1 +- **Owner:** Bert Hausmans +- **Origin:** UX brainstorm session 2026-05-08 (Claude Chat) — concept + three PoC iterations + six locked decisions +- **Related:** + - `SCHEMA.md` §3.5.7 (artists, performances, stages, stage_days, advance_sections) + - `BACKLOG.md` ARCH-09 (Artist model — hard prerequisite), ART-02 (Timetable backlog item) + - `ARCH-FORM-BUILDER.md` §17 (artist_advance purpose subject_type) + - `CLAUDE.md` "Order of work" (17-step module-generation sequence) + - `design-document.md` §3.5.10 Database design rules + - `dev-docs/PurposeRegistry` — artist_advance purpose with subject_type=artist + +## 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, and a bug here visibly breaks the show. Three +properties make this module non-trivial enough to warrant up-front spec: + +1. It depends on a model (Artist) that does not exist yet — see ARCH-09. +2. It bridges three concerns that festival operators conflate but Crewli must + keep distinct: stages (physical), festival_sections (organisational), and + advancing (per-section workflow). +3. The interaction surface (drag-drop Gantt with conflict detection, + cross-day stage variance, popover with advancing summary) does not exist + off-the-shelf in the current frontend stack — the build-vs-buy choice for + the rendering library has multi-week consequences. + +This RFC captures every architectural decision so implementation does not +re-litigate them under time pressure. PoC iterations (chat artefacts v1/v2/v3) +are visual references, not authoritative — this document is. + +## 3. Scope & non-scope + +### In scope (v1) + +- CRUD for stages and stage_days (which stage runs which day) +- CRUD for performances (artist on stage at time) +- Horizontal Gantt timetable: stages as rows, time as x-axis, day tabs +- Drag/drop performance: re-time and re-stage in one gesture +- Resize performance duration (snap to 15 min) +- Click-to-add performance from empty grid cell +- Detail popover with avatar, status pill, advancing aggregate, status switch, + delete, manage-booking link +- Conflict detection: same-stage same-day overlapping non-cancelled performances +- Back-to-back marker: ≤5 min gap between consecutive performances on same stage +- "Edit lineup" matrix: bulk toggle stages × days +- Empty-day state: copy-from-other-day affordance +- Activity log on all mutations +- Multi-tenancy via OrganisationScope (FK-chain through event) + +### Out of scope (v1) + +- **Artist Handling / show-day check-in view** — separate Mission Control module +- **Capacity warning** on performance blocks — requires a new column + (`performances.expected_attendance` or similar). Defer to v2; backlog as + ART-04. +- **Advance section CRUD** — covered by separate Artist Advancing module + (BACKLOG ART-01). The popover only *reads* advance_sections aggregate. +- **PDF / print export** of running order — backlog as ART-05. +- **Multi-select bulk shift** of performances ("move everything on Mainstage + +15 min") — backlog as ART-06. +- **Undo/redo stack** — backlog as ART-07. +- **Mobile-optimised view** — timetable is a desktop tool. Mobile gets a + read-only list view in a later iteration. +- **Stage templates across events** ("copy stages from last year's event") + — backlog as ARCH-10, parallel to ARCH-03 (festival_section templates). + +## 4. Locked design decisions + +### D1 — Block visual: stage-stripe + status-fill + +Each performance block carries: + +- Background fill = booking_status colour (6 colours, see §10). +- 3px left stripe = `stages.color` for stage-grouping cue. +- Right grab handle for resize. + +Rationale: status-fill alone (PoC v2) lost stage grouping when many stages +shared similar status. The stripe restores the visual stage-cluster signal at +zero cost. (Bert decision 2026-05-08.) + +### D2 — Advancing aggregate, not per-section dots + +The detail popover shows `n/m completed` (e.g. "3/5 advancing complete") and +the underlying section list as a tooltip on hover. No per-section dots on the +block itself. + +Rationale: `advance_sections` is configurable per artist — fixed 5-dot icons +break the moment one artist has 4 sections and another has 7. Aggregate +fraction degrades gracefully across configurations and reads cleanly at small +sizes. (Bert decision 2026-05-08.) + +The aggregate is computed as: + +``` +n = count(advance_sections WHERE artist_id = X AND submission_status = 'accepted') +m = count(advance_sections WHERE artist_id = X) +``` + +Implementation: cached on `artists` model as a denormalised +`advancing_completed_count` + `advancing_total_count` pair, recomputed via +observer on `advance_section` create/update/delete. Avoids N+1 on timetable +load. To be added in same migration as Artist model (ARCH-09). + +### D3 — Manage booking opens artist page (not modal) + +Click "Manage booking" in popover → navigate to +`/events/{event}/artists/{artist}?return=timetable&day=fri&t=210000`. The +artist page surfaces all related records (performances, advance_sections, +contacts, riders, itinerary) as tabs. A prominent return banner at top: +"← Back to Timetable (Vr 21:00)" restores scroll/tab context. + +Rationale: artist record is too large for modal (5+ relation tables). Page +navigation matches every other Crewli edit surface (shifts, persons, +festival_sections). Bookmarkability + cmd-click-to-new-tab are bonuses +no modal can provide. (Bert decision 2026-05-08.) + +### D4 — Stage-day filtering is enforced everywhere + +The timetable for day D shows only stages where `stage_days(stage_id, D)` +exists. Performances scheduled on `(stage_id, D)` where `stage_days(...)` does +not exist are **hidden but not deleted** — toggling a stage off a day must be +reversible without data loss. + +This is enforced at: + +- API list endpoint (filter at SQL level) +- Frontend rendering (defensive — trust API but verify) +- Performance creation — the FormRequest validates that `stage_days` + exists for the requested `(stage_id, performance.date)` and rejects + with 422 otherwise. + +### D5 — Conflict detection scope: same-stage same-day overlap only + +Cross-stage overlap (same artist on two stages simultaneously) is **not** +flagged in v1. Reason: an artist on two stages at once is rare but legitimate +at small festivals where one DJ runs main set + silent disco simultaneously. +Surface this on the artist page in a future iteration, not on the timetable. + +Cancelled performances participate in **no** conflict check. + +### D6 — B2B marker rule + +Two consecutive non-cancelled performances on the same stage with +`p2.start - p1.end ∈ [0, 5]` minutes get a B2B marker (small dot at the +boundary). Useful for stage managers planning changeover windows. + +Pure rendering concern — no schema impact. + +### D7 — Drag-drop interaction model + +- Drag block horizontally → re-time (snap to 15 min) +- Drag block vertically → re-stage (only across stages active on current day) +- Drag right edge → resize duration (snap to 15 min, min 15 min) +- Drag is committed on mouseup with single PATCH request +- Failed PATCH (validation error, conflict the user wants to refuse) reverts + block to its origin position with a toast + +### D8 — Rendering library: custom Vue components, not FullCalendar + +**Recommendation: build custom.** + +FullCalendar timeline view (`resourceTimelinePlugin`) was the obvious choice +on paper but has three blockers: + +1. Premium-licensed (~€600/yr commercial). Acceptable cost but adds + procurement friction and a vendor dependency. +2. The advancing-aggregate badge, stage-stripe, B2B marker, and conflict + ring all require `eventDidMount` render hooks — at that point we're + reimplementing rendering inside FC's render cycle, fighting its DOM. +3. Cross-day stage filtering (D4) does not map cleanly to FC's resource + model. Workaround is per-day resource list rebuild on tab switch, which + defeats incremental-render benefits. + +Custom Vue components (TimetableGrid, StageRow, PerformanceBlock, +PerformancePopover, LineupMatrix) on top of native HTML5 drag-and-drop give +total control at ~3 days additional frontend cost. The PoC v3 is already +~80% of the rendering logic. PoC code must be rewritten as proper Vue 3 +components (Composition API, `