From d2287ef9638c7cd035749c0a317fe3705d88549f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Pocrnji=C4=8D?= Date: Sun, 7 Dec 2025 09:45:19 +0100 Subject: [PATCH 1/2] Changed SMS package so it allows separate SMS for each person contract. --- app/Http/Controllers/Admin/PackageController.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/Http/Controllers/Admin/PackageController.php b/app/Http/Controllers/Admin/PackageController.php index 367c456..834609e 100644 --- a/app/Http/Controllers/Admin/PackageController.php +++ b/app/Http/Controllers/Admin/PackageController.php @@ -223,6 +223,8 @@ public function store(StorePackageRequest $request): RedirectResponse 'created_by' => optional($request->user())->id, ]); + dd($data['items']); + $items = collect($data['items']) ->map(function (array $row) { return new PackageItem([ @@ -457,12 +459,12 @@ public function storeFromContracts(StorePackageFromContractsRequest $request, Ph continue; } $key = $phone->id ? 'id:'.$phone->id : 'num:'.$phone->nu; - if ($seen->contains($key)) { + /*if ($seen->contains($key)) { // skip duplicates across multiple contracts/persons $skipped++; continue; - } + }*/ $seen->push($key); $items[] = [ 'number' => (string) $phone->nu, From fa54cf48f3a920d4f5d1a378a54fec94ed482874 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Pocrnji=C4=8D?= Date: Wed, 10 Dec 2025 20:41:10 +0100 Subject: [PATCH 2/2] Segment view contract export option --- app/Exports/SegmentContractsExport.php | 125 ++++++++++ app/Http/Controllers/SegmentController.php | 187 ++++++++++---- .../ExportSegmentContractsRequest.php | 42 ++++ app/Models/Person/Person.php | 2 +- resources/js/Pages/Segments/Show.vue | 229 ++++++++++++++++++ resources/views/app.blade.php | 1 + routes/web.php | 7 +- tests/Feature/SegmentExportTest.php | 208 ++++++++++++++++ 8 files changed, 744 insertions(+), 57 deletions(-) create mode 100644 app/Exports/SegmentContractsExport.php create mode 100644 app/Http/Requests/ExportSegmentContractsRequest.php create mode 100644 tests/Feature/SegmentExportTest.php diff --git a/app/Exports/SegmentContractsExport.php b/app/Exports/SegmentContractsExport.php new file mode 100644 index 0000000..5d10234 --- /dev/null +++ b/app/Exports/SegmentContractsExport.php @@ -0,0 +1,125 @@ + + */ + public const COLUMN_METADATA = [ + 'reference' => ['label' => 'Pogodba'], + 'client_case' => ['label' => 'Primer'], + 'client' => ['label' => 'Stranka'], + 'type' => ['label' => 'Vrsta'], + 'start_date' => ['label' => 'Začetek'], + 'end_date' => ['label' => 'Konec'], + 'account' => ['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->columns as $index => $column) { + if (in_array($column, ['start_date', 'end_date'], true)) { + $formats[$this->columnLetter($index)] = self::DATE_EXCEL_FORMAT; + } + } + + return $formats; + } + + private function resolveValue(Contract $contract, string $column): mixed + { + return match ($column) { + 'reference' => $contract->reference, + 'client_case' => optional($contract->clientCase?->person)->full_name, + 'client' => optional($contract->clientCase?->client?->person)->full_name, + 'type' => optional($contract->type)->name, + 'start_date' => $this->formatDate($contract->start_date), + 'end_date' => $this->formatDate($contract->end_date), + 'account' => optional($contract->account)->balance_amount, + default => null, + }; + } + + private function formatDate(mixed $value): ?float + { + $carbon = Carbon::make($value); + + if (! $carbon) { + return null; + } + + return ExcelDate::dateTimeToExcel($carbon->copy()->startOfDay()); + } + + private function columnLetter(int $index): string + { + $index++; + $letter = ''; + + while ($index > 0) { + $remainder = ($index - 1) % 26; + $letter = chr(65 + $remainder).$letter; + $index = intdiv($index - 1, 26); + } + + return $letter; + } +} diff --git a/app/Http/Controllers/SegmentController.php b/app/Http/Controllers/SegmentController.php index d02fef5..5fcc1b5 100644 --- a/app/Http/Controllers/SegmentController.php +++ b/app/Http/Controllers/SegmentController.php @@ -2,12 +2,20 @@ namespace App\Http\Controllers; +use App\Exports\SegmentContractsExport; +use App\Http\Requests\ExportSegmentContractsRequest; use App\Http\Requests\StoreSegmentRequest; use App\Http\Requests\UpdateSegmentRequest; +use App\Models\Client; +use App\Models\Contract; use App\Models\Segment; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; +use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Str; use Inertia\Inertia; +use Maatwebsite\Excel\Facades\Excel; class SegmentController extends Controller { @@ -44,64 +52,20 @@ public function index() ]); } - public function show(\App\Models\Segment $segment) + public function show(Segment $segment) { - // Retrieve contracts that are active in this segment, eager-loading required relations $search = request('search'); - $clientFilter = request('client') ?? request('client_id'); // support either ?client= or ?client_id= - $contractsQuery = \App\Models\Contract::query() - ->whereHas('segments', function ($q) use ($segment) { - $q->where('segments.id', $segment->id) - ->where('contract_segment.active', '=', 1); - }) - ->with([ - 'clientCase.person', - 'clientCase.client.person', - 'type', - 'account', - ]) - ->latest('id'); + $clientFilter = request('client') ?? request('client_id'); + $perPage = request()->integer('perPage', request()->integer('per_page', 15)); + $perPage = max(1, min(200, $perPage)); - // Optional filter by client (accepts numeric id or client uuid) - if (! empty($clientFilter)) { - $contractsQuery->whereHas('clientCase.client', function ($q) use ($clientFilter) { - if (is_numeric($clientFilter)) { - $q->where('clients.id', (int) $clientFilter); - } else { - $q->where('clients.uuid', $clientFilter); - } - }); - } - - if (! empty($search)) { - $contractsQuery->where(function ($qq) use ($search) { - $qq->where('contracts.reference', 'ilike', '%'.$search.'%') - ->orWhereHas('clientCase.person', function ($p) use ($search) { - $p->where('full_name', 'ilike', '%'.$search.'%'); - }) - ->orWhereHas('clientCase.client.person', function ($p) use ($search) { - $p->where('full_name', 'ilike', '%'.$search.'%'); - }); - }); - } - - $contracts = $contractsQuery - ->paginate(15) + $contracts = $this->buildContractsQuery($segment, $search, $clientFilter) + ->paginate($perPage) ->withQueryString(); - // Mirror client onto the contract to simplify frontend access (c.client.person.full_name) - $items = collect($contracts->items()); - $items->each(function ($contract) { - if ($contract->relationLoaded('clientCase') && $contract->clientCase) { - $contract->setRelation('client', $contract->clientCase->client); - } - }); - if (method_exists($contracts, 'setCollection')) { - $contracts->setCollection($items); - } + $contracts = $this->hydrateClientShortcut($contracts); - // Build a full client list for this segment (not limited to current page) for the dropdown - $clients = \App\Models\Client::query() + $clients = Client::query() ->whereHas('clientCases.contracts.segments', function ($q) use ($segment) { $q->where('segments.id', $segment->id) ->where('contract_segment.active', '=', 1); @@ -124,6 +88,69 @@ public function show(\App\Models\Segment $segment) ]); } + public function export(ExportSegmentContractsRequest $request, Segment $segment) + { + $data = $request->validated(); + $client = $this->resolveClient($data['client'] ?? null); + $columns = array_values(array_unique($data['columns'])); + $query = $this->buildContractsQuery( + $segment, + $data['search'] ?? null, + $data['client'] ?? null + ); + + if (($data['scope'] ?? ExportSegmentContractsRequest::SCOPE_ALL) === ExportSegmentContractsRequest::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($segment, $client); + + return Excel::download(new SegmentContractsExport($query, $columns), $filename); + } + + private function resolveClient(?string $identifier): ?Client + { + if (empty($identifier)) { + return null; + } + + $query = Client::query()->with(['person:id,full_name']); + + if (Str::isUuid($identifier)) { + $query->where('uuid', $identifier); + } elseif (is_numeric($identifier)) { + $query->where('id', (int) $identifier); + } else { + $query->where('uuid', $identifier); + } + + return $query->first(); + } + + private function buildExportFilename(Segment $segment, ?Client $client): string + { + $datePrefix = now()->format('dmy'); + $segmentName = $this->slugify($segment->name ?? 'segment'); + $base = sprintf('%s_%s-Pogodbe', $datePrefix, $segmentName); + + if ($client && $client->person?->full_name) { + $clientName = $this->slugify($client->person->full_name); + + return sprintf('%s_%s.xlsx', $base, $clientName); + } + + return sprintf('%s.xlsx', $base); + } + + private function slugify(string $value): string + { + $slug = trim(preg_replace('/[^a-zA-Z0-9]+/', '-', $value), '-'); + + return $slug !== '' ? $slug : 'data'; + } + public function settings(Request $request) { return Inertia::render('Settings/Segments/Index', [ @@ -155,4 +182,60 @@ public function update(UpdateSegmentRequest $request, Segment $segment) return to_route('settings.segments')->with('success', 'Segment updated'); } + + private function buildContractsQuery(Segment $segment, ?string $search, ?string $clientFilter): Builder + { + $query = Contract::query() + ->whereHas('segments', function ($q) use ($segment) { + $q->where('segments.id', $segment->id) + ->where('contract_segment.active', '=', 1); + }) + ->with([ + 'clientCase.person', + 'clientCase.client.person', + 'type', + 'account', + ]) + ->latest('id'); + + if (! empty($clientFilter)) { + $query->whereHas('clientCase.client', function ($q) use ($clientFilter) { + if (is_numeric($clientFilter)) { + $q->where('clients.id', (int) $clientFilter); + } else { + $q->where('clients.uuid', $clientFilter); + } + }); + } + + if (! empty($search)) { + $query->where(function ($qq) use ($search) { + $qq->where('contracts.reference', 'ilike', '%'.$search.'%') + ->orWhereHas('clientCase.person', function ($p) use ($search) { + $p->where('full_name', 'ilike', '%'.$search.'%'); + }) + ->orWhereHas('clientCase.client.person', function ($p) use ($search) { + $p->where('full_name', 'ilike', '%'.$search.'%'); + }); + }); + } + + return $query; + } + + private function hydrateClientShortcut(LengthAwarePaginator $contracts): LengthAwarePaginator + { + $items = collect($contracts->items()); + $items->each(function (Contract $contract) { + if ($contract->relationLoaded('clientCase') && $contract->clientCase) { + $contract->setRelation('client', $contract->clientCase->client); + } + }); + + if (method_exists($contracts, 'setCollection')) { + $contracts->setCollection($items); + } + + return $contracts; + } } diff --git a/app/Http/Requests/ExportSegmentContractsRequest.php b/app/Http/Requests/ExportSegmentContractsRequest.php new file mode 100644 index 0000000..5ab818e --- /dev/null +++ b/app/Http/Requests/ExportSegmentContractsRequest.php @@ -0,0 +1,42 @@ +user() !== null; + } + + public function rules(): array + { + $columnRule = Rule::in(SegmentContractsExport::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'], + 'client' => ['nullable', 'string', 'max:64'], + 'page' => ['nullable', 'integer', 'min:1'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:200'], + ]; + } + + protected function prepareForValidation(): void + { + $this->merge([ + 'client' => $this->input('client') ?? $this->input('client_id'), + 'per_page' => $this->input('per_page') ?? $this->input('perPage'), + ]); + } +} diff --git a/app/Models/Person/Person.php b/app/Models/Person/Person.php index 2633526..f3fe192 100644 --- a/app/Models/Person/Person.php +++ b/app/Models/Person/Person.php @@ -190,7 +190,7 @@ protected static function joinNameParts(?string $first, ?string $second): ?strin protected static function normalizeSegment(?string $value): ?string { - if (blank($value)) { + if (empty($value)) { return null; } diff --git a/resources/js/Pages/Segments/Show.vue b/resources/js/Pages/Segments/Show.vue index 96ad401..70ab382 100644 --- a/resources/js/Pages/Segments/Show.vue +++ b/resources/js/Pages/Segments/Show.vue @@ -2,7 +2,9 @@ import AppLayout from "@/Layouts/AppLayout.vue"; import { Link, router } from "@inertiajs/vue3"; import { ref, computed, watch } from "vue"; +import axios from "axios"; import DataTableServer from "@/Components/DataTable/DataTableServer.vue"; +import DialogModal from "@/Components/DialogModal.vue"; const props = defineProps({ segment: Object, @@ -27,6 +29,82 @@ const columns = [ { key: "account", label: "Stanje", align: "right" }, ]; +const exportDialogOpen = ref(false); +const exportScope = ref("current"); +const exportColumns = ref(columns.map((col) => col.key)); +const exportError = ref(""); +const isExporting = ref(false); + +const contractsCurrentPage = computed(() => props.contracts?.current_page ?? 1); +const contractsPerPage = computed(() => props.contracts?.per_page ?? 15); +const totalContracts = computed( + () => props.contracts?.total ?? props.contracts?.data?.length ?? 0 +); +const currentPageCount = computed(() => props.contracts?.data?.length ?? 0); + +const allColumnsSelected = computed(() => exportColumns.value.length === columns.length); +const exportDisabled = computed(() => exportColumns.value.length === 0 || isExporting.value); + +function toggleAllColumns(checked) { + exportColumns.value = checked ? columns.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], + search: search.value || "", + client: selectedClient.value || "", + page: contractsCurrentPage.value, + per_page: contractsPerPage.value, + }; + + const response = await axios.post( + route("segments.export", { segment: props.segment?.id ?? props.segment }), + 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) { + exportError.value = "Izvoz je spodletel. Poskusi znova."; + } finally { + isExporting.value = false; + } +} + // Build client options from the full list provided by the server, so the dropdown isn't limited by current filters const clientOptions = computed(() => { const list = Array.isArray(props.clients) ? props.clients : []; @@ -37,6 +115,17 @@ const clientOptions = computed(() => { return opts.sort((a, b) => (a.label || "").localeCompare(b.label || "")); }); +const selectedClientName = computed(() => { + if (!selectedClient.value) { + return ""; + } + + const options = clientOptions.value || []; + const match = options.find((opt) => opt.value === selectedClient.value); + + return match?.label || ""; +}); + // React to client selection changes by visiting the same route with updated query watch(selectedClient, (val) => { const query = { search: search.value }; @@ -71,6 +160,48 @@ function formatCurrency(value) { " €" ); } + +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); + let base = `${dd}${mm}${yy}_${slugify(props.segment?.name || "segment")}-Pogodbe`; + const clientName = selectedClientName.value; + if (clientName) { + base += `_${slugify(clientName)}`; + } + return `${base}.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; +}