Add design en information for developing the Artist Management module
This commit is contained in:
@@ -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, `<script setup lang="ts">`) — the PoC is not
|
||||
production code.
|
||||
|
||||
**Open verification before commit:** test drag-and-drop accessibility with
|
||||
screen reader. If insurmountable, revisit FullCalendar Premium decision.
|
||||
|
||||
### D9 — `performances.booking_status` migrates from string to PHP Enum
|
||||
|
||||
SCHEMA §3.5.7 currently lists `booking_status` on performances as `string`.
|
||||
This violates the zero-compromise rule "PHP Enums mandatory for all
|
||||
type/operator/status fields". Must be promoted to a backed PHP enum during
|
||||
implementation:
|
||||
|
||||
```php
|
||||
namespace App\Enums\Artist;
|
||||
|
||||
enum PerformanceBookingStatus: string {
|
||||
case Concept = 'concept';
|
||||
case Requested = 'requested';
|
||||
case Option = 'option';
|
||||
case Confirmed = 'confirmed';
|
||||
case Contracted = 'contracted';
|
||||
case Cancelled = 'cancelled';
|
||||
}
|
||||
```
|
||||
|
||||
Same enum is reused on `artists.booking_status` (currently inline string-enum
|
||||
in migration). Rename of existing column type to use the enum cast in the
|
||||
Eloquent model. No column-level migration needed — both are already string
|
||||
columns. SCHEMA.md needs update to reflect this in the implementation prompt.
|
||||
|
||||
## 5. Schema impact
|
||||
|
||||
### No new tables
|
||||
|
||||
All four needed tables exist in §3.5.7: `artists`, `performances`, `stages`,
|
||||
`stage_days`.
|
||||
|
||||
### Two column additions on `artists` (ARCH-09 migration)
|
||||
|
||||
```
|
||||
advancing_completed_count unsigned int default 0
|
||||
advancing_total_count unsigned int default 0
|
||||
```
|
||||
|
||||
Denormalised aggregate for D2 popover. Recomputed via
|
||||
`AdvanceSectionObserver`. Both columns indexed only as part of the artist
|
||||
PK (no separate index needed — read pattern is by artist).
|
||||
|
||||
### One enum upgrade
|
||||
|
||||
`performances.booking_status` cast to `PerformanceBookingStatus` enum at
|
||||
Eloquent layer. SCHEMA.md updated to reflect enum constraint in §3.5.7.
|
||||
|
||||
### Soft-delete strategy
|
||||
|
||||
- `artists`: soft-delete YES (already in schema)
|
||||
- `performances`: soft-delete YES (cascade with artist via observer; restore
|
||||
cascades back)
|
||||
- `stages`: soft-delete NO. Reason: deleting a stage is rare and destructive;
|
||||
if you mistakenly delete you can recreate. Soft-delete on stages adds
|
||||
query complexity (joining performances→stages with `withTrashed`) without
|
||||
meaningful safety win.
|
||||
- `stage_days`: pure pivot, no soft-delete, hard delete on day-toggle-off.
|
||||
|
||||
This is a deviation from the §3.5.10 default ("artists soft-delete yes,
|
||||
stages not listed") — explicitly noted here.
|
||||
|
||||
## 6. Routes & API
|
||||
|
||||
All routes scoped under `events.{event}` with `OrganisationScope` enforced
|
||||
via FK-chain (stage.event.organisation_id, performance.stage.event.organisation_id).
|
||||
|
||||
```
|
||||
GET /api/v1/events/{event}/stages
|
||||
POST /api/v1/events/{event}/stages
|
||||
GET /api/v1/events/{event}/stages/{stage}
|
||||
PATCH /api/v1/events/{event}/stages/{stage}
|
||||
DELETE /api/v1/events/{event}/stages/{stage}
|
||||
|
||||
PUT /api/v1/events/{event}/stages/{stage}/days
|
||||
Body: { day_dates: ["2026-07-10", "2026-07-11"] }
|
||||
Replaces stage_days for stage atomically. Returns 200 with
|
||||
new stage_days collection.
|
||||
|
||||
GET /api/v1/events/{event}/performances?day=2026-07-10
|
||||
POST /api/v1/events/{event}/performances
|
||||
GET /api/v1/events/{event}/performances/{performance}
|
||||
PATCH /api/v1/events/{event}/performances/{performance}
|
||||
DELETE /api/v1/events/{event}/performances/{performance}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- The `PUT /stages/{stage}/days` endpoint is the matrix-editor backend.
|
||||
Single-day toggle from the stage editor uses the same endpoint with the
|
||||
full new array. This is REST-correct (replace resource collection) and
|
||||
avoids race conditions inherent in N individual POST/DELETE calls.
|
||||
- `GET /performances?day=YYYY-MM-DD` filters by `performances.date`. Without
|
||||
the parameter returns all performances for the event (used for cross-day
|
||||
artist views).
|
||||
- Resource includes: `artist:id,name,booking_status,advancing_completed_count,advancing_total_count`,
|
||||
`stage:id,name,color`. Always eager-loaded — no N+1.
|
||||
- Idempotency-Key header required on POST `/performances` and POST
|
||||
`/stages` per ARCH §10. 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
|
||||
StageHeaderCell.vue ← swatch + name + capacity + day chips
|
||||
PerformanceBlock.vue ← single block; emits drag/resize/click events
|
||||
PerformancePopover.vue ← floating detail; teleported to body
|
||||
LineupMatrix.vue ← bulk stages × days editor (modal)
|
||||
StageEditor.vue ← single stage CRUD (modal)
|
||||
AddPerformanceDialog.vue ← click-to-add modal
|
||||
EmptyDayState.vue ← "no stages on this day, copy from..."
|
||||
|
||||
apps/app/src/composables/timetable/
|
||||
useTimetable.ts ← TanStack queries: stages, performances
|
||||
useTimetableMutations.ts ← performance CRUD mutations + optimistic updates
|
||||
useStageDays.ts ← stage-day matrix mutation
|
||||
useDragDrop.ts ← pointer event handling, snap math
|
||||
useTimetableConflicts.ts ← computed conflict + b2b state per day
|
||||
|
||||
apps/app/src/types/timetable.ts
|
||||
Stage, StageDay, Performance, PerformanceBookingStatus (zod schemas)
|
||||
|
||||
apps/app/src/api/timetable.ts
|
||||
Axios calls; types inferred from zod schemas
|
||||
```
|
||||
|
||||
State pattern:
|
||||
|
||||
- TanStack Query holds canonical server state.
|
||||
- Pinia store (`useTimetableStore`) holds *only* UI state (selectedDay,
|
||||
selectedPerformanceId, popoverPosition, dragState). Never duplicate
|
||||
server data into Pinia.
|
||||
- Optimistic updates on drag/drop and resize via `onMutate` rollback pattern.
|
||||
|
||||
Form validation: VeeValidate + Zod on AddPerformanceDialog and StageEditor.
|
||||
Schemas mirrored from `apps/app/src/types/timetable.ts`.
|
||||
|
||||
## 8. Activity log
|
||||
|
||||
Spatie ActivityLog with `LogsActivity` trait on Stage, Performance, Artist
|
||||
(latter already covered by ARCH-09).
|
||||
|
||||
Logged events:
|
||||
|
||||
- `stage.created`, `stage.updated`, `stage.deleted`
|
||||
- `stage.day_added`, `stage.day_removed` (custom events on PUT /days
|
||||
diffing the before/after sets)
|
||||
- `performance.created`, `performance.updated`, `performance.deleted`
|
||||
- `performance.moved` — special event when stage_id or date changes (high
|
||||
signal for production managers; surfaces in event audit log)
|
||||
|
||||
Stage and performance updates capture before/after on: name, color, capacity
|
||||
(stages); start_time, end_time, date, stage_id, booking_status (performances).
|
||||
|
||||
Activity log entries scoped to organisation + event for audit log filtering.
|
||||
|
||||
## 9. Authorization (Policy)
|
||||
|
||||
```
|
||||
StagePolicy:
|
||||
viewAny(User, Event) — user must be member of event.organisation
|
||||
view(User, Stage) — same + stage.event_id check
|
||||
create(User, Event) — user must have permission 'events.manage_program'
|
||||
update(User, Stage) — same as create + organisation match
|
||||
delete(User, Stage) — same as update + no related performances or
|
||||
performances are also deletable
|
||||
|
||||
PerformancePolicy:
|
||||
viewAny(User, Event) — same as Stage
|
||||
view(User, Performance) — via stage.event.organisation
|
||||
create(User, Event) — 'events.manage_program' permission
|
||||
update(User, Performance) — same as create
|
||||
delete(User, Performance) — same as create
|
||||
```
|
||||
|
||||
`events.manage_program` is a new Spatie permission to add. Roles that get it:
|
||||
`event_admin`, `program_manager` (new role — added in same sprint).
|
||||
Volunteers, crew, suppliers do not have it.
|
||||
|
||||
## 10. Validation rules
|
||||
|
||||
### CreatePerformanceRequest / UpdatePerformanceRequest
|
||||
|
||||
```
|
||||
artist_id required ulid exists:artists,id (scoped to event via stage)
|
||||
stage_id required ulid exists:stages,id (scoped to event)
|
||||
date required date
|
||||
between: event.start_date and event.end_date inclusive
|
||||
must exist in stage_days(stage_id=value of stage_id,
|
||||
day_date=value)
|
||||
→ custom rule StageActiveOnDay
|
||||
start_time required time format H:i
|
||||
end_time required time format H:i
|
||||
after start_time within same date
|
||||
(cross-midnight handled via end_date if needed; v1 forbids
|
||||
cross-midnight performances — flag as a known limitation)
|
||||
booking_status required PerformanceBookingStatus enum value
|
||||
check_in_status optional CheckInStatus enum, default 'expected'
|
||||
```
|
||||
|
||||
Overlap is **not** a hard validation error — it produces a 200 response with
|
||||
a `warnings: ["overlap"]` field in the resource. The frontend renders the
|
||||
conflict ring; the production manager decides whether to resolve.
|
||||
|
||||
### CreateStageRequest
|
||||
|
||||
```
|
||||
name required string max:120 unique:stages,name,NULL,id,event_id,{event_id}
|
||||
color required string regex:/^#[0-9A-Fa-f]{6}$/
|
||||
capacity nullable integer min:1 max:1000000
|
||||
active_days required array min:1
|
||||
active_days.* date between event.start_date and event.end_date
|
||||
```
|
||||
|
||||
### Stage-day matrix replacement (PUT /stages/{stage}/days)
|
||||
|
||||
```
|
||||
day_dates required array min:1 (a stage must always have ≥1 active day)
|
||||
day_dates.* date between event.start_date and event.end_date
|
||||
```
|
||||
|
||||
If the request would remove a day with scheduled non-cancelled performances,
|
||||
return 409 Conflict with `{performances_on_removed_days: [...]}`. The frontend
|
||||
shows the matrix-editor confirmation dialog and resends with
|
||||
`?force_orphan=true` to commit. The orphaned performances persist as soft
|
||||
references (still in DB, hidden from views) — recovered when the day is
|
||||
re-added.
|
||||
|
||||
## 11. Open questions / future work
|
||||
|
||||
- **Stage capacity-vs-draw warning** — requires `expected_attendance` column
|
||||
on performances OR `expected_draw_default` on artists. Add as ART-04 in
|
||||
backlog. Out of v1.
|
||||
- **Stage templates** — copy stage configuration across events. Backlog as
|
||||
ARCH-10. Out of v1.
|
||||
- **Artist double-booking detection across stages** — defer to artist page,
|
||||
not timetable. Backlog as ART-08.
|
||||
- **Cross-midnight performances** — currently forbidden by validation
|
||||
(end_time after start_time within same date). For show-day operations
|
||||
this is restrictive (a 23:30 → 00:30 set spans midnight). Pragmatic v1
|
||||
workaround: store as 23:30 → 24:30 with `end_time > 23:59:59` allowed
|
||||
via custom validation. Or add `end_date` column and treat properly.
|
||||
**Decision required before implementation.**
|
||||
|
||||
## 12. Implementation order
|
||||
|
||||
Three Claude Code sessions, sequential:
|
||||
|
||||
### Session 1 — ARCH-09 + Backend models
|
||||
|
||||
1. Artist model + migration + factory + seeder (ARCH-09)
|
||||
2. Update `PURPOSE_SUBJECT_FQCN` constant: string-literal → `Artist::class`
|
||||
3. Stage + StageDay + Performance models + migrations
|
||||
4. PHP Enums: PerformanceBookingStatus, CheckInStatus (artists)
|
||||
5. AdvanceSectionObserver to recompute advancing aggregate
|
||||
6. PerformanceObserver for cascade soft-delete with artist
|
||||
7. OrganisationScope registration on all three models (FK-chain)
|
||||
8. SCHEMA.md update (enum upgrade, advancing_*_count columns)
|
||||
|
||||
### Session 2 — Backend API + business logic
|
||||
|
||||
1. StagePolicy, PerformancePolicy
|
||||
2. New permission `events.manage_program`, new role `program_manager`
|
||||
3. StageService, PerformanceService (StageDayService for matrix replace)
|
||||
4. FormRequests (CreateStageRequest, UpdateStageRequest, etc.)
|
||||
5. Custom rules: StageActiveOnDay
|
||||
6. API Resources (StageResource, PerformanceResource with includes)
|
||||
7. Controllers (5 routes per module)
|
||||
8. Routes registered in api.php
|
||||
9. Activity log integration on all mutations
|
||||
10. Tests: feature tests for all endpoints, policy tests, validation tests,
|
||||
overlap-warning tests, day-removal-with-orphans flow
|
||||
11. API.md update
|
||||
|
||||
### Session 3 — Frontend
|
||||
|
||||
1. Types + zod schemas
|
||||
2. API client
|
||||
3. TanStack composables
|
||||
4. Pinia store (UI state only)
|
||||
5. Page entry + routing
|
||||
6. TimetableGrid + StageRow + StageHeaderCell
|
||||
7. PerformanceBlock with drag/resize composable
|
||||
8. PerformancePopover with advancing aggregate fetch
|
||||
9. AddPerformanceDialog
|
||||
10. StageEditor + LineupMatrix modals
|
||||
11. EmptyDayState
|
||||
12. Vitest component tests on critical math (snap, overlap, conflict)
|
||||
13. End-to-end flow test with Playwright (or whatever Crewli uses): drag
|
||||
performance, switch days, edit lineup matrix
|
||||
|
||||
Estimate: 2.5 + 2 + 3 = **7.5 days across three sessions**.
|
||||
|
||||
## 13. Test strategy
|
||||
|
||||
### Backend unit + feature
|
||||
|
||||
- StageActiveOnDay rule
|
||||
- PerformanceService overlap detection (warns, does not block)
|
||||
- StageDayService atomic replacement with orphan-performance handling
|
||||
- AdvanceSectionObserver count recomputation
|
||||
- PerformanceObserver cascade soft-delete with artist
|
||||
- All policy methods (positive + negative)
|
||||
- All endpoints (200, 201, 204, 401, 403, 404, 409, 422)
|
||||
|
||||
### Frontend
|
||||
|
||||
- Pure: snap math, conflict detection, B2B detection, day-filter
|
||||
- Component: PerformanceBlock renders correctly per booking_status
|
||||
- Component: drag invokes mutation with optimistic update + rollback on error
|
||||
- Integration: full add → drag → edit → delete flow with mocked API
|
||||
- Accessibility: keyboard navigation through stages, screen-reader on
|
||||
performance blocks (focus order, aria-labels)
|
||||
|
||||
## 14. Dependencies
|
||||
|
||||
### Hard
|
||||
|
||||
- ARCH-09 (Artist model) — must land in Session 1 before stages/performances
|
||||
can be created. Currently a string-literal `'App\\Models\\Artist'` in
|
||||
`PURPOSE_SUBJECT_FQCN`; first artist submission would fault. Resolves at
|
||||
start of Session 1.
|
||||
- New permission `events.manage_program` and role `program_manager` —
|
||||
Session 2 prerequisite.
|
||||
|
||||
### Soft
|
||||
|
||||
- ART-01 (Artist Advancing portal) — provides the advance_sections data
|
||||
that powers the popover aggregate. Without ART-01 the aggregate is always
|
||||
`0/0`. Acceptable for v1: the aggregate displays with grey "—" when
|
||||
total is 0.
|
||||
- ARCH-10 (stage templates) — would speed up setup of recurring events but
|
||||
is not blocking.
|
||||
|
||||
### None
|
||||
|
||||
- Form Builder, Accreditation Engine, Briefings — fully independent.
|
||||
@@ -0,0 +1,470 @@
|
||||
# Timetable Module — Implementatie-spec
|
||||
|
||||
> **Doel**: een interactieve timetable-module in de Crewli-app waarin programmers acts kunnen plannen op stages, slepen tussen stages/wachtrij, conflicten en advancing-status zien, en stages beheren.
|
||||
>
|
||||
> **Stack**: Vue 3 (Composition API) + Pinia + TypeScript op de Vuexy/Vuetify-template, Laravel 11 als API-backend, MySQL/Postgres, Reverb voor real-time.
|
||||
>
|
||||
> **Referentie-PoC**: zie `_poc/Crewli Timetable.html` (interactieve mockup met alle gewenste UX-gedrag — gebruik dit als executable spec).
|
||||
|
||||
---
|
||||
|
||||
## 1. Domain model
|
||||
|
||||
### 1.1 Tabellen
|
||||
|
||||
```text
|
||||
events
|
||||
id ulid PK
|
||||
name string # "Echt Zomer Feesten 2026"
|
||||
slug string unique
|
||||
start_hour tinyint # grid start, default 14 (14:00)
|
||||
total_minutes smallint # grid lengte, default 720 (12u)
|
||||
stages_label string # configureerbare term, default "Stages"
|
||||
created_at, updated_at, deleted_at
|
||||
|
||||
event_days
|
||||
id ulid PK
|
||||
event_id ulid FK -> events
|
||||
date date
|
||||
label string # "Vrijdag 12 juli"
|
||||
sort smallint
|
||||
unique(event_id, date)
|
||||
|
||||
stages
|
||||
id ulid PK
|
||||
event_id ulid FK -> events
|
||||
name string
|
||||
color string(7) # hex
|
||||
capacity int # voor draw-warnings
|
||||
sort smallint # globale fallback-volgorde
|
||||
created_at, updated_at, deleted_at # soft delete
|
||||
|
||||
stage_days # welke stages zijn op welke dag actief
|
||||
id ulid PK
|
||||
stage_id ulid FK -> stages
|
||||
day_id ulid FK -> event_days
|
||||
sort smallint # volgorde van stages binnen die dag
|
||||
unique(stage_id, day_id)
|
||||
|
||||
artists
|
||||
id ulid PK
|
||||
event_id ulid FK -> events
|
||||
name string
|
||||
initials string(4)
|
||||
genre string # enum-string, freeform mag
|
||||
draw int # verwachte trekkracht
|
||||
created_at, updated_at, deleted_at
|
||||
|
||||
advance_sections # configureerbaar per event
|
||||
id ulid PK
|
||||
event_id ulid FK -> events
|
||||
key string # 'tour' | 'hosp' | 'travel' | ...
|
||||
label string
|
||||
sort smallint
|
||||
unique(event_id, key)
|
||||
|
||||
artist_advance # pivot: welke secties zijn done
|
||||
artist_id ulid FK
|
||||
section_id ulid FK
|
||||
done_at timestamp nullable
|
||||
primary key (artist_id, section_id)
|
||||
|
||||
performances # GEPLAND op een stage
|
||||
id ulid PK
|
||||
event_id ulid FK
|
||||
day_id ulid FK -> event_days
|
||||
stage_id ulid FK -> stages
|
||||
artist_id ulid FK -> artists
|
||||
start_minute smallint # offset vanaf event.start_hour*60
|
||||
end_minute smallint
|
||||
lane tinyint # sub-swimlane within stage row
|
||||
status enum('concept','requested','option','confirmed','cancelled')
|
||||
notes text nullable
|
||||
version int default 0 # optimistic locking
|
||||
created_at, updated_at, deleted_at
|
||||
index(day_id, stage_id, start_minute)
|
||||
|
||||
parked_performances # WACHTRIJ — niet gepland
|
||||
id ulid PK
|
||||
event_id ulid FK
|
||||
day_id ulid FK
|
||||
artist_id ulid FK
|
||||
duration_minutes smallint # voorgenomen duur
|
||||
status enum(...) # zelfde set als performances
|
||||
notes text nullable
|
||||
origin enum('manual','unscheduled','stage_deleted')
|
||||
created_at, updated_at
|
||||
|
||||
pending_performances # nog niet bevestigde aanvragen
|
||||
id ulid PK
|
||||
event_id ulid FK
|
||||
day_id ulid FK
|
||||
artist_id ulid FK
|
||||
duration_minutes smallint
|
||||
notes text nullable
|
||||
created_at, updated_at
|
||||
|
||||
audit_log # wie deed wat wanneer
|
||||
id ulid PK
|
||||
user_id ulid FK -> users
|
||||
event_id ulid FK
|
||||
subject_type string # 'performance' | 'stage' | ...
|
||||
subject_id ulid
|
||||
action string # 'move' | 'create' | 'park' | ...
|
||||
before json nullable
|
||||
after json nullable
|
||||
created_at
|
||||
index(event_id, subject_type, subject_id)
|
||||
```
|
||||
|
||||
### 1.2 Invarianten
|
||||
|
||||
1. Een artiest mag op één moment maar één performance hebben (cross-stage conflict). Backend valideert; frontend toont waarschuwing maar blokkeert niet.
|
||||
2. Op één stage mogen meerdere performances *op verschillende lanes* tegelijk lopen. Op dezelfde lane mag geen tijds-overlap.
|
||||
3. `start_minute < end_minute`, beide binnen `[0, event.total_minutes]`.
|
||||
4. `stage_id` moet een actieve `stage_day` hebben voor `day_id`.
|
||||
5. Soft-delete van een stage cascadet alle bijbehorende `performances` naar `parked_performances` met `origin='stage_deleted'`.
|
||||
|
||||
### 1.3 Status-enum
|
||||
|
||||
```
|
||||
concept # idee, niet aangevraagd
|
||||
requested # aangevraagd bij agent/artiest
|
||||
option # geboekt onder optie
|
||||
confirmed # bevestigd
|
||||
cancelled # geannuleerd; blijft zichtbaar met diagonaal patroon
|
||||
```
|
||||
|
||||
Status-kleuren staan vast in de frontend theme (zie §6).
|
||||
|
||||
---
|
||||
|
||||
## 2. API-contract
|
||||
|
||||
Alle endpoints onder `/api/v1`, JSON, Sanctum auth. Resources gebruiken `EventResource`, `PerformanceResource`, etc.
|
||||
|
||||
### 2.1 Read
|
||||
|
||||
| Method | Path | Doel |
|
||||
|---|---|---|
|
||||
| `GET` | `/events/{event}` | Event-meta + days + stages + advance_sections |
|
||||
| `GET` | `/events/{event}/timetable?day={dayId}` | Performances + parked + pending voor één dag, in één response |
|
||||
| `GET` | `/events/{event}/artists` | Volledige artiestenlijst (voor add-modal autocomplete) |
|
||||
|
||||
**Response shape `/timetable`**:
|
||||
```json
|
||||
{
|
||||
"day": { "id": "...", "label": "Vrijdag 12 juli" },
|
||||
"stages": [{ "id": "...", "name": "...", "color": "...", "capacity": 2400, "sort": 0 }],
|
||||
"performances": [{ "id": "...", "stage_id": "...", "artist_id": "...", "start": 480, "end": 540, "lane": 0, "status": "confirmed", "version": 3 }],
|
||||
"parked": [...],
|
||||
"pending": [...]
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Write — performances
|
||||
|
||||
| Method | Path | Body | Effect |
|
||||
|---|---|---|---|
|
||||
| `POST` | `/performances` | `{ day_id, stage_id?, artist_id, start?, end?, status, lane? }` | Nieuw — als `stage_id` ontbreekt → wachtrij |
|
||||
| `PATCH` | `/performances/{id}` | `{ start?, end?, stage_id?, lane?, status?, version }` | Move/resize/restage. Bij `version` mismatch → 409 |
|
||||
| `DELETE` | `/performances/{id}` | — | Verwijder volledig |
|
||||
| `POST` | `/performances/{id}/park` | — | Verplaatst naar `parked_performances` |
|
||||
| `POST` | `/parked/{id}/schedule` | `{ stage_id, start, end, lane? }` | Wachtrij → timetable, met cascade-bump als lane bezet |
|
||||
| `POST` | `/pending/{id}/schedule` | `{ stage_id, start, end, lane? }` | Pending → timetable |
|
||||
| `DELETE` | `/parked/{id}` of `/pending/{id}` | — | Verwijder uit wachtrij |
|
||||
|
||||
**Cascade-logica** (lane-conflict op drop): backend ontvangt expliciete `lane`. Als die lane qua tijd bezet is op die stage, *bumpt het bestaande block* één lane omlaag, recursief. Backend retourneert *alle* gewijzigde performances in een `cascade[]` array zodat de frontend in één state-update kan patchen.
|
||||
|
||||
```json
|
||||
// PATCH response
|
||||
{
|
||||
"performance": { ...updated... },
|
||||
"cascade": [{ ...other... }, ...]
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 Write — stages
|
||||
|
||||
| Method | Path | Body | Effect |
|
||||
|---|---|---|---|
|
||||
| `POST` | `/stages` | `{ event_id, name, color, capacity, day_ids[] }` | Nieuwe stage |
|
||||
| `PATCH` | `/stages/{id}` | `{ name?, color?, capacity?, day_ids? }` | Update + sync `stage_days` |
|
||||
| `DELETE` | `/stages/{id}` | — | Soft delete + cascade performances → parked |
|
||||
| `POST` | `/stages/reorder` | `{ day_id, stage_ids[] }` | Per-dag volgorde |
|
||||
|
||||
### 2.4 Errors
|
||||
|
||||
```
|
||||
400 Bad Request # validatie (start >= end, etc.)
|
||||
403 Forbidden # geen permissie
|
||||
404 Not Found
|
||||
409 Conflict # version mismatch (optimistic lock)
|
||||
422 Unprocessable # business rule (artiest dubbel geboekt)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Frontend architectuur
|
||||
|
||||
### 3.1 Bestandsstructuur
|
||||
|
||||
```
|
||||
resources/js/timetable/
|
||||
├─ index.ts # public exports + route
|
||||
├─ types.ts # Performance, Stage, Artist, Drag, ...
|
||||
├─ lib/
|
||||
│ ├─ lanes.ts # assignLanes() + sub-swimlane logic
|
||||
│ ├─ conflicts.ts # findConflicts, findB2B
|
||||
│ ├─ time.ts # fmtTime, snap
|
||||
│ ├─ advance.ts # advanceCount
|
||||
│ └─ __tests__/
|
||||
├─ stores/
|
||||
│ └─ timetable.ts # Pinia store
|
||||
├─ composables/
|
||||
│ ├─ useTimetableDrag.ts
|
||||
│ ├─ useTimetableScroll.ts
|
||||
│ └─ useRealtime.ts
|
||||
├─ components/
|
||||
│ ├─ Timetable.vue # canvas + axis + rows
|
||||
│ ├─ TimetableHeader.vue # day tabs, conflict counter, +Performance
|
||||
│ ├─ TimetableToolbar.vue # density, snap, percent toggle, +Performance
|
||||
│ ├─ Block.vue # één performance-block
|
||||
│ ├─ StageRow.vue
|
||||
│ ├─ Wachtrij.vue
|
||||
│ ├─ Popover.vue
|
||||
│ ├─ Drawer.vue
|
||||
│ ├─ modals/
|
||||
│ │ ├─ PerformanceModal.vue
|
||||
│ │ ├─ StageEditor.vue
|
||||
│ │ └─ LineupMatrix.vue
|
||||
│ └─ __tests__/
|
||||
├─ scss/
|
||||
│ └─ timetable.scss # gebruikt Vuexy SCSS-tokens
|
||||
└─ pages/
|
||||
└─ EventTimetable.vue # route entrypoint
|
||||
```
|
||||
|
||||
### 3.2 Pinia store skelet
|
||||
|
||||
```ts
|
||||
// stores/timetable.ts
|
||||
export const useTimetableStore = defineStore('timetable', () => {
|
||||
const event = ref<Event | null>(null)
|
||||
const activeDayId = ref<string | null>(null)
|
||||
const stages = ref<Stage[]>([])
|
||||
const performances = ref<Map<string, Performance>>(new Map())
|
||||
const parked = ref<ParkedPerformance[]>([])
|
||||
const pending = ref<PendingPerformance[]>([])
|
||||
const artists = ref<Artist[]>([])
|
||||
|
||||
// ── Computed ─────────────────────────────
|
||||
const dayStages = computed(() => /* filter by stage_days for activeDayId */)
|
||||
const dayPerformances = computed(() => /* filter by day_id */)
|
||||
const lanesByStage = computed(() => /* assignLanes per stage */)
|
||||
|
||||
// ── Actions (alle optimistic + rollback) ─
|
||||
async function loadDay(eventId: string, dayId: string) { ... }
|
||||
async function movePerf(id: string, patch: PerfPatch) { ... }
|
||||
async function createPerf(input: NewPerf) { ... }
|
||||
async function parkPerf(id: string) { ... }
|
||||
async function schedulePending(pendingId: string, target: Target) { ... }
|
||||
async function scheduleParked(parkedId: string, target: Target) { ... }
|
||||
async function deletePerf(id: string) { ... }
|
||||
async function addStage(input: NewStage) { ... }
|
||||
async function updateStage(id: string, patch: StagePatch) { ... }
|
||||
async function deleteStage(id: string) { ... }
|
||||
async function reorderStages(dayId: string, stageIds: string[]) { ... }
|
||||
|
||||
// ── Realtime patches (Reverb) ────────────
|
||||
function applyRemotePatch(event: BroadcastEvent) { ... }
|
||||
|
||||
return { /* state + computed + actions */ }
|
||||
})
|
||||
```
|
||||
|
||||
**Optimistic update pattern** (gebruik overal):
|
||||
```ts
|
||||
async function movePerf(id, patch) {
|
||||
const before = performances.value.get(id)
|
||||
performances.value.set(id, { ...before, ...patch }) // 1. local apply
|
||||
try {
|
||||
const { data } = await api.patch(`/performances/${id}`, { ...patch, version: before.version })
|
||||
performances.value.set(id, data.performance) // 2. authoritative replace
|
||||
data.cascade.forEach(p => performances.value.set(p.id, p)) // 3. apply cascade
|
||||
} catch (e) {
|
||||
performances.value.set(id, before) // 4. rollback
|
||||
if (e.status === 409) toast.warn('Iemand anders heeft dit zojuist aangepast — ververs')
|
||||
else toast.error('Opslaan mislukt')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 Drag-drop composable
|
||||
|
||||
Port `startDragMove`, `startDragResize`, `startCreateDrag`, `startDragFromParking` van de PoC (`timetable.jsx`) **letterlijk** naar één composable. Gebruik `ref` voor `drag`-state en window-scoped `mousemove`/`mouseup` listeners. Cleanup via `onScopeDispose`.
|
||||
|
||||
Belangrijke regels die de PoC al heeft uitgewerkt — port verbatim:
|
||||
- **Lane-snap**: `Math.floor(yInRow / LANE_STEP)` voor create, `Math.round(...)` voor move
|
||||
- **Cursor-anchor cross-stage move**: `newRow` afgeleid van cursor-Y, niet block-top
|
||||
- **Click-after-drag suppressie**: zet `data-just-dragged` op het block bij mouseup-na-drag, fail-fast in click handler
|
||||
- **Timestamp-only conflict check**: `o.start < newEnd && o.end > newStart` — nooit pixel-Y meenemen in conflict-bepaling
|
||||
|
||||
### 3.4 Pure-logic ports
|
||||
|
||||
`lib/lanes.ts`, `lib/conflicts.ts` etc. zijn **directe TypeScript-ports** van `helpers.js`. Begin hiermee — schrijf eerst de Vitest tests en check ze tegen de PoC-output. Dit is de hoeksteen; alles wat erop bouwt is daarna voorspelbaar.
|
||||
|
||||
---
|
||||
|
||||
## 4. Realtime (Reverb + Echo)
|
||||
|
||||
### 4.1 Channels
|
||||
|
||||
```
|
||||
private-event.{eventId}.timetable
|
||||
```
|
||||
|
||||
Geautoriseerd in `routes/channels.php` via `Gate::allows('view-event-timetable', $event)`.
|
||||
|
||||
### 4.2 Events
|
||||
|
||||
```php
|
||||
PerformanceCreated { performance: PerformanceResource }
|
||||
PerformanceUpdated { performance: ..., cascade: [...] }
|
||||
PerformanceDeleted { id: ulid }
|
||||
PerformanceParked { performance: ..., parked: ParkedResource }
|
||||
ParkedScheduled { parked_id: ulid, performance: ... }
|
||||
StageCreated/Updated/Deleted/Reordered
|
||||
```
|
||||
|
||||
Elke event-class heeft `socket()` returning `request()->header('X-Socket-Id')` — clients filteren hun eigen broadcasts uit, zo voorkom je dubbele optimistic updates.
|
||||
|
||||
### 4.3 Frontend wiring
|
||||
|
||||
```ts
|
||||
// composables/useRealtime.ts
|
||||
export function useRealtime(eventId: string) {
|
||||
const store = useTimetableStore()
|
||||
const channel = window.Echo.private(`event.${eventId}.timetable`)
|
||||
channel.listen('PerformanceUpdated', (e) => store.applyRemotePatch(e))
|
||||
// ...
|
||||
onScopeDispose(() => channel.stopListening())
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Permissies (CASL)
|
||||
|
||||
Definieer in `resources/js/abilities.ts`:
|
||||
|
||||
```ts
|
||||
can('view', 'Timetable', { event_id: ... })
|
||||
can('edit', 'Performance', { event_id: ... })
|
||||
can('manage', 'Stage', { event_id: ... })
|
||||
can('book', 'Performance', { status: ['concept','requested','option'] })
|
||||
```
|
||||
|
||||
UI: knoppen/handles disablen via `v-if="$can('edit', 'Performance')"`. Backend valideert *altijd* opnieuw via `PerformancePolicy`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Styling
|
||||
|
||||
### 6.1 Tokens (Vuexy)
|
||||
|
||||
```scss
|
||||
// scss/timetable.scss — gebruikt Vuexy core variables
|
||||
@use '@core/scss/base/variables' as *;
|
||||
@use '@core/scss/base/mixins' as *;
|
||||
|
||||
:root {
|
||||
// Status-kleuren — toevoegen aan Vuetify theme onder customProperties:
|
||||
--tt-status-concept-bg: #f4f5f8;
|
||||
--tt-status-concept-bd: #c8ccd6;
|
||||
--tt-status-requested-bg: #fff5e6;
|
||||
--tt-status-requested-bd: #e89a3c;
|
||||
--tt-status-option-bg: #e9efff;
|
||||
--tt-status-option-bd: #5a8fcf;
|
||||
--tt-status-confirmed-bg: #e8f7ee;
|
||||
--tt-status-confirmed-bd: #3cc28a;
|
||||
--tt-status-cancelled-bd: #d63d4b;
|
||||
}
|
||||
|
||||
.cw-block {
|
||||
border-radius: $border-radius-sm;
|
||||
background: var(--v-theme-surface);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Voor dark-mode: definieer alle `--tt-status-*-*` ook in `[data-theme="dark"]`.
|
||||
|
||||
### 6.2 Vuetify-componenten waar nuttig
|
||||
|
||||
Header tabs → `<VTabs>`. Modals → `<VDialog>`. Density-toggle → `<VBtnToggle>`. Drawer → `<VNavigationDrawer location="right">`. Toasts → `<VSnackbar>` of `vue-toastification`.
|
||||
|
||||
**Niet** Vuetify gebruiken voor: het canvas, de blocks, de wachtrij-cards, de popover. Die zijn custom — past geen library-component bij.
|
||||
|
||||
---
|
||||
|
||||
## 7. Migratie-volgorde
|
||||
|
||||
Bouw in deze volgorde, één PR per stap:
|
||||
|
||||
1. **Migrations + seeders** met testdata (kopieer `data.js` uit PoC als seeder-script)
|
||||
2. **Models + Resources + Policies + tests**
|
||||
3. **API endpoints + Feature tests** (volledige roundtrip per endpoint)
|
||||
4. **`lib/*.ts` ports + Vitest** — alleen pure logic
|
||||
5. **Pinia store + mock-API tests**
|
||||
6. **`Timetable.vue` + `Block.vue` + `StageRow.vue`** — read-only render
|
||||
7. **Drag composable + drop wiring** (move, resize, restage)
|
||||
8. **Wachtrij + create-drag + parked-drop**
|
||||
9. **Modals (PerformanceModal, StageEditor, LineupMatrix)**
|
||||
10. **Popover + Drawer**
|
||||
11. **Realtime (Reverb)**
|
||||
12. **Audit log + permissions**
|
||||
13. **Print-view + lock-feature** (later)
|
||||
|
||||
Elke stap heeft een **definition of done**: PR groen (tests + Pint + ESLint), screenshot in PR-beschrijving, één code-review.
|
||||
|
||||
---
|
||||
|
||||
## 8. Testing strategy
|
||||
|
||||
| Laag | Tool | Coverage-doel |
|
||||
|---|---|---|
|
||||
| Pure logic (`lib/`) | Vitest | 100% — edge cases uit PoC |
|
||||
| Pinia store | Vitest + mock fetch | Alle actions, optimistic + rollback |
|
||||
| Components | Vitest + @vue/test-utils | Smoke + interaction op kritieke flows |
|
||||
| API | Pest | Per endpoint: happy + 4 edge cases + permissie |
|
||||
| E2E | Playwright | 3 critical flows: drag-drop, stage-delete-cascade, realtime-sync |
|
||||
|
||||
---
|
||||
|
||||
## 9. Open vragen (beslis vóór de eerste sprint)
|
||||
|
||||
1. **Multi-event per organisatie**: één gebruiker kan meerdere events tegelijk plannen → `event_id` overal scope, of subdomain per event?
|
||||
2. **Wachtrij per dag of per event**: PoC toont wachtrij gefilterd op actieve dag. Confirm: dat is de spec.
|
||||
3. **Lane storage**: bewaar je `lane` op de DB of bereken je het altijd opnieuw bij elk render? PoC bewaart het — dat is mijn aanbeveling (anders kan een gebruiker geen explicit lane-keuze maken).
|
||||
4. **Conflict policy**: blokkeren of waarschuwen? PoC waarschuwt + toont rode rand. Confirm.
|
||||
5. **Print-formaat**: A3 landscape per dag? Of één file met alle dagen?
|
||||
6. **Audit retention**: hoe lang bewaren? GDPR-relevant.
|
||||
|
||||
---
|
||||
|
||||
## 10. Bronbestanden
|
||||
|
||||
PoC ligt in `_poc/Crewli Timetable.html` (zelf-bevattend). De relevante files:
|
||||
|
||||
- `app.jsx` — root, Pinia-equivalent state
|
||||
- `timetable.jsx` — grid, blocks, drag, create-drag, ghost
|
||||
- `popover.jsx` — popover + drawer + wachtrij
|
||||
- `modals.jsx` — PerformanceModal, StageEditor, LineupMatrix
|
||||
- `helpers.js` — pure logic (port als eerste!)
|
||||
- `data.js` — demo data (seeder-template)
|
||||
- `styles.css` — visuele referentie voor de SCSS-port
|
||||
|
||||
---
|
||||
|
||||
**Eind van spec — klaar om aan Claude Code te voeden, één hoofdstuk tegelijk.**
|
||||
Reference in New Issue
Block a user