380 lines
14 KiB
PHP
380 lines
14 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Reports\ReportRegistry;
|
|
use Illuminate\Http\Request;
|
|
use Inertia\Inertia;
|
|
|
|
// facades referenced with fully-qualified names below to satisfy static analysis
|
|
|
|
class ReportController extends Controller
|
|
{
|
|
public function __construct(protected ReportRegistry $registry) {}
|
|
|
|
public function index(Request $request)
|
|
{
|
|
$reports = collect($this->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<int, array<string, mixed>> $inputs
|
|
* @return array<string, mixed>
|
|
*/
|
|
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<int, object|array> $rows
|
|
* @param array<int, string> $keys
|
|
* @return array<int, array<int, mixed>>
|
|
*/
|
|
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;
|
|
}
|
|
}
|