production #1
125
app/Exports/SegmentContractsExport.php
Normal file
125
app/Exports/SegmentContractsExport.php
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
<?php
|
||||
|
||||
namespace App\Exports;
|
||||
|
||||
use App\Models\Contract;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Maatwebsite\Excel\Concerns\FromQuery;
|
||||
use Maatwebsite\Excel\Concerns\ShouldAutoSize;
|
||||
use Maatwebsite\Excel\Concerns\WithColumnFormatting;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
use Maatwebsite\Excel\Concerns\WithMapping;
|
||||
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
|
||||
|
||||
class SegmentContractsExport implements FromQuery, ShouldAutoSize, WithColumnFormatting, WithHeadings, WithMapping
|
||||
{
|
||||
public const DATE_EXCEL_FORMAT = 'dd"."mm"."yyyy';
|
||||
|
||||
/**
|
||||
* @var array<string, array{label: string}>
|
||||
*/
|
||||
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<int, string> $columns
|
||||
*/
|
||||
public function __construct(private Builder $query, private array $columns) {}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
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<int, mixed>
|
||||
*/
|
||||
public function map($row): array
|
||||
{
|
||||
return array_map(fn (string $column) => $this->resolveValue($row, $column), $this->columns);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function headings(): array
|
||||
{
|
||||
return array_map(fn (string $column) => self::columnLabel($column), $this->columns);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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([
|
||||
|
|
@ -471,12 +473,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,
|
||||
|
|
|
|||
|
|
@ -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=<uuid|id> or ?client_id=<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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
42
app/Http/Requests/ExportSegmentContractsRequest.php
Normal file
42
app/Http/Requests/ExportSegmentContractsRequest.php
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Exports\SegmentContractsExport;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ExportSegmentContractsRequest extends FormRequest
|
||||
{
|
||||
public const SCOPE_CURRENT = 'current';
|
||||
|
||||
public const SCOPE_ALL = 'all';
|
||||
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -124,6 +255,15 @@ function formatCurrency(value) {
|
|||
empty-text="Ni pogodb v tem segmentu."
|
||||
row-key="uuid"
|
||||
>
|
||||
<template #toolbar-extra>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center rounded-md border border-indigo-200 bg-white px-3 py-2 text-sm font-medium text-indigo-700 shadow-sm hover:bg-indigo-50"
|
||||
@click="openExportDialog"
|
||||
>
|
||||
Izvozi v Excel
|
||||
</button>
|
||||
</template>
|
||||
<!-- Primer (client_case) cell with link when available -->
|
||||
<template #cell-client_case="{ row }">
|
||||
<Link
|
||||
|
|
@ -168,5 +308,94 @@ function formatCurrency(value) {
|
|||
</DataTableServer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogModal :show="exportDialogOpen" max-width="3xl" @close="closeExportDialog">
|
||||
<template #title>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold">Izvoz v Excel</h3>
|
||||
<p class="text-sm text-gray-500">Izberi stolpce in obseg podatkov za izvoz.</p>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<form id="segment-export-form" class="space-y-6" @submit.prevent="submitExport">
|
||||
<div>
|
||||
<span class="text-sm font-semibold text-gray-700">Obseg podatkov</span>
|
||||
<div class="mt-2 space-y-2">
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input
|
||||
type="radio"
|
||||
name="scope"
|
||||
value="current"
|
||||
class="text-indigo-600"
|
||||
v-model="exportScope"
|
||||
/>
|
||||
Trenutna stran ({{ currentPageCount }} zapisov)
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input
|
||||
type="radio"
|
||||
name="scope"
|
||||
value="all"
|
||||
class="text-indigo-600"
|
||||
v-model="exportScope"
|
||||
/>
|
||||
Celoten segment ({{ totalContracts }} zapisov)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-semibold text-gray-700">Stolpci</span>
|
||||
<label class="flex items-center gap-2 text-xs text-gray-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="allColumnsSelected"
|
||||
@change="toggleAllColumns($event.target.checked)"
|
||||
/>
|
||||
Označi vse
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<label
|
||||
v-for="col in columns"
|
||||
:key="col.key"
|
||||
class="flex items-center gap-2 rounded border border-gray-200 px-3 py-2 text-sm"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="columns[]"
|
||||
:value="col.key"
|
||||
v-model="exportColumns"
|
||||
class="text-indigo-600"
|
||||
/>
|
||||
{{ col.label }}
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="exportError" class="mt-2 text-sm text-red-600">{{ exportError }}</p>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex flex-row gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm text-gray-600 hover:text-gray-900"
|
||||
@click="closeExportDialog"
|
||||
>
|
||||
Prekliči
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
form="segment-export-form"
|
||||
class="inline-flex items-center rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:disabled="exportDisabled"
|
||||
>
|
||||
<span v-if="!isExporting">Prenesi Excel</span>
|
||||
<span v-else>Pripravljam ...</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</DialogModal>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
|
||||
<title inertia>{{ config('app.name', 'Laravel') }}</title>
|
||||
|
||||
|
|
|
|||
|
|
@ -168,8 +168,6 @@
|
|||
Route::get('packages-contracts', [\App\Http\Controllers\Admin\PackageController::class, 'contracts'])->name('packages.contracts');
|
||||
Route::post('packages-from-contracts', [\App\Http\Controllers\Admin\PackageController::class, 'storeFromContracts'])->name('packages.store-from-contracts');
|
||||
|
||||
|
||||
|
||||
});
|
||||
|
||||
// Contract document generation (JSON) - protected by auth+verified; permission enforced inside controller service
|
||||
|
|
@ -409,6 +407,7 @@
|
|||
// segments index overview
|
||||
Route::get('segments', [SegmentController::class, 'index'])->name('segments.index');
|
||||
Route::get('segments/{segment}', [SegmentController::class, 'show'])->name('segments.show');
|
||||
Route::post('segments/{segment}/export', [SegmentController::class, 'export'])->name('segments.export');
|
||||
|
||||
Route::middleware('permission:manage-imports')->group(function () {
|
||||
// imports
|
||||
|
|
|
|||
208
tests/Feature/SegmentExportTest.php
Normal file
208
tests/Feature/SegmentExportTest.php
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Exports\SegmentContractsExport;
|
||||
use App\Http\Controllers\SegmentController;
|
||||
use App\Http\Requests\ExportSegmentContractsRequest;
|
||||
use App\Models\Client;
|
||||
use App\Models\Contract;
|
||||
use App\Models\Person\Person as PersonModel;
|
||||
use App\Models\Segment;
|
||||
use App\Models\User;
|
||||
use Illuminate\Routing\Redirector;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
class SegmentExportTest extends \Tests\TestCase
|
||||
{
|
||||
public function test_exports_only_current_page_when_requested(): void
|
||||
{
|
||||
Excel::fake();
|
||||
Carbon::setTestNow('2025-12-10 12:00:00');
|
||||
|
||||
$segment = Segment::factory()->create();
|
||||
$contracts = Contract::factory()->count(2)->create();
|
||||
|
||||
foreach ($contracts as $contract) {
|
||||
DB::table('contract_segment')->insert([
|
||||
'contract_id' => $contract->id,
|
||||
'segment_id' => $segment->id,
|
||||
'active' => true,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
$user = User::factory()->create();
|
||||
$request = $this->makeExportRequest([
|
||||
'scope' => 'current',
|
||||
'columns' => ['reference', 'client'],
|
||||
'page' => 1,
|
||||
'per_page' => 1,
|
||||
], $user);
|
||||
|
||||
$response = app(SegmentController::class)->export($request, $segment);
|
||||
|
||||
$this->assertInstanceOf(BinaryFileResponse::class, $response);
|
||||
|
||||
$expectedName = $this->expectedFilename($segment);
|
||||
|
||||
Excel::assertDownloaded($expectedName, function (SegmentContractsExport $export) {
|
||||
$this->assertSame(1, $export->query()->get()->count());
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
public function test_exports_full_segment_when_scope_all(): void
|
||||
{
|
||||
Excel::fake();
|
||||
Carbon::setTestNow('2025-12-10 12:05:00');
|
||||
|
||||
$segment = Segment::factory()->create();
|
||||
$contracts = Contract::factory()->count(3)->create();
|
||||
|
||||
foreach ($contracts as $contract) {
|
||||
DB::table('contract_segment')->insert([
|
||||
'contract_id' => $contract->id,
|
||||
'segment_id' => $segment->id,
|
||||
'active' => true,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
$user = User::factory()->create();
|
||||
$request = $this->makeExportRequest([
|
||||
'scope' => 'all',
|
||||
'columns' => SegmentContractsExport::allowedColumns(),
|
||||
], $user);
|
||||
|
||||
$response = app(SegmentController::class)->export($request, $segment);
|
||||
|
||||
$this->assertInstanceOf(BinaryFileResponse::class, $response);
|
||||
|
||||
$request = $this->makeExportRequest([
|
||||
'scope' => 'all',
|
||||
'columns' => SegmentContractsExport::allowedColumns(),
|
||||
], $user);
|
||||
|
||||
$expectedName = $this->expectedFilename($segment);
|
||||
|
||||
Excel::assertDownloaded($expectedName, function (SegmentContractsExport $export) use ($contracts) {
|
||||
$this->assertSame($contracts->count(), $export->query()->get()->count());
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
public function test_export_filename_includes_client_name_when_filtered(): void
|
||||
{
|
||||
Excel::fake();
|
||||
Carbon::setTestNow('2025-12-10 12:10:00');
|
||||
|
||||
$segment = Segment::factory()->create(['name' => 'VIP Segment']);
|
||||
$contract = Contract::factory()->create();
|
||||
|
||||
DB::table('contract_segment')->insert([
|
||||
'contract_id' => $contract->id,
|
||||
'segment_id' => $segment->id,
|
||||
'active' => true,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$client = Client::factory()
|
||||
->for(PersonModel::factory(['full_name' => 'Ana Novak']), 'person')
|
||||
->create();
|
||||
|
||||
$contract->clientCase?->update(['client_id' => $client->id]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$request = $this->makeExportRequest([
|
||||
'scope' => 'all',
|
||||
'columns' => ['reference'],
|
||||
'client' => (string) $client->uuid,
|
||||
], $user);
|
||||
|
||||
$expectedName = $this->expectedFilename($segment, 'Ana Novak');
|
||||
|
||||
$response = app(SegmentController::class)->export($request, $segment);
|
||||
|
||||
$this->assertInstanceOf(BinaryFileResponse::class, $response);
|
||||
|
||||
Excel::assertDownloaded($expectedName, function (SegmentContractsExport $export) {
|
||||
$this->assertSame(1, $export->query()->get()->count());
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
public function test_column_formats_apply_to_date_columns(): void
|
||||
{
|
||||
$export = new SegmentContractsExport(
|
||||
Contract::query(),
|
||||
['reference', 'start_date', 'client', 'end_date']
|
||||
);
|
||||
|
||||
$this->assertSame(
|
||||
[
|
||||
'B' => SegmentContractsExport::DATE_EXCEL_FORMAT,
|
||||
'D' => SegmentContractsExport::DATE_EXCEL_FORMAT,
|
||||
],
|
||||
$export->columnFormats()
|
||||
);
|
||||
}
|
||||
|
||||
public function test_date_values_are_converted_to_excel_serial_numbers(): void
|
||||
{
|
||||
$contract = Contract::factory()->make([
|
||||
'start_date' => '2025-10-30',
|
||||
]);
|
||||
|
||||
$export = new SegmentContractsExport(
|
||||
Contract::query(),
|
||||
['start_date']
|
||||
);
|
||||
|
||||
$row = $export->map($contract);
|
||||
|
||||
$this->assertIsFloat($row[0]);
|
||||
$this->assertGreaterThan(40000, $row[0]);
|
||||
}
|
||||
|
||||
private function makeExportRequest(array $payload, User $user): ExportSegmentContractsRequest
|
||||
{
|
||||
$request = ExportSegmentContractsRequest::create('/segments/export', 'POST', $payload);
|
||||
$request->setContainer($this->app);
|
||||
$request->setRedirector($this->app->make(Redirector::class));
|
||||
$request->setUserResolver(fn () => $user);
|
||||
$request->setRouteResolver(fn () => (object) [
|
||||
'parameter' => fn () => null,
|
||||
]);
|
||||
$request->validateResolved();
|
||||
|
||||
return $request;
|
||||
}
|
||||
|
||||
private function expectedFilename(Segment $segment, ?string $clientName = null): string
|
||||
{
|
||||
$base = now()->format('dmy').'_'.$this->slugify($segment->name).'-Pogodbe';
|
||||
|
||||
if ($clientName) {
|
||||
return sprintf('%s_%s.xlsx', $base, $this->slugify($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';
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user