registry->all()) ->map(fn ($r) => [ 'slug' => $r->slug(), 'name' => $r->name(), 'description' => $r->description(), ]) ->values(); return Inertia::render('Reports/Index', [ 'reports' => $reports, ]); } public function show(string $slug, Request $request) { $report = $this->registry->findBySlug($slug); abort_if(! $report, 404); $report->authorize($request); // Accept filters & pagination from query and return initial data for server-driven table $filters = $this->validateFilters($report->inputs(), $request); \Log::info('Report filters', ['filters' => $filters, 'request' => $request->all()]); $perPage = (int) ($request->integer('per_page') ?: 25); $paginator = $report->paginate($filters, $perPage); $rows = collect($paginator->items()) ->map(fn ($row) => $this->normalizeRow($row)) ->values(); return Inertia::render('Reports/Show', [ 'slug' => $report->slug(), 'name' => $report->name(), 'description' => $report->description(), 'inputs' => $report->inputs(), 'columns' => $report->columns(), 'rows' => $rows, 'meta' => [ 'total' => $paginator->total(), 'current_page' => $paginator->currentPage(), 'per_page' => $paginator->perPage(), 'last_page' => $paginator->lastPage(), ], 'query' => array_filter($filters, fn ($v) => $v !== null && $v !== ''), ]); } public function data(string $slug, Request $request) { $report = $this->registry->findBySlug($slug); abort_if(! $report, 404); $report->authorize($request); $filters = $this->validateFilters($report->inputs(), $request); $perPage = (int) ($request->integer('per_page') ?: 25); $paginator = $report->paginate($filters, $perPage); $rows = collect($paginator->items()) ->map(fn ($row) => $this->normalizeRow($row)) ->values(); return response()->json([ 'data' => $rows, 'total' => $paginator->total(), 'current_page' => $paginator->currentPage(), 'last_page' => $paginator->lastPage(), ]); } public function export(string $slug, Request $request) { $report = $this->registry->findBySlug($slug); abort_if(! $report, 404); $report->authorize($request); $filters = $this->validateFilters($report->inputs(), $request); $format = strtolower((string) $request->get('format', 'csv')); $rows = $report->query($filters)->get()->map(fn ($row) => $this->normalizeRow($row)); $columns = $report->columns(); $filename = $report->slug().'-'.now()->format('Ymd_His'); if ($format === 'pdf') { $pdf = \Barryvdh\DomPDF\Facade\Pdf::loadView('reports.pdf.table', [ 'name' => $report->name(), 'columns' => $columns, 'rows' => $rows, ]); return $pdf->download($filename.'.pdf'); } if ($format === 'xlsx') { $keys = array_map(fn ($c) => $c['key'], $columns); $headings = array_map(fn ($c) => $c['label'] ?? $c['key'], $columns); // Convert values for correct Excel rendering (dates, numbers, text) $array = $this->prepareXlsxArray($rows, $keys); // Build base column formats: text for contracts, EU datetime for *_at; numbers are formatted per-cell in AfterSheet $columnFormats = []; $textColumns = []; $dateColumns = []; foreach ($keys as $i => $key) { $letter = $this->excelColumnLetter($i + 1); if ($key === 'contract_reference') { $columnFormats[$letter] = '@'; $textColumns[] = $letter; continue; } if (str_ends_with($key, '_at')) { $columnFormats[$letter] = 'dd.mm.yyyy hh:mm'; $dateColumns[] = $letter; continue; } } // Anonymous export with custom value binder to force text where needed $export = new class($array, $headings, $columnFormats, $textColumns, $dateColumns) extends \Maatwebsite\Excel\DefaultValueBinder implements \Maatwebsite\Excel\Concerns\FromArray, \Maatwebsite\Excel\Concerns\ShouldAutoSize, \Maatwebsite\Excel\Concerns\WithColumnFormatting, \Maatwebsite\Excel\Concerns\WithCustomValueBinder, \Maatwebsite\Excel\Concerns\WithEvents, \Maatwebsite\Excel\Concerns\WithHeadings { public function __construct(private array $array, private array $headings, private array $formats, private array $textColumns, private array $dateColumns) {} public function array(): array { return $this->array; } public function headings(): array { return $this->headings; } public function columnFormats(): array { return $this->formats; } public function bindValue(\PhpOffice\PhpSpreadsheet\Cell\Cell $cell, $value): bool { $col = preg_replace('/\d+/', '', $cell->getCoordinate()); // e.g., B from B2 // Force text for configured columns or very long digit-only strings (>15) if (in_array($col, $this->textColumns, true) || (is_string($value) && ctype_digit($value) && strlen($value) > 15)) { $cell->setValueExplicit((string) $value, \PhpOffice\PhpSpreadsheet\Cell\DataType::TYPE_STRING); return true; } return parent::bindValue($cell, $value); } public function registerEvents(): array { return [ \Maatwebsite\Excel\Events\AfterSheet::class => function (\Maatwebsite\Excel\Events\AfterSheet $event) { $sheet = $event->sheet->getDelegate(); // Data starts at row 2 (row 1 is headings) $rowIndex = 2; foreach ($this->array as $row) { foreach (array_values($row) as $i => $val) { $colLetter = $this->colLetter($i + 1); if (in_array($colLetter, $this->textColumns, true) || in_array($colLetter, $this->dateColumns, true)) { continue; // already handled via columnFormats or binder } $coord = $colLetter.$rowIndex; $fmt = null; if (is_int($val)) { // Integer: thousands separator, no decimals $fmt = '#,##0'; } elseif (is_float($val)) { // Float: show decimals only if fractional part exists $fmt = (floor($val) != $val) ? '#,##0.00' : '#,##0'; } if ($fmt) { $sheet->getStyle($coord)->getNumberFormat()->setFormatCode($fmt); } } $rowIndex++; } }, ]; } private function colLetter(int $index): string { $letter = ''; while ($index > 0) { $mod = ($index - 1) % 26; $letter = chr(65 + $mod).$letter; $index = intdiv($index - $mod, 26) - 1; } return $letter; } }; return \Maatwebsite\Excel\Facades\Excel::download($export, $filename.'.xlsx'); } // Default CSV export $keys = array_map(fn ($c) => $c['key'], $columns); $headings = array_map(fn ($c) => $c['label'] ?? $c['key'], $columns); $csv = fopen('php://temp', 'r+'); fputcsv($csv, $headings); foreach ($rows as $r) { $line = collect($keys)->map(fn ($k) => data_get($r, $k))->toArray(); fputcsv($csv, $line); } rewind($csv); $content = stream_get_contents($csv) ?: ''; fclose($csv); return response($content, 200, [ 'Content-Type' => 'text/csv', 'Content-Disposition' => 'attachment; filename="'.$filename.'.csv"', ]); } /** * Lightweight users lookup for filters: id + name, optional search and limit. */ public function users(Request $request) { $search = trim((string) $request->get('search', '')); $limit = (int) ($request->integer('limit') ?: 10); $q = \App\Models\User::query()->orderBy('name'); if ($search !== '') { $like = '%'.mb_strtolower($search).'%'; $q->where(function ($qq) use ($like) { $qq->whereRaw('LOWER(name) LIKE ?', [$like]) ->orWhereRaw('LOWER(email) LIKE ?', [$like]); }); } $users = $q->limit(max(1, min(50, $limit)))->get(['id', 'name']); return response()->json($users); } /** * Lightweight clients lookup for filters: uuid + name (person full_name), optional search and limit. */ public function clients(Request $request) { $clients = \App\Models\Client::query() ->with('person:id,full_name') ->get() ->map(fn($c) => [ 'id' => $c->uuid, 'name' => $c->person->full_name ?? 'Unknown' ]) ->sortBy('name') ->values(); return response()->json($clients); } /** * Build validation rules based on inputs descriptor and validate. * * @param array> $inputs * @return array */ protected function validateFilters(array $inputs, Request $request): array { $rules = []; foreach ($inputs as $inp) { $key = $inp['key']; $type = $inp['type'] ?? 'string'; $nullable = ($inp['nullable'] ?? true) ? 'nullable' : 'required'; $rules[$key] = match ($type) { 'date' => [$nullable, 'date'], 'integer' => [$nullable, 'integer'], 'select:user' => [$nullable, 'integer', 'exists:users,id'], 'select:client' => [$nullable, 'string', 'exists:clients,uuid'], default => [$nullable, 'string'], }; } return $request->validate($rules); } /** * Ensure derived export/display fields exist on row objects. */ protected function normalizeRow(object $row): object { if (isset($row->contract) && ! isset($row->contract_reference)) { $row->contract_reference = $row->contract->reference ?? null; } if (isset($row->assignedUser) && ! isset($row->assigned_user_name)) { $row->assigned_user_name = $row->assignedUser->name ?? null; } return $row; } /** * Convert rows for XLSX export: dates to Excel serial numbers, numbers to numeric, contract refs to text. * * @param iterable $rows * @param array $keys * @return array> */ protected function prepareXlsxArray(iterable $rows, array $keys): array { $out = []; foreach ($rows as $r) { $line = []; foreach ($keys as $k) { $v = data_get($r, $k); if ($k === 'contract_reference') { $line[] = (string) $v; continue; } if (str_ends_with($k, '_at')) { if (empty($v)) { $line[] = null; } else { try { $dt = \Carbon\Carbon::parse($v); $line[] = \PhpOffice\PhpSpreadsheet\Shared\Date::dateTimeToExcel($dt); } catch (\Throwable $e) { $line[] = (string) $v; } } continue; } if (is_int($v) || is_float($v)) { $line[] = $v; } elseif (is_numeric($v) && is_string($v)) { // cast numeric-like strings unless they are identifiers that we want as text $line[] = (strpos($k, 'id') !== false) ? (int) $v : ($v + 0); } else { $line[] = $v; } } $out[] = $line; } return $out; } /** * Convert 1-based index to Excel column letter. */ protected function excelColumnLetter(int $index): string { $letter = ''; while ($index > 0) { $mod = ($index - 1) % 26; $letter = chr(65 + $mod).$letter; $index = intdiv($index - $mod, 26) - 1; } return $letter; } }