diff --git a/api/app/Http/Resources/Api/V1/Artist/ArtistContactResource.php b/api/app/Http/Resources/Api/V1/Artist/ArtistContactResource.php new file mode 100644 index 00000000..11c4384e --- /dev/null +++ b/api/app/Http/Resources/Api/V1/Artist/ArtistContactResource.php @@ -0,0 +1,33 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'artist_id' => $this->artist_id, + 'name' => $this->name, + 'email' => $this->email, + 'phone' => $this->phone, + 'role' => $this->role, + 'is_primary' => (bool) $this->is_primary, + 'receives_briefing' => (bool) $this->receives_briefing, + 'receives_infosheet' => (bool) $this->receives_infosheet, + ]; + } +} diff --git a/api/app/Http/Resources/Api/V1/Artist/ArtistEngagementResource.php b/api/app/Http/Resources/Api/V1/Artist/ArtistEngagementResource.php new file mode 100644 index 00000000..d3a2778a --- /dev/null +++ b/api/app/Http/Resources/Api/V1/Artist/ArtistEngagementResource.php @@ -0,0 +1,124 @@ + + */ + public function toArray(Request $request): array + { + $fee = (float) ($this->fee_amount ?? 0); + $bumaPercentage = (float) ($this->buma_percentage ?? 0); + $vatPercentage = (float) ($this->vat_percentage ?? 0); + + $bumaAmount = ($this->buma_applicable && $this->buma_handled_by === BumaHandledBy::Organisation) + ? round($fee * $bumaPercentage / 100, 2) + : 0.0; + + $vatGrondslag = $fee + ( + $this->buma_handled_by === BumaHandledBy::Organisation + ? $bumaAmount + : 0.0 + ); + + $vatAmount = $this->vat_applicable + ? round($vatGrondslag * $vatPercentage / 100, 2) + : 0.0; + + $breakdownTotal = 0.0; + foreach ((array) $this->deal_breakdown as $line) { + if (is_array($line) && isset($line['amount'])) { + $breakdownTotal += (float) $line['amount']; + } + } + + $totalCost = round($fee + $bumaAmount + $vatAmount + $breakdownTotal, 2); + + return [ + 'id' => $this->id, + 'organisation_id' => $this->organisation_id, + 'artist_id' => $this->artist_id, + 'event_id' => $this->event_id, + 'artist' => ArtistResource::make($this->whenLoaded('artist')), + 'project_leader_id' => $this->project_leader_id, + 'project_leader' => $this->whenLoaded('projectLeader', fn () => [ + 'id' => $this->projectLeader?->id, + 'name' => trim(($this->projectLeader?->first_name ?? '').' '.($this->projectLeader?->last_name ?? '')), + 'email' => $this->projectLeader?->email, + ]), + 'booking_status' => [ + 'value' => $this->booking_status?->value, + 'label' => $this->booking_status?->label(), + ], + 'fee_amount' => $this->fee_amount, + 'fee_currency' => $this->fee_currency, + 'fee_type' => [ + 'value' => $this->fee_type?->value, + 'label' => $this->fee_type?->label(), + ], + 'buma_applicable' => (bool) $this->buma_applicable, + 'buma_percentage' => $this->buma_percentage, + 'buma_handled_by' => [ + 'value' => $this->buma_handled_by?->value, + 'label' => $this->buma_handled_by?->label(), + ], + 'vat_applicable' => (bool) $this->vat_applicable, + 'vat_percentage' => $this->vat_percentage, + 'deal_breakdown' => $this->deal_breakdown, + 'deposit_percentage' => $this->deposit_percentage, + 'deposit_due_date' => optional($this->deposit_due_date)->toIso8601String(), + 'balance_due_date' => optional($this->balance_due_date)->toIso8601String(), + 'payment_status' => [ + 'value' => $this->payment_status?->value, + 'label' => $this->payment_status?->label(), + ], + 'crew_count' => $this->crew_count, + 'guests_count' => $this->guests_count, + 'requested_at' => optional($this->requested_at)->toIso8601String(), + 'option_expires_at' => optional($this->option_expires_at)->toIso8601String(), + 'advance_open_from' => optional($this->advance_open_from)->toIso8601String(), + 'advance_open_to' => optional($this->advance_open_to)->toIso8601String(), + 'advancing_completed_count' => $this->advancing_completed_count, + 'advancing_total_count' => $this->advancing_total_count, + 'notes' => $this->notes, + 'computed' => [ + 'buma_amount' => $bumaAmount, + 'vat_grondslag' => $vatGrondslag, + 'vat_amount' => $vatAmount, + 'breakdown_total' => $breakdownTotal, + 'total_cost' => $totalCost, + ], + 'performances' => PerformanceResource::collection($this->whenLoaded('performances')), + 'created_at' => optional($this->created_at)->toIso8601String(), + 'updated_at' => optional($this->updated_at)->toIso8601String(), + 'deleted_at' => optional($this->deleted_at)->toIso8601String(), + ]; + } +} diff --git a/api/app/Http/Resources/Api/V1/Artist/ArtistResource.php b/api/app/Http/Resources/Api/V1/Artist/ArtistResource.php new file mode 100644 index 00000000..3fb3dbb6 --- /dev/null +++ b/api/app/Http/Resources/Api/V1/Artist/ArtistResource.php @@ -0,0 +1,69 @@ + + */ + public function toArray(Request $request): array + { + $lifetime = $this->engagements() + ->whereNotIn('booking_status', [ + ArtistEngagementStatus::Cancelled->value, + ArtistEngagementStatus::Rejected->value, + ArtistEngagementStatus::Declined->value, + ]) + ->count(); + + $upcoming = $this->engagements() + ->whereNotIn('booking_status', [ + ArtistEngagementStatus::Cancelled->value, + ArtistEngagementStatus::Rejected->value, + ArtistEngagementStatus::Declined->value, + ]) + ->whereHas('event', fn ($q) => $q->where('end_at', '>=', now())) + ->count(); + + return [ + 'id' => $this->id, + 'organisation_id' => $this->organisation_id, + 'name' => $this->name, + 'slug' => $this->slug, + 'default_genre_id' => $this->default_genre_id, + 'default_genre' => GenreResource::make($this->whenLoaded('defaultGenre')), + 'default_draw' => $this->default_draw, + 'star_rating' => $this->star_rating, + 'home_base_country' => $this->home_base_country, + 'agent_company_id' => $this->agent_company_id, + 'agent_company' => $this->whenLoaded( + 'agentCompany', + fn () => [ + 'id' => $this->agentCompany?->id, + 'name' => $this->agentCompany?->name, + 'handles_buma' => (bool) ($this->agentCompany?->handles_buma ?? false), + ], + ), + 'notes' => $this->notes, + 'contacts' => ArtistContactResource::collection($this->whenLoaded('contacts')), + 'engagements_summary' => [ + 'lifetime_count' => $lifetime, + 'upcoming_count' => $upcoming, + ], + 'created_at' => optional($this->created_at)->toIso8601String(), + 'updated_at' => optional($this->updated_at)->toIso8601String(), + 'deleted_at' => optional($this->deleted_at)->toIso8601String(), + ]; + } +} diff --git a/api/app/Http/Resources/Api/V1/Artist/GenreResource.php b/api/app/Http/Resources/Api/V1/Artist/GenreResource.php new file mode 100644 index 00000000..5be38fab --- /dev/null +++ b/api/app/Http/Resources/Api/V1/Artist/GenreResource.php @@ -0,0 +1,32 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'organisation_id' => $this->organisation_id, + 'name' => $this->name, + 'color' => $this->color, + 'sort_order' => $this->sort_order, + 'is_active' => (bool) $this->is_active, + 'created_at' => optional($this->created_at)->toIso8601String(), + 'updated_at' => optional($this->updated_at)->toIso8601String(), + ]; + } +} diff --git a/api/app/Http/Resources/Api/V1/Artist/PerformanceResource.php b/api/app/Http/Resources/Api/V1/Artist/PerformanceResource.php new file mode 100644 index 00000000..cdb5d5ef --- /dev/null +++ b/api/app/Http/Resources/Api/V1/Artist/PerformanceResource.php @@ -0,0 +1,109 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'engagement_id' => $this->engagement_id, + 'event_id' => $this->event_id, + 'stage_id' => $this->stage_id, + 'lane' => (int) $this->lane, + 'lane_resolved' => $this->resolveLane(), + 'start_at' => optional($this->start_at)->toIso8601String(), + 'end_at' => optional($this->end_at)->toIso8601String(), + 'version' => (int) $this->version, + 'notes' => $this->notes, + 'warnings' => $this->computeWarnings(), + 'engagement' => ArtistEngagementResource::make($this->whenLoaded('engagement')), + 'stage' => StageResource::make($this->whenLoaded('stage')), + 'created_at' => optional($this->created_at)->toIso8601String(), + 'updated_at' => optional($this->updated_at)->toIso8601String(), + 'deleted_at' => optional($this->deleted_at)->toIso8601String(), + ]; + } + + /** + * Computed via LaneResolver over the (stage, sub-event) cohort. + * For parked performances (stage_id = null) the persisted lane is + * surfaced as-is — the wachtrij is a flat list, not a lane grid. + */ + private function resolveLane(): int + { + if ($this->stage_id === null) { + return (int) $this->lane; + } + + $cohort = Performance::query() + ->where('stage_id', $this->stage_id) + ->where('event_id', $this->event_id) + ->get(); + + $resolved = app(LaneResolver::class)->resolve($cohort); + + return $resolved[(string) $this->id] ?? (int) $this->lane; + } + + /** + * RFC v0.2 D5 / D6 / D25 — overlap, B2B, capacity warnings. + * Naive implementation for Session 2; refined as the timetable + * frontend lands in Session 4. + * + * @return array + */ + private function computeWarnings(): array + { + $warnings = []; + + if ($this->stage_id === null) { + return $warnings; + } + + $start = CarbonImmutable::instance($this->start_at); + $end = CarbonImmutable::instance($this->end_at); + + $peers = Performance::query() + ->where('stage_id', $this->stage_id) + ->where('event_id', $this->event_id) + ->where('id', '!=', $this->id) + ->get(); + + foreach ($peers as $other) { + $oStart = CarbonImmutable::instance($other->start_at); + $oEnd = CarbonImmutable::instance($other->end_at); + + if ($start < $oEnd && $oStart < $end && (int) $other->lane === (int) $this->lane) { + $warnings[] = 'overlap'; + break; + } + } + + foreach ($peers as $other) { + $oEnd = CarbonImmutable::instance($other->end_at); + $oStart = CarbonImmutable::instance($other->start_at); + if ($oEnd->equalTo($start) || $oStart->equalTo($end)) { + $warnings[] = 'b2b'; + break; + } + } + + return array_values(array_unique($warnings)); + } +} diff --git a/api/app/Http/Resources/Api/V1/Artist/StageResource.php b/api/app/Http/Resources/Api/V1/Artist/StageResource.php new file mode 100644 index 00000000..b6d46f30 --- /dev/null +++ b/api/app/Http/Resources/Api/V1/Artist/StageResource.php @@ -0,0 +1,36 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'event_id' => $this->event_id, + 'name' => $this->name, + 'color' => $this->color, + 'capacity' => $this->capacity, + 'sort_order' => $this->sort_order, + 'stage_days' => $this->whenLoaded( + 'stageDays', + fn () => $this->stageDays->pluck('event_id')->all(), + ), + 'created_at' => optional($this->created_at)->toIso8601String(), + 'updated_at' => optional($this->updated_at)->toIso8601String(), + ]; + } +} diff --git a/api/app/Services/Artist/LaneResolver.php b/api/app/Services/Artist/LaneResolver.php new file mode 100644 index 00000000..4811298c --- /dev/null +++ b/api/app/Services/Artist/LaneResolver.php @@ -0,0 +1,70 @@ + $performances scoped to one + * (stage_id, event_id) + * group + * @return array perf-id → resolved lane + */ + public function resolve(iterable $performances): array + { + $list = Collection::make($performances) + ->sortBy([ + fn (Performance $p): string => (string) $p->start_at, + fn (Performance $p): int => (int) $p->lane, + ]) + ->values(); + + /** @var array $laneEnds */ + $laneEnds = []; + $resolved = []; + + foreach ($list as $perf) { + $start = CarbonImmutable::instance($perf->start_at); + $end = CarbonImmutable::instance($perf->end_at); + + $assigned = null; + foreach ($laneEnds as $idx => $occupiedUntil) { + if ($start >= $occupiedUntil) { + $assigned = $idx; + $laneEnds[$idx] = $end; + break; + } + } + + if ($assigned === null) { + $assigned = count($laneEnds); + $laneEnds[$assigned] = $end; + } + + $resolved[(string) $perf->id] = $assigned; + } + + return $resolved; + } +}