diff --git a/app/Exports/ClientContractsExport.php b/app/Exports/ClientContractsExport.php new file mode 100644 index 0000000..2153f74 --- /dev/null +++ b/app/Exports/ClientContractsExport.php @@ -0,0 +1,158 @@ + + */ + private array $columnLetterMap = []; + + /** + * @var array + */ + public const COLUMN_METADATA = [ + 'reference' => ['label' => 'Referenca'], + 'customer' => ['label' => 'Stranka'], + 'start' => ['label' => 'Začetek'], + 'segment' => ['label' => 'Segment'], + 'balance' => ['label' => 'Stanje'], + ]; + + /** + * @param array $columns + */ + public function __construct(private Builder $query, private array $columns) {} + + /** + * @return array + */ + public static function allowedColumns(): array + { + return array_keys(self::COLUMN_METADATA); + } + + public static function columnLabel(string $column): string + { + return self::COLUMN_METADATA[$column]['label'] ?? $column; + } + + public function query(): Builder + { + return $this->query; + } + + /** + * @return array + */ + public function map($row): array + { + return array_map(fn (string $column) => $this->resolveValue($row, $column), $this->columns); + } + + /** + * @return array + */ + public function headings(): array + { + return array_map(fn (string $column) => self::columnLabel($column), $this->columns); + } + + /** + * @return array + */ + public function columnFormats(): array + { + $formats = []; + + foreach ($this->getColumnLetterMap() as $letter => $column) { + if ($column === 'reference') { + $formats[$letter] = self::TEXT_EXCEL_FORMAT; + + continue; + } + + if ($column === 'start') { + $formats[$letter] = self::DATE_EXCEL_FORMAT; + } + } + + return $formats; + } + + private function resolveValue(Contract $contract, string $column): mixed + { + return match ($column) { + 'reference' => $contract->reference, + 'customer' => optional($contract->clientCase?->person)->full_name, + 'start' => $this->formatDate($contract->start_date), + 'segment' => $contract->segments?->first()?->name, + 'balance' => optional($contract->account)->balance_amount, + default => null, + }; + } + + private function formatDate(?string $date): mixed + { + if (empty($date)) { + return null; + } + + try { + $carbon = Carbon::parse($date); + + return ExcelDate::dateTimeToExcel($carbon); + } catch (\Exception $e) { + return null; + } + } + + /** + * @return array + */ + private function getColumnLetterMap(): array + { + if ($this->columnLetterMap !== []) { + return $this->columnLetterMap; + } + + $letter = 'A'; + foreach ($this->columns as $column) { + $this->columnLetterMap[$letter] = $column; + $letter++; + } + + return $this->columnLetterMap; + } + + public function bindValue(Cell $cell, $value): bool + { + if (is_numeric($value)) { + $cell->setValueExplicit($value, DataType::TYPE_NUMERIC); + + return true; + } + + return parent::bindValue($cell, $value); + } +} diff --git a/app/Http/Controllers/ClientController.php b/app/Http/Controllers/ClientController.php index 51cccb6..3925b61 100644 --- a/app/Http/Controllers/ClientController.php +++ b/app/Http/Controllers/ClientController.php @@ -2,11 +2,15 @@ namespace App\Http\Controllers; +use App\Exports\ClientContractsExport; +use App\Http\Requests\ExportClientContractsRequest; use App\Models\Client; use App\Services\ReferenceDataCache; use DB; use Illuminate\Http\Request; +use Illuminate\Support\Str; use Inertia\Inertia; +use Maatwebsite\Excel\Facades\Excel; class ClientController extends Controller { @@ -166,6 +170,83 @@ public function contracts(Client $client, Request $request) ]); } + public function exportContracts(ExportClientContractsRequest $request, Client $client) + { + $data = $request->validated(); + $columns = array_values(array_unique($data['columns'])); + + $from = $data['from'] ?? null; + $to = $data['to'] ?? null; + $search = $data['search'] ?? null; + $segmentsParam = $data['segments'] ?? null; + $segmentIds = $segmentsParam ? array_filter(explode(',', $segmentsParam)) : []; + + $query = \App\Models\Contract::query() + ->whereHas('clientCase', function ($q) use ($client) { + $q->where('client_id', $client->id); + }) + ->with([ + 'clientCase:id,uuid,person_id', + 'clientCase.person:id,full_name', + 'segments' => function ($q) { + $q->wherePivot('active', true)->select('segments.id', 'segments.name'); + }, + 'account:id,accounts.contract_id,balance_amount', + ]) + ->select(['id', 'uuid', 'reference', 'start_date', 'client_case_id']) + ->whereNull('deleted_at') + ->when($from || $to, function ($q) use ($from, $to) { + if (! empty($from)) { + $q->whereDate('start_date', '>=', $from); + } + if (! empty($to)) { + $q->whereDate('start_date', '<=', $to); + } + }) + ->when($search, function ($q) use ($search) { + $q->where(function ($inner) use ($search) { + $inner->where('reference', 'ilike', '%'.$search.'%') + ->orWhereHas('clientCase.person', function ($p) use ($search) { + $p->where('full_name', 'ilike', '%'.$search.'%'); + }); + }); + }) + ->when($segmentIds, function ($q) use ($segmentIds) { + $q->whereHas('segments', function ($s) use ($segmentIds) { + $s->whereIn('segments.id', $segmentIds) + ->where('contract_segment.active', true); + }); + }) + ->orderByDesc('start_date'); + + if (($data['scope'] ?? ExportClientContractsRequest::SCOPE_ALL) === ExportClientContractsRequest::SCOPE_CURRENT) { + $page = max(1, (int) ($data['page'] ?? 1)); + $perPage = max(1, min(200, (int) ($data['per_page'] ?? 15))); + $query->forPage($page, $perPage); + } + + $filename = $this->buildExportFilename($client); + + return Excel::download(new ClientContractsExport($query, $columns), $filename); + } + + private function buildExportFilename(Client $client): string + { + $datePrefix = now()->format('dmy'); + $clientName = $this->slugify($client->person?->full_name ?? 'stranka'); + + return sprintf('%s_%s-Pogodbe.xlsx', $datePrefix, $clientName); + } + + private function slugify(?string $value): string + { + if (empty($value)) { + return 'data'; + } + + return Str::slug($value, '-') ?: 'data'; + } + public function store(Request $request) { diff --git a/app/Http/Requests/ExportClientContractsRequest.php b/app/Http/Requests/ExportClientContractsRequest.php new file mode 100644 index 0000000..50397c4 --- /dev/null +++ b/app/Http/Requests/ExportClientContractsRequest.php @@ -0,0 +1,43 @@ +user() !== null; + } + + public function rules(): array + { + $columnRule = Rule::in(ClientContractsExport::allowedColumns()); + + return [ + 'scope' => ['required', Rule::in([self::SCOPE_CURRENT, self::SCOPE_ALL])], + 'columns' => ['required', 'array', 'min:1'], + 'columns.*' => ['string', $columnRule], + 'search' => ['nullable', 'string', 'max:255'], + 'from' => ['nullable', 'date'], + 'to' => ['nullable', 'date'], + 'segments' => ['nullable', 'string'], + 'page' => ['nullable', 'integer', 'min:1'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:200'], + ]; + } + + protected function prepareForValidation(): void + { + $this->merge([ + 'per_page' => $this->input('per_page') ?? $this->input('perPage'), + ]); + } +} diff --git a/resources/js/Pages/Client/Contracts.vue b/resources/js/Pages/Client/Contracts.vue index 29e921b..26ff9ca 100644 --- a/resources/js/Pages/Client/Contracts.vue +++ b/resources/js/Pages/Client/Contracts.vue @@ -124,6 +124,128 @@ function formatDate(value) { return value; } } + +function toggleAllColumns(checked) { + exportColumns.value = checked ? exportableColumns.map((col) => col.key) : []; +} + +function openExportDialog() { + exportDialogOpen.value = true; + exportError.value = ""; +} + +function closeExportDialog() { + exportDialogOpen.value = false; +} + +async function submitExport() { + if (exportColumns.value.length === 0) { + exportError.value = "Izberi vsaj en stolpec."; + return; + } + + try { + exportError.value = ""; + isExporting.value = true; + + const payload = { + scope: exportScope.value, + columns: [...exportColumns.value], + from: fromDate.value || "", + to: toDate.value || "", + search: search.value || "", + segments: + selectedSegments.value.length > 0 + ? selectedSegments.value.map((s) => s.id).join(",") + : "", + page: props.contracts.current_page, + per_page: props.contracts.per_page, + }; + + const response = await axios.post( + route("client.contracts.export", { uuid: props.client.uuid }), + payload, + { responseType: "blob" } + ); + + const blob = new Blob([response.data], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + const filename = + extractFilenameFromHeaders(response.headers) || buildDefaultFilename(); + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + + exportDialogOpen.value = false; + } catch (error) { + console.error("Export error:", error); + console.error("Error response:", error.response); + + let errorMessage = "Izvoz je spodletel. Poskusi znova."; + + if (error.response?.status === 404) { + errorMessage = "Pot za izvoz ne obstaja. Prosim kontaktiraj administratorja."; + } else if (error.response?.status === 500) { + errorMessage = "Napaka na strežniku. Poskusi znova."; + } else if (error.response?.data) { + try { + const text = await error.response.data.text(); + const json = JSON.parse(text); + errorMessage = json.message || errorMessage; + } catch (e) { + console.error("Could not parse error response:", e); + } + } + + exportError.value = errorMessage; + } finally { + isExporting.value = false; + } +} + +function slugify(value) { + if (!value) { + return "data"; + } + const slug = value.replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, ""); + return slug || "data"; +} + +function buildDefaultFilename() { + const now = new Date(); + const dd = String(now.getDate()).padStart(2, "0"); + const mm = String(now.getMonth() + 1).padStart(2, "0"); + const yy = String(now.getFullYear()).slice(-2); + const clientName = props.client?.person?.full_name || "stranka"; + return `${dd}${mm}${yy}_${slugify(clientName)}-Pogodbe.xlsx`; +} + +function extractFilenameFromHeaders(headers) { + if (!headers) { + return null; + } + const disposition = + headers["content-disposition"] || headers["Content-Disposition"] || ""; + if (!disposition) { + return null; + } + const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i); + if (utf8Match?.[1]) { + try { + return decodeURIComponent(utf8Match[1]); + } catch (error) { + return utf8Match[1]; + } + } + const asciiMatch = disposition.match(/filename="?([^";]+)"?/i); + return asciiMatch?.[1] || null; +}