1655 lines
70 KiB
Markdown
1655 lines
70 KiB
Markdown
# 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.
|