Commit 3d472374 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Add branded printable schedule sheets with the space grid

"The schedule" is not one document. Different people print it for different
reasons and a single layout serves none of them, so this ships three sheets
sized for their actual use scene:

- Facility day board (A4/A3 landscape, auto-picked by segment count) — time
  down the side, the facility's physical grid segments across the top, every
  booking in its own cell. This is the one that carries the grid, and the one
  that did not exist. Built to be pinned at the court entrance and read from
  a few metres away.
- Facility week board (A3 landscape) — seven days x time, segments as a badge
  per booking. The notice-board overview.
- Trainer day cards (A4 portrait, two-up, cut lines) — one pocket card per
  trainer: when, where, which segment, how many players. A trainer does not
  want an A3 off the wall.

Rendered as branded HTML and printed from the browser rather than through a
PDF library: Arabic shaping survives intact, Cairo and the academy's brand
colour render exactly, and nothing queues on the server. print-color-adjust
is set explicitly, without which browsers strip every fill and the whole
colour-coded board arrives as blank boxes.

Group colours move to a shared GroupColor palette used by both the builder
and every sheet, so a coach who learns "our group is the teal one" on the
wall sees the same teal in the app. The hues stay distinguishable in
greyscale, because plenty of academies print on a mono laser.

Bookings read as a filled, tinted cell rather than a thin coloured stripe —
a 3px rail is invisible at the distance these are actually read from.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent 9454aa03
<?php
namespace App\Domain\Training\Support;
/**
* One palette for group colours, shared by the schedule builder and every
* printed sheet.
*
* Screen and paper must agree: a coach who learns "our group is the teal one"
* on the wall board should see the same teal in the app. Two copies of this
* list would drift the first time either side was edited.
*
* Hues are chosen to stay distinguishable in greyscale too, because plenty of
* academies print these on a mono laser.
*/
final class GroupColor
{
public const PALETTE = [
'#1D4ED8', // blue
'#047857', // emerald
'#B45309', // amber
'#B91C1C', // red
'#6D28D9', // violet
'#BE185D', // pink
'#0E7490', // cyan
'#4D7C0F', // lime
'#C2410C', // orange
'#4338CA', // indigo
'#0F766E', // teal
'#A21CAF', // fuchsia
'#0369A1', // sky
'#15803D', // green
'#7E22CE', // purple
];
public static function for(int $id): string
{
return self::PALETTE[$id % count(self::PALETTE)];
}
/**
* A tint of the group colour for cell backgrounds — dark enough to identify,
* light enough to keep the text on top above 4.5:1.
*/
public static function tint(int $id): string
{
return self::for($id) . '1A'; // ~10% alpha
}
}
This diff is collapsed.
......@@ -1018,11 +1018,7 @@ private function getAvailableTrainers(): array
private function groupColor(int $id): string
{
$colors = [
'#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6',
'#EC4899', '#06B6D4', '#84CC16', '#F97316', '#6366F1',
'#14B8A6', '#D946EF', '#0EA5E9', '#22C55E', '#A855F7',
];
return $colors[$id % count($colors)];
// Shared with every printed sheet — screen and paper must agree.
return \App\Domain\Training\Support\GroupColor::for($id);
}
}
@props([
'title',
'subtitle' => null,
'meta' => [],
'size' => 'A4 landscape',
'legend' => null,
])
@php
$branding = app(\App\Domain\Shared\Services\SettingsService::class);
$brandLogo = $branding->get('branding.logo');
$brandPrimary = $branding->get('branding.primary_color', '#1e40af');
$academyName = $branding->get('branding.academy_name')
?? (app()->has('current_academy') ? app('current_academy')->name_ar : config('app.name'));
$footerText = $branding->get('branding.receipt_footer_text', '');
@endphp
<!DOCTYPE html>
<html dir="rtl" lang="ar">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ $title }}</title>
<link href="https://fonts.googleapis.com/css2?family=Cairo:wght@400;600;700;900&display=swap" rel="stylesheet">
<style>
/* These sheets live on a wall or in a hand, not on a screen.
Sized for reading at distance, printed on whatever laser is nearest. */
@page { size: {{ $size }}; margin: 8mm; }
*, *::before, *::after { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
background: #fff;
color: #111827;
font-family: 'Cairo', 'Noto Sans Arabic', system-ui, sans-serif;
font-size: 12px;
line-height: 1.45;
-webkit-font-smoothing: antialiased;
}
/* Without this browsers strip every fill on print and the whole
colour-coded board arrives as blank boxes. */
body, .sheet, .band, .cell, .chip, .card {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.sheet { padding: 6mm 4mm; }
/* ── Masthead ─────────────────────────────────────── */
.band {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
background: {{ $brandPrimary }};
color: #fff;
padding: 10px 16px;
border-radius: 6px;
}
.band .who { display: flex; align-items: center; gap: 12px; min-width: 0; }
.band img { height: 34px; width: auto; object-fit: contain; }
.band .academy { font-size: 12px; font-weight: 600; opacity: .82; }
.band h1 { margin: 0; font-size: 21px; font-weight: 900; letter-spacing: -.01em; }
.band .sub { font-size: 12.5px; font-weight: 600; opacity: .92; }
.band .meta {
text-align: left;
font-size: 11px;
line-height: 1.7;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.band .meta b { font-weight: 700; }
/* ── Legend ───────────────────────────────────────── */
.legend {
display: flex;
flex-wrap: wrap;
gap: 6px 14px;
padding: 8px 4px 0;
font-size: 11px;
}
.legend .item { display: flex; align-items: center; gap: 5px; }
.legend .dot { width: 9px; height: 9px; border-radius: 2px; flex: none; }
/* ── Shared table shell ───────────────────────────── */
table { width: 100%; border-collapse: collapse; margin-top: 8px; }
thead { display: table-header-group; } /* repeat header on page 2+ */
tr { break-inside: avoid; }
th, td { border: 1px solid #D1D5DB; padding: 5px 6px; vertical-align: top; }
th {
background: #F3F4F6;
font-size: 10.5px;
font-weight: 700;
color: #374151;
text-align: center;
}
.time-col {
width: 62px;
text-align: center;
font-weight: 700;
font-variant-numeric: tabular-nums;
background: #F9FAFB;
white-space: nowrap;
}
.num { font-variant-numeric: tabular-nums; }
/* ── Footer ───────────────────────────────────────── */
.foot {
margin-top: 10px;
padding-top: 6px;
border-top: 1px solid #E5E7EB;
display: flex;
justify-content: space-between;
gap: 16px;
font-size: 10px;
color: #6B7280;
}
/* ── Screen-only toolbar ──────────────────────────── */
.toolbar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
gap: 8px;
justify-content: flex-end;
padding: 10px 14px;
background: #F9FAFB;
border-bottom: 1px solid #E5E7EB;
}
.toolbar button, .toolbar a {
font: inherit;
font-size: 13px;
font-weight: 600;
padding: 8px 16px;
border-radius: 8px;
border: 1px solid #D1D5DB;
background: #fff;
color: #374151;
cursor: pointer;
text-decoration: none;
}
.toolbar .primary {
background: {{ $brandPrimary }};
border-color: {{ $brandPrimary }};
color: #fff;
}
@media print { .toolbar { display: none !important; } }
</style>
@stack('styles')
</head>
<body>
<div class="toolbar">
<a href="javascript:history.back()">{{ __('رجوع') }}</a>
<button class="primary" onclick="window.print()">{{ __('طباعة') }}</button>
</div>
<div class="sheet">
<div class="band">
<div class="who">
@if($brandLogo)
<img src="{{ $brandLogo }}" alt="">
@endif
<div>
<div class="academy">{{ $academyName }}</div>
<h1>{{ $title }}</h1>
@if($subtitle)<div class="sub">{{ $subtitle }}</div>@endif
</div>
</div>
@if($meta)
<div class="meta">
@foreach($meta as $label => $value)
<div><b>{{ $value }}</b> — {{ $label }}</div>
@endforeach
</div>
@endif
</div>
{{-- An unpassed slot is still an object, so test the rendered string. --}}
@if(isset($legend) && trim((string) $legend) !== '')
<div class="legend">{{ $legend }}</div>
@endif
{{ $slot }}
<div class="foot">
<span>{{ $footerText }}</span>
<span class="num">{{ __('طُبع في') }} {{ now()->format('Y-m-d H:i') }}</span>
</div>
</div>
</body>
</html>
......@@ -11,6 +11,55 @@
<option value="{{ $f->id }}">{{ $f->name_ar }} ({{ $f->branch?->name_ar }})</option>
@endforeach
</select>
{{-- Print sheets --}}
<div class="mt-3 pt-3 border-t border-gray-100" x-data="{ printOpen: false }">
<button type="button" @click="printOpen = !printOpen"
class="w-full flex items-center justify-between gap-2 px-3 py-2 rounded-lg
border border-gray-300 text-sm font-medium text-gray-700 hover:bg-gray-50">
<span class="flex items-center gap-2">
<x-ui.icon name="document-text" class="w-4 h-4 text-gray-500" />
{{ __('طباعة الجدول') }}
</span>
<x-ui.icon name="chevron-down" class="w-4 h-4 text-gray-400 transition-transform"
x-bind:class="printOpen && 'rotate-180'" />
</button>
<div x-show="printOpen" x-cloak class="mt-2 space-y-1">
@if($facility_id)
<a href="{{ route('facilities.print.day', ['facility' => $facility_id, 'date' => $selectedDay]) }}"
target="_blank"
class="flex items-start gap-2 px-3 py-2 rounded-lg hover:bg-blue-50 text-sm text-gray-700">
<x-ui.icon name="grid" class="w-4 h-4 mt-0.5 text-blue-600 shrink-0" />
<span>
<span class="block font-medium">{{ __('لوحة اليوم') }}</span>
<span class="block text-xs text-gray-500">{{ __('الوقت × أجزاء المساحة — للتعليق على الحائط') }}</span>
</span>
</a>
<a href="{{ route('facilities.print.week', ['facility' => $facility_id, 'date' => $selectedDay]) }}"
target="_blank"
class="flex items-start gap-2 px-3 py-2 rounded-lg hover:bg-blue-50 text-sm text-gray-700">
<x-ui.icon name="calendar-days" class="w-4 h-4 mt-0.5 text-blue-600 shrink-0" />
<span>
<span class="block font-medium">{{ __('لوحة الأسبوع') }}</span>
<span class="block text-xs text-gray-500">{{ __('٧ أيام × الوقت — نظرة عامة') }}</span>
</span>
</a>
@else
<p class="px-3 py-2 text-xs text-gray-400">{{ __('اختر منشأة لطباعة لوحاتها') }}</p>
@endif
<a href="{{ route('schedule.print.trainers', ['date' => $selectedDay]) }}"
target="_blank"
class="flex items-start gap-2 px-3 py-2 rounded-lg hover:bg-blue-50 text-sm text-gray-700">
<x-ui.icon name="user-group" class="w-4 h-4 mt-0.5 text-blue-600 shrink-0" />
<span>
<span class="block font-medium">{{ __('بطاقات المدربين') }}</span>
<span class="block text-xs text-gray-500">{{ __('بطاقة لكل مدرب — تُقص وتُوزع') }}</span>
</span>
</a>
</div>
</div>
</div>
{{-- Tabs: Groups / Trainers --}}
......
@php
use App\Domain\Training\Support\GroupColor;
@endphp
<x-print.sheet
:title="$facility->name_ar"
:subtitle="__('جدول اليوم') . ' — ' . $dayLabel"
:size="count($segments) > 6 ? 'A3 landscape' : 'A4 landscape'"
:meta="[
__('الفرع') => $facility->branch?->name_ar ?? '—',
__('التاريخ') => $date->format('Y-m-d'),
__('الحجوزات') => count($reservations),
]">
<x-slot:legend>
@foreach($legendGroups as $g)
<span class="item">
<span class="dot" style="background: {{ GroupColor::for($g['id']) }}"></span>
{{ $g['name'] }}
</span>
@endforeach
@if(empty($legendGroups))
<span style="color:#6B7280">{{ __('لا توجد حجوزات في هذا اليوم') }}</span>
@endif
</x-slot:legend>
@push('styles')
<style>
.board td { height: 30px; padding: 0; }
.cell {
height: 100%;
padding: 4px 5px;
border-radius: 3px;
font-size: 10.5px;
line-height: 1.3;
overflow: hidden;
}
.cell .g { font-weight: 700; display: block; }
/* Tint the secondary line from the group hue rather than graying it,
so it holds contrast on the filled cell. */
.cell .t { font-size: 9.5px; opacity: .8; display: block; }
.seg-head { font-size: 10px; font-weight: 700; }
.seg-head span { display: block; font-weight: 400; opacity: .7; font-size: 9px; }
.free { background: repeating-linear-gradient(45deg,#fff,#fff 5px,#FAFAFA 5px,#FAFAFA 10px); }
</style>
@endpush
<table class="board">
<thead>
<tr>
<th class="time-col">{{ __('الوقت') }}</th>
@foreach($segments as $segment)
<th class="seg-head">
{{ $segment->name_ar }}
<span class="num">{{ $segment->code }}</span>
</th>
@endforeach
</tr>
</thead>
<tbody>
@foreach($slots as $slot)
<tr>
<td class="time-col">{{ $slot['label'] }}</td>
@foreach($segments as $segment)
@php $booking = $cells[$slot['key']][$segment->id] ?? null; @endphp
<td>
@if($booking)
<div class="cell" style="background: {{ GroupColor::for($booking['group_id']) }}26;
border: 1px solid {{ GroupColor::for($booking['group_id']) }}66;
color: {{ GroupColor::for($booking['group_id']) }};">
<span class="g">{{ $booking['title'] }}</span>
<span class="t num">{{ $booking['start'] }}–{{ $booking['end'] }}</span>
@if($booking['trainer'])
<span class="t">{{ $booking['trainer'] }}</span>
@endif
</div>
@else
<div class="cell free"></div>
@endif
</td>
@endforeach
</tr>
@endforeach
</tbody>
</table>
</x-print.sheet>
@php
use App\Domain\Training\Support\GroupColor;
@endphp
<x-print.sheet
:title="$facility->name_ar"
:subtitle="__('الجدول الأسبوعي')"
size="A3 landscape"
:meta="[
__('الفرع') => $facility->branch?->name_ar ?? '—',
__('الأسبوع') => $weekStart->format('Y-m-d') . ' → ' . $weekEnd->format('Y-m-d'),
__('الحجوزات') => $totalBookings,
]">
<x-slot:legend>
@foreach($legendGroups as $g)
<span class="item">
<span class="dot" style="background: {{ GroupColor::for($g['id']) }}"></span>
{{ $g['name'] }}
</span>
@endforeach
@if(empty($legendGroups))
<span style="color:#6B7280">{{ __('لا توجد حجوزات هذا الأسبوع') }}</span>
@endif
</x-slot:legend>
@push('styles')
<style>
.week td { height: 26px; padding: 2px; }
.day-head { font-size: 11px; }
.day-head span { display: block; font-weight: 400; opacity: .7; font-size: 9.5px; }
.bk {
padding: 3px 5px;
border-radius: 3px;
font-size: 10px;
line-height: 1.3;
margin-bottom: 2px;
}
.bk:last-child { margin-bottom: 0; }
.bk b { display: block; font-weight: 700; }
.bk .where { font-size: 9px; opacity: .8; }
</style>
@endpush
<table class="week">
<thead>
<tr>
<th class="time-col">{{ __('الوقت') }}</th>
@foreach($days as $day)
<th class="day-head">
{{ $day['name'] }}
<span class="num">{{ $day['date']->format('m-d') }}</span>
</th>
@endforeach
</tr>
</thead>
<tbody>
@foreach($slots as $slot)
<tr>
<td class="time-col">{{ $slot['label'] }}</td>
@foreach($days as $day)
<td>
@foreach($grid[$day['key']][$slot['key']] ?? [] as $booking)
<div class="bk" style="background: {{ GroupColor::for($booking['group_id']) }}26;
border: 1px solid {{ GroupColor::for($booking['group_id']) }}66;
color: {{ GroupColor::for($booking['group_id']) }};">
<b>{{ $booking['title'] }}</b>
<span class="where num">
{{ $booking['start'] }}–{{ $booking['end'] }}
@if($booking['segments']) · {{ $booking['segments'] }} @endif
</span>
</div>
@endforeach
</td>
@endforeach
</tr>
@endforeach
</tbody>
</table>
</x-print.sheet>
@php
use App\Domain\Training\Support\GroupColor;
@endphp
<x-print.sheet
:title="__('بطاقات المدربين')"
:subtitle="$dayLabel"
size="A4 portrait"
:meta="[
__('الفرع') => $branchName ?? '—',
__('التاريخ') => $date->format('Y-m-d'),
__('المدربون') => count($trainers),
]">
@push('styles')
<style>
/* Two per row, cut along the dashed line — a trainer wants something
pocket-sized, not an A3 off the wall. */
.cards { display: grid; grid-template-columns: 1fr 1fr; gap: 0; margin-top: 10px; }
.card {
break-inside: avoid;
border: 1px dashed #9CA3AF;
padding: 10px 12px;
min-height: 62mm;
}
.card h2 { margin: 0 0 2px; font-size: 15px; font-weight: 900; }
.card .role { font-size: 10.5px; color: #6B7280; margin-bottom: 8px; }
.slot {
display: flex;
gap: 8px;
padding: 5px 0;
border-top: 1px solid #F3F4F6;
}
.slot .when {
font-weight: 700;
font-size: 12px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
min-width: 84px;
}
.slot .what b { display: block; font-size: 11.5px; }
.slot .what span { font-size: 10px; color: #4B5563; }
.rail { width: 4px; border-radius: 2px; flex: none; }
.none { font-size: 11px; color: #9CA3AF; padding: 10px 0; }
</style>
@endpush
<div class="cards">
@foreach($trainers as $trainer)
<div class="card">
<h2>{{ $trainer['name'] }}</h2>
<div class="role num">{{ $dayLabel }} · {{ $date->format('Y-m-d') }}</div>
@forelse($trainer['slots'] as $slot)
<div class="slot">
<span class="rail" style="background: {{ GroupColor::for($slot['group_id']) }}"></span>
<span class="when">{{ $slot['start'] }}–{{ $slot['end'] }}</span>
<span class="what">
<b>{{ $slot['group'] }}</b>
<span>
{{ $slot['facility'] }}@if($slot['segments']) · {{ $slot['segments'] }}@endif
@if($slot['count'] !== null) · {{ $slot['count'] }} {{ __('لاعب') }} @endif
</span>
</span>
</div>
@empty
<p class="none">{{ __('لا توجد حصص اليوم') }}</p>
@endforelse
</div>
@endforeach
</div>
</x-print.sheet>
......@@ -538,6 +538,14 @@
->middleware('permission:invoices.create');
// Print — Group Schedule, Participant Card, Invoice
// Printable schedule sheets — branded HTML, printed from the browser.
Route::get('/facilities/{facility}/print/day', [\App\Http\Controllers\SchedulePrintController::class, 'facilityDay'])
->name('facilities.print.day')->middleware('permission:schedules.view');
Route::get('/facilities/{facility}/print/week', [\App\Http\Controllers\SchedulePrintController::class, 'facilityWeek'])
->name('facilities.print.week')->middleware('permission:schedules.view');
Route::get('/schedule/print/trainers', [\App\Http\Controllers\SchedulePrintController::class, 'trainerDay'])
->name('schedule.print.trainers')->middleware('permission:schedules.view');
Route::get('/groups/{group}/print', \App\Http\Controllers\GroupSchedulePrintController::class)->name('groups.print')
->middleware('permission:groups.list');
Route::get('/participants/{participant}/card', \App\Http\Controllers\ParticipantCardController::class)->name('participants.card')
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment