12 Commits

16 changed files with 1100 additions and 65 deletions
+160
View File
@@ -0,0 +1,160 @@
<?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\WithCustomValueBinder;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithMapping;
use PhpOffice\PhpSpreadsheet\Cell\Cell;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
use PhpOffice\PhpSpreadsheet\Cell\DefaultValueBinder;
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
class ClientContractsExport extends DefaultValueBinder implements FromQuery, ShouldAutoSize, WithColumnFormatting, WithCustomValueBinder, WithHeadings, WithMapping
{
public const DATE_EXCEL_FORMAT = 'dd"."mm"."yyyy';
public const TEXT_EXCEL_FORMAT = NumberFormat::FORMAT_TEXT;
/**
* @var array<string, string>
*/
private array $columnLetterMap = [];
/**
* @var array<string, array{label: string}>
*/
public const COLUMN_METADATA = [
'reference' => ['label' => 'Referenca'],
'customer' => ['label' => 'Stranka'],
'address' => ['label' => 'Naslov'],
'start' => ['label' => 'Začetek'],
'segment' => ['label' => 'Segment'],
'balance' => ['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->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,
'address' => optional($contract->clientCase?->person?->address)->address,
'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<string, string>
*/
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);
}
}
+2
View File
@@ -34,6 +34,7 @@ class SegmentContractsExport extends DefaultValueBinder implements FromQuery, Sh
public const COLUMN_METADATA = [ public const COLUMN_METADATA = [
'reference' => ['label' => 'Pogodba'], 'reference' => ['label' => 'Pogodba'],
'client_case' => ['label' => 'Primer'], 'client_case' => ['label' => 'Primer'],
'address' => ['label' => 'Naslov'],
'client' => ['label' => 'Stranka'], 'client' => ['label' => 'Stranka'],
'type' => ['label' => 'Vrsta'], 'type' => ['label' => 'Vrsta'],
'start_date' => ['label' => 'Začetek'], 'start_date' => ['label' => 'Začetek'],
@@ -107,6 +108,7 @@ private function resolveValue(Contract $contract, string $column): mixed
return match ($column) { return match ($column) {
'reference' => $contract->reference, 'reference' => $contract->reference,
'client_case' => optional($contract->clientCase?->person)->full_name, 'client_case' => optional($contract->clientCase?->person)->full_name,
'address' => optional($contract->clientCase?->person?->address)->address,
'client' => optional($contract->clientCase?->client?->person)->full_name, 'client' => optional($contract->clientCase?->client?->person)->full_name,
'type' => optional($contract->type)->name, 'type' => optional($contract->type)->name,
'start_date' => $this->formatDate($contract->start_date), 'start_date' => $this->formatDate($contract->start_date),
@@ -1585,6 +1585,156 @@ public function archiveContract(ClientCase $clientCase, string $uuid, Request $r
); );
} }
/**
* Archive multiple contracts in a batch operation
*/
public function archiveBatch(Request $request)
{
$validated = $request->validate([
'contracts' => 'required|array',
'contracts.*' => 'required|uuid|exists:contracts,uuid',
'reactivate' => 'boolean',
]);
$reactivate = $validated['reactivate'] ?? false;
// Get archive setting
$setting = \App\Models\ArchiveSetting::query()
->where('enabled', true)
->whereIn('strategy', ['immediate', 'manual'])
->where('reactivate', $reactivate)
->orderByDesc('id')
->first();
if (! $setting) {
\Log::warning('No archive settings found for batch archive');
return back()->with('flash', [
'error' => 'No archive settings found',
]);
}
$executor = app(\App\Services\Archiving\ArchiveExecutor::class);
$successCount = 0;
$skippedCount = 0;
$errors = [];
foreach ($validated['contracts'] as $contractUuid) {
try {
$contract = Contract::where('uuid', $contractUuid)->firstOrFail();
// Skip if contract is already archived (active = 0)
if (!$contract->active) {
$skippedCount++;
continue;
}
$clientCase = $contract->clientCase;
$context = [
'contract_id' => $contract->id,
'client_case_id' => $clientCase->id,
'account_id' => $contract->account->id ?? null,
];
// Execute archive setting
$executor->executeSetting($setting, $context, \Auth::id());
// Transaction for segment updates and activity logging
\DB::transaction(function () use ($contract, $clientCase, $setting, $reactivate) {
// Create activity log
if ($setting->action_id && $setting->decision_id) {
$activityData = [
'client_case_id' => $clientCase->id,
'action_id' => $setting->action_id,
'decision_id' => $setting->decision_id,
'note' => ($reactivate)
? "Ponovno aktivirana pogodba $contract->reference"
: "Arhivirana pogodba $contract->reference",
];
try {
\App\Models\Activity::create($activityData);
} catch (Exception $e) {
\Log::warning('Activity could not be created during batch archive');
}
}
// Move to archive segment if specified
if ($setting->segment_id) {
$segmentId = $setting->segment_id;
// Deactivate all current segments
$contract->segments()
->allRelatedIds()
->map(fn (int $val) => $contract->segments()->updateExistingPivot($val, [
'active' => false,
'updated_at' => now(),
]));
// Activate archive segment
if ($contract->attachedSegments()->find($segmentId)->pluck('id')->isNotEmpty()) {
$contract->attachedSegments()->updateExistingPivot($segmentId, [
'active' => true,
'updated_at' => now(),
]);
} else {
$contract->segments()->attach($segmentId, [
'active' => true,
'created_at' => now(),
'updated_at' => now(),
]);
}
}
// Cancel pending field jobs
$contract->fieldJobs()
->whereNull('completed_at')
->whereNull('cancelled_at')
->update([
'cancelled_at' => date('Y-m-d'),
'updated_at' => now(),
]);
});
$successCount++;
} catch (Exception $e) {
\Log::error('Error archiving contract in batch', [
'uuid' => $contractUuid,
'error' => $e->getMessage(),
]);
$errors[] = [
'uuid' => $contractUuid,
'error' => $e->getMessage(),
];
}
}
if (count($errors) > 0) {
$message = "Archived $successCount contracts";
if ($skippedCount > 0) {
$message .= ", skipped $skippedCount already archived";
}
$message .= ", " . count($errors) . " failed";
return back()->with('flash', [
'error' => $message,
'details' => $errors,
]);
}
$message = $reactivate
? "Successfully reactivated $successCount contracts"
: "Successfully archived $successCount contracts";
if ($skippedCount > 0) {
$message .= " ($skippedCount already archived)";
}
return back()->with('flash', [
'success' => $message,
]);
}
/** /**
* Emergency: recreate a missing / soft-deleted person for a client case and re-link related data. * Emergency: recreate a missing / soft-deleted person for a client case and re-link related data.
*/ */
+83
View File
@@ -2,10 +2,14 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Exports\ClientContractsExport;
use App\Http\Requests\ExportClientContractsRequest;
use App\Models\Client; use App\Models\Client;
use DB; use DB;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Inertia\Inertia; use Inertia\Inertia;
use Maatwebsite\Excel\Facades\Excel;
class ClientController extends Controller class ClientController extends Controller
{ {
@@ -128,6 +132,7 @@ public function contracts(Client $client, Request $request)
->with([ ->with([
'clientCase:id,uuid,person_id', 'clientCase:id,uuid,person_id',
'clientCase.person:id,full_name', 'clientCase.person:id,full_name',
'clientCase.person.address',
'segments' => function ($q) { 'segments' => function ($q) {
$q->wherePivot('active', true)->select('segments.id', 'segments.name'); $q->wherePivot('active', true)->select('segments.id', 'segments.name');
}, },
@@ -175,6 +180,84 @@ 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',
'clientCase.person.address',
'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) public function store(Request $request)
{ {
+7 -2
View File
@@ -65,6 +65,12 @@ public function show(Segment $segment)
$contracts = $this->hydrateClientShortcut($contracts); $contracts = $this->hydrateClientShortcut($contracts);
// Hide addresses array since we're using the singular address relationship
$contracts->getCollection()->each(function ($contract) {
$contract->clientCase?->person?->makeHidden('addresses');
$contract->clientCase?->client?->person?->makeHidden('addresses');
});
$clients = Client::query() $clients = Client::query()
->whereHas('clientCases.contracts.segments', function ($q) use ($segment) { ->whereHas('clientCases.contracts.segments', function ($q) use ($segment) {
$q->where('segments.id', $segment->id) $q->where('segments.id', $segment->id)
@@ -191,8 +197,7 @@ private function buildContractsQuery(Segment $segment, ?string $search, ?string
->where('contract_segment.active', '=', 1); ->where('contract_segment.active', '=', 1);
}) })
->with([ ->with([
'clientCase.person', 'clientCase.person.address',
'clientCase.client.person',
'type', 'type',
'account', 'account',
]) ])
@@ -0,0 +1,43 @@
<?php
namespace App\Http\Requests;
use App\Exports\ClientContractsExport;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ExportClientContractsRequest 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(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'),
]);
}
}
+2
View File
@@ -6,10 +6,12 @@
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Account extends Model class Account extends Model
{ {
/** @use HasFactory<\Database\Factories\Person/AccountFactory> */ /** @use HasFactory<\Database\Factories\Person/AccountFactory> */
use SoftDeletes;
use HasFactory; use HasFactory;
protected $fillable = [ protected $fillable = [
+9
View File
@@ -46,6 +46,7 @@ class Person extends Model
'group_id', 'group_id',
'type_id', 'type_id',
'user_id', 'user_id',
'employer'
]; ];
protected $hidden = [ protected $hidden = [
@@ -112,6 +113,14 @@ public function addresses(): HasMany
->orderBy('id'); ->orderBy('id');
} }
public function address(): HasOne
{
return $this->hasOne(\App\Models\Person\PersonAddress::class)
->with(['type'])
->where('active', '=', 1)
->oldestOfMany('id');
}
public function emails(): HasMany public function emails(): HasMany
{ {
return $this->hasMany(\App\Models\Email::class, 'person_id') return $this->hasMany(\App\Models\Email::class, 'person_id')
+66 -6
View File
@@ -24,6 +24,7 @@
use App\Models\Person\PersonPhone; use App\Models\Person\PersonPhone;
use App\Models\Person\PersonType; use App\Models\Person\PersonType;
use App\Models\Person\PhoneType; use App\Models\Person\PhoneType;
use Exception;
use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\QueryException; use Illuminate\Database\QueryException;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
@@ -1632,7 +1633,7 @@ private function upsertAccount(Import $import, array $mapped, $mappings, bool $h
$existing = Account::query() $existing = Account::query()
->where('contract_id', $contractId) ->where('contract_id', $contractId)
->where('reference', $reference) //->where('reference', $reference)
->where('active', 1) ->where('active', 1)
->first(); ->first();
@@ -1655,6 +1656,14 @@ private function upsertAccount(Import $import, array $mapped, $mappings, bool $h
$value = $acc[$field] ?? null; $value = $acc[$field] ?? null;
if (in_array($field, ['balance_amount', 'initial_amount'], true) && is_string($value)) { if (in_array($field, ['balance_amount', 'initial_amount'], true) && is_string($value)) {
$value = $this->normalizeDecimal($value); $value = $this->normalizeDecimal($value);
// Ensure the normalized value is numeric, otherwise default to 0
if ($value === '' || $value === '-' || ! is_numeric($value)) {
$value = 0;
}
}
// Convert empty string to 0 for amount fields
if (in_array($field, ['balance_amount', 'initial_amount'], true) && ($value === '' || $value === null)) {
$value = 0;
} }
$mode = $map->apply_mode ?? 'both'; $mode = $map->apply_mode ?? 'both';
if ($mode === 'keyref') { if ($mode === 'keyref') {
@@ -1684,8 +1693,12 @@ private function upsertAccount(Import $import, array $mapped, $mappings, bool $h
if ($existing) { if ($existing) {
// Build non-null changes for account fields // Build non-null changes for account fields
$changes = array_filter($applyUpdate, fn ($v) => ! is_null($v)); $changes = array_filter($applyUpdate, fn ($v) => ! is_null($v));
// Track balance change // Track balance change - normalize in case DB has malformed data
$oldBalance = (float) ($existing->balance_amount ?? 0); $rawBalance = $existing->balance_amount ?? 0;
if (is_string($rawBalance) && $rawBalance !== '') {
$rawBalance = $this->normalizeDecimal($rawBalance);
}
$oldBalance = is_numeric($rawBalance) ? (float) $rawBalance : 0;
// Note: meta merging for contracts is handled in upsertContractChain, not here // Note: meta merging for contracts is handled in upsertContractChain, not here
if (! empty($changes)) { if (! empty($changes)) {
$existing->fill($changes); $existing->fill($changes);
@@ -1694,7 +1707,11 @@ private function upsertAccount(Import $import, array $mapped, $mappings, bool $h
// If balance_amount changed and this wasn't caused by a payment (we are in account upsert), log an activity with before/after // If balance_amount changed and this wasn't caused by a payment (we are in account upsert), log an activity with before/after
if (array_key_exists('balance_amount', $changes)) { if (array_key_exists('balance_amount', $changes)) {
$newBalance = (float) ($existing->balance_amount ?? 0); $rawNewBalance = $existing->balance_amount ?? 0;
if (is_string($rawNewBalance) && $rawNewBalance !== '') {
$rawNewBalance = $this->normalizeDecimal($rawNewBalance);
}
$newBalance = is_numeric($rawNewBalance) ? (float) $rawNewBalance : 0;
if ($newBalance !== $oldBalance) { if ($newBalance !== $oldBalance) {
try { try {
$contractId = $existing->contract_id; $contractId = $existing->contract_id;
@@ -2970,7 +2987,7 @@ private function findOrCreatePersonId(array $p): ?int
// Create person if any fields present; ensure required foreign keys // Create person if any fields present; ensure required foreign keys
if (! empty($p)) { if (! empty($p)) {
$data = []; $data = [];
foreach (['first_name', 'last_name', 'full_name', 'tax_number', 'social_security_number', 'birthday', 'gender', 'description', 'group_id', 'type_id'] as $k) { foreach (['first_name', 'last_name', 'full_name', 'tax_number', 'social_security_number', 'birthday', 'gender', 'description', 'group_id', 'type_id', 'employer'] as $k) {
if (array_key_exists($k, $p)) { if (array_key_exists($k, $p)) {
$data[$k] = $p[$k]; $data[$k] = $p[$k];
} }
@@ -2983,6 +3000,16 @@ private function findOrCreatePersonId(array $p): ?int
$data['full_name'] = trim($fn.' '.$ln); $data['full_name'] = trim($fn.' '.$ln);
} }
} }
// normalise birthday date
if (!empty($data['birthday'])) {
try {
$data['birthday'] = date('Y-m-d', strtotime($data['birthday']));
} catch (Exception $e) {
Log::warning('ImportProcessor::findOrCreatePersonId ' . $e->getMessage());
}
}
// ensure required group/type ids // ensure required group/type ids
$data['group_id'] = $data['group_id'] ?? $this->getDefaultPersonGroupId(); $data['group_id'] = $data['group_id'] ?? $this->getDefaultPersonGroupId();
$data['type_id'] = $data['type_id'] ?? $this->getDefaultPersonTypeId(); $data['type_id'] = $data['type_id'] ?? $this->getDefaultPersonTypeId();
@@ -3159,10 +3186,38 @@ private function upsertAddress(int $personId, array $addrData, $mappings): array
if (! isset($addrData['country']) || $addrData['country'] === null || $addrData['country'] === '') { if (! isset($addrData['country']) || $addrData['country'] === null || $addrData['country'] === '') {
$addrData['country'] = 'SLO'; $addrData['country'] = 'SLO';
} }
if (!empty($addrData['city']) && empty($addrData['post_code'])) {
if (preg_match('/^\d{3,}\s+/',trim($addrData['city']))) {
$cleanStrCity = str($addrData['city'])->squish()->value();
$splitCity = preg_split('/\s/', $cleanStrCity, 2);
if (count($splitCity) >= 2) {
$addrData['post_code'] = $splitCity[0];
$addrData['city'] = $splitCity[1];
}
}
}
// Compare addresses with all spaces removed to handle whitespace variations // Compare addresses with all spaces removed to handle whitespace variations
$addressLineNoSpaces = preg_replace('/\s+/', '', $addressLine); /*$addressLineNoSpaces = preg_replace('/\s+/', '', $addressLine);
$existing = PersonAddress::where('person_id', $personId) $existing = PersonAddress::where('person_id', $personId)
->whereRaw("REPLACE(address, ' ', '') = ?", [$addressLineNoSpaces]) ->whereRaw("REPLACE(address, ' ', '') = ?", [$addressLineNoSpaces])
->first();*/
// Build search query combining address, post_code and city
$searchParts = [$addrData['address']];
if (!empty($addrData['post_code'])) {
$searchParts[] = $addrData['post_code'];
}
if (!empty($addrData['city'])) {
$searchParts[] = $addrData['city'];
}
$searchQuery = implode(' ', $searchParts);
// Use fulltext search (GIN index optimized)
$existing = PersonAddress::query()->where('person_id', $personId)
->whereRaw("search_vector @@ plainto_tsquery('simple', ?)", [$searchQuery])
->first(); ->first();
$applyInsert = []; $applyInsert = [];
@@ -3207,6 +3262,11 @@ private function upsertAddress(int $personId, array $addrData, $mappings): array
$data['person_id'] = $personId; $data['person_id'] = $personId;
$data['country'] = $data['country'] ?? 'SLO'; $data['country'] = $data['country'] ?? 'SLO';
$data['type_id'] = $data['type_id'] ?? $this->getDefaultAddressTypeId(); $data['type_id'] = $data['type_id'] ?? $this->getDefaultAddressTypeId();
if (!empty($addrData['post_code']) && $addrData['post_code'] !== '0' && !isset($applyUpdate['post_code'])) {
$data['post_code'] = $addrData['post_code'];
}
try { try {
$created = PersonAddress::create($data); $created = PersonAddress::create($data);
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('person', function (Blueprint $table){
$table->string('employer', 125)->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('person', function (Blueprint $table){
$table->dropColumn('employer');
});
}
};
@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// Add a generated tsvector column for fulltext search
DB::statement("
ALTER TABLE person_addresses
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
to_tsvector('simple',
coalesce(address, '') || ' ' ||
coalesce(post_code, '') || ' ' ||
coalesce(city, '')
)
) STORED
");
// Create GIN index on the tsvector column for fast fulltext search
DB::statement('CREATE INDEX person_addresses_search_vector_idx ON person_addresses USING GIN(search_vector)');
}
public function down(): void
{
Schema::table('person_addresses', function (Blueprint $table) {
$table->dropIndex('person_addresses_search_vector_idx');
$table->dropColumn('search_vector');
});
}
};
+2 -1
View File
@@ -14,7 +14,7 @@ public function run(): void
'key' => 'person', 'key' => 'person',
'canonical_root' => 'person', 'canonical_root' => 'person',
'label' => 'Person', 'label' => 'Person',
'fields' => ['first_name', 'last_name', 'full_name', 'gender', 'birthday', 'tax_number', 'social_security_number', 'description'], 'fields' => ['first_name', 'last_name', 'full_name', 'gender', 'birthday', 'tax_number', 'social_security_number', 'description', 'employer'],
'field_aliases' => [ 'field_aliases' => [
'dob' => 'birthday', 'dob' => 'birthday',
'date_of_birth' => 'birthday', 'date_of_birth' => 'birthday',
@@ -30,6 +30,7 @@ public function run(): void
['pattern' => '/^(spol|gender)\b/i', 'field' => 'gender'], ['pattern' => '/^(spol|gender)\b/i', 'field' => 'gender'],
['pattern' => '/^(rojstvo|datum\s*rojstva|dob|birth|birthday|date\s*of\s*birth)\b/i', 'field' => 'birthday'], ['pattern' => '/^(rojstvo|datum\s*rojstva|dob|birth|birthday|date\s*of\s*birth)\b/i', 'field' => 'birthday'],
['pattern' => '/^(komentar|opis|opomba|comment|description|note)\b/i', 'field' => 'description'], ['pattern' => '/^(komentar|opis|opomba|comment|description|note)\b/i', 'field' => 'description'],
['pattern' => '/^(delodajalec|služba)\b/i', 'field' => 'employer']
], ],
'ui' => ['order' => 1], 'ui' => ['order' => 1],
], ],
+66 -10
View File
@@ -580,6 +580,19 @@ const openSmsDialog = (phone) => {
// Load contracts for this case (for contract/account placeholders) // Load contracts for this case (for contract/account placeholders)
loadContractsForCase(); loadContractsForCase();
}; };
// Format YYYY-MM-DD (or ISO date) to dd.mm.yyyy
function formatDate(value) {
if (!value) return "-";
try {
const iso = String(value).split("T")[0];
const parts = iso.split("-");
if (parts.length !== 3) return value;
const [y, m, d] = parts;
return `${d.padStart(2, "0")}.${m.padStart(2, "0")}.${y}`;
} catch (e) {
return value;
}
}
const loadContractsForCase = async () => { const loadContractsForCase = async () => {
try { try {
const url = route("clientCase.contracts.list", { client_case: props.clientCaseUuid }); const url = route("clientCase.contracts.list", { client_case: props.clientCaseUuid });
@@ -640,43 +653,86 @@ const submitSms = () => {
</div> </div>
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-2"> <div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-2">
<div class="rounded p-2 shadow"> <div class="rounded p-2 shadow">
<p class="text-xs leading-5 md:text-sm text-gray-500">Nu.</p> <p class="text-xs leading-5 md:text-sm text-gray-500">Primer ref.</p>
<p class="text-sm md:text-base leading-7 text-gray-900">{{ person.nu }}</p> <p class="text-sm md:text-base leading-7 text-gray-900">{{ person.nu }}</p>
</div> </div>
<div class="rounded p-2 shadow"> <div class="rounded p-2 shadow">
<p class="text-sm leading-5 md:text-sm text-gray-500">Name.</p> <p class="text-sm leading-5 md:text-sm text-gray-500">Naziv</p>
<p class="text-sm md:text-base leading-7 text-gray-900"> <p class="text-sm md:text-base leading-7 text-gray-900">
{{ person.full_name }} {{ person.full_name }}
</p> </p>
</div> </div>
<div class="rounded p-2 shadow"> <div class="rounded p-2 shadow">
<p class="text-sm leading-5 md:text-sm text-gray-500">Tax NU.</p> <p class="text-sm leading-5 md:text-sm text-gray-500">Davčna</p>
<p class="text-sm md:text-base leading-7 text-gray-900"> <p class="text-sm md:text-base leading-7 text-gray-900">
{{ person.tax_number }} {{ person.tax_number }}
</p> </p>
</div> </div>
<div class="rounded p-2 shadow"> <div class="rounded p-2 shadow">
<p class="text-sm leading-5 md:text-sm text-gray-500">Social security NU.</p> <p class="text-sm leading-5 md:text-sm text-gray-500">Emšo</p>
<p class="text-sm md:text-base leading-7 text-gray-900"> <p class="text-sm md:text-base leading-7 text-gray-900">
{{ person.social_security_number }} {{ person.social_security_number }}
</p> </p>
</div> </div>
</div> </div>
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2 mt-1"> <div
<div class="rounded p-2 shadow"> v-if="clientCaseUuid"
<p class="text-sm leading-5 md:text-sm text-gray-500">Address</p> class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2 mt-1"
>
<div class="col-span-full lg:col-span-1 rounded p-2 shadow">
<p class="text-sm leading-5 md:text-sm text-gray-500">Naslov</p>
<p class="text-sm md:text-base leading-7 text-gray-900"> <p class="text-sm md:text-base leading-7 text-gray-900">
{{ getMainAddress(person.addresses) }} {{ getMainAddress(person.addresses) }}
</p> </p>
</div> </div>
<div class="rounded p-2 shadow"> <div class="rounded p-2 shadow">
<p class="text-sm leading-5 md:text-sm text-gray-500">Phone</p> <p class="text-sm leading-5 md:text-sm text-gray-500">Telefon</p>
<p class="text-sm md:text-base leading-7 text-gray-900"> <p class="text-sm md:text-base leading-7 text-gray-900">
{{ getMainPhone(person.phones) }} {{ getMainPhone(person.phones) }}
</p> </p>
</div> </div>
<div class="md:col-span-full lg:col-span-1 rounded p-2 shadow"> <div class="rounded p-2 shadow">
<p class="text-sm leading-5 md:text-sm text-gray-500">Description</p> <p class="text-sm leading-5 md:text-sm text-gray-500">Datum rojstva</p>
<p class="text-sm md:text-base leading-7 text-gray-900">
{{ formatDate(person.birthday) }}
</p>
</div>
</div>
<div v-else class="grid grid-rows-* grid-cols-1 md:grid-cols-2 gap-2 mt-1">
<div class="rounded p-2 shadow">
<p class="text-sm leading-5 md:text-sm text-gray-500">Naslov</p>
<p class="text-sm md:text-base leading-7 text-gray-900">
{{ getMainAddress(person.addresses) }}
</p>
</div>
<div class="rounded p-2 shadow">
<p class="text-sm leading-5 md:text-sm text-gray-500">Telefon</p>
<p class="text-sm md:text-base leading-7 text-gray-900">
{{ getMainPhone(person.phones) }}
</p>
</div>
</div>
<div
v-if="clientCaseUuid"
class="grid grid-rows-* grid-cols-1 lg:grid-cols-2 gap-2 mt-1"
>
<div class="rounded p-2 shadow">
<p class="text-sm leading-5 md:text-sm text-gray-500">Delodajalec</p>
<p class="text-sm md:text-base leading-7 text-gray-900">
{{ person.employer }}
</p>
</div>
<div class="rounded p-2 shadow">
<p class="text-sm leading-5 md:text-sm text-gray-500">Opis</p>
<p class="text-sm md:text-base leading-7 text-gray-900">
{{ person.description }}
</p>
</div>
</div>
<div v-else class="grid grid-rows-* grid-cols-1 md:grid-cols-2 gap-2 mt-1">
<div class="col-span-full rounded p-2 shadow">
<p class="text-sm leading-5 md:text-sm text-gray-500">Opis</p>
<p class="text-sm md:text-base leading-7 text-gray-900"> <p class="text-sm md:text-base leading-7 text-gray-900">
{{ person.description }} {{ person.description }}
</p> </p>
+299 -35
View File
@@ -1,7 +1,8 @@
<script setup> <script setup>
import AppLayout from "@/Layouts/AppLayout.vue"; import AppLayout from "@/Layouts/AppLayout.vue";
import { ref } from "vue"; import { ref, computed } from "vue";
import { Link, router, useForm } from "@inertiajs/vue3"; import { Link, router, useForm } from "@inertiajs/vue3";
import axios from "axios";
import DataTableServer from "@/Components/DataTable/DataTableServer.vue"; import DataTableServer from "@/Components/DataTable/DataTableServer.vue";
import PersonInfoGrid from "@/Components/PersonInfoGrid.vue"; import PersonInfoGrid from "@/Components/PersonInfoGrid.vue";
import SectionTitle from "@/Components/SectionTitle.vue"; import SectionTitle from "@/Components/SectionTitle.vue";
@@ -31,6 +32,30 @@ const showSegmentModal = ref(false);
const targetSegmentId = ref(null); const targetSegmentId = ref(null);
const segmentForm = useForm({ segment_id: null, contracts: [] }); const segmentForm = useForm({ segment_id: null, contracts: [] });
const exportDialogOpen = ref(false);
const exportScope = ref("current");
const exportColumns = ref(["reference", "customer", "address", "start", "segment", "balance"]);
const exportError = ref("");
const isExporting = ref(false);
const exportableColumns = [
{ key: "reference", label: "Referenca" },
{ key: "customer", label: "Stranka" },
{ key: "address", label: "Naslov" },
{ key: "start", label: "Začetek" },
{ key: "segment", label: "Segment" },
{ key: "balance", label: "Stanje" },
];
const allColumnsSelected = computed(
() => exportColumns.value.length === exportableColumns.length
);
const exportDisabled = computed(
() => exportColumns.value.length === 0 || isExporting.value
);
const currentPageCount = computed(() => props.contracts?.data?.length ?? 0);
const totalContracts = computed(() => props.contracts?.total ?? 0);
function toggleSelectAll() { function toggleSelectAll() {
if (selectedRows.value.length === props.contracts.data.length) { if (selectedRows.value.length === props.contracts.data.length) {
selectedRows.value = []; selectedRows.value = [];
@@ -149,6 +174,128 @@ function formatDate(value) {
return 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;
}
</script> </script>
<template> <template>
@@ -299,6 +446,7 @@ function formatDate(value) {
{ key: 'select', label: '', sortable: false, width: '50px' }, { key: 'select', label: '', sortable: false, width: '50px' },
{ key: 'reference', label: 'Referenca', sortable: false }, { key: 'reference', label: 'Referenca', sortable: false },
{ key: 'customer', label: 'Stranka', sortable: false }, { key: 'customer', label: 'Stranka', sortable: false },
{ key: 'address', label: 'Naslov', sortable: false },
{ key: 'start', label: 'Začetek', sortable: false }, { key: 'start', label: 'Začetek', sortable: false },
{ key: 'segment', label: 'Segment', sortable: false }, { key: 'segment', label: 'Segment', sortable: false },
{ key: 'balance', label: 'Stanje', sortable: false, align: 'right' }, { key: 'balance', label: 'Stanje', sortable: false, align: 'right' },
@@ -325,41 +473,50 @@ function formatDate(value) {
:only-props="['contracts']" :only-props="['contracts']"
> >
<template #toolbar-extra> <template #toolbar-extra>
<div v-if="selectedRows.length" class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="text-sm text-gray-700"> <button
Izbrano: <span class="font-medium">{{ selectedRows.length }}</span> type="button"
</div> 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"
<Dropdown width="48" align="left"> @click="openExportDialog"
<template #trigger> >
<button Izvozi v Excel
type="button" </button>
class="inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-md border border-gray-300 text-gray-700 bg-white hover:bg-gray-50" <div v-if="selectedRows.length" class="flex items-center gap-2">
> <div class="text-sm text-gray-700">
Akcije Izbrano: <span class="font-medium">{{ selectedRows.length }}</span>
<svg </div>
class="ml-1 h-4 w-4" <Dropdown width="48" align="left">
viewBox="0 0 20 20" <template #trigger>
fill="currentColor" <button
aria-hidden="true" type="button"
class="inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-md border border-gray-300 text-gray-700 bg-white hover:bg-gray-50"
> >
<path Akcije
fill-rule="evenodd" <svg
d="M5.23 7.21a.75.75 0 011.06.02L10 10.94l3.71-3.71a.75.75 0 111.06 1.06l-4.24 4.24a.75.75 0 01-1.06 0L5.21 8.29a.75.75 0 01.02-1.08z" class="ml-1 h-4 w-4"
clip-rule="evenodd" viewBox="0 0 20 20"
/> fill="currentColor"
</svg> aria-hidden="true"
</button> >
</template> <path
<template #content> fill-rule="evenodd"
<button d="M5.23 7.21a.75.75 0 011.06.02L10 10.94l3.71-3.71a.75.75 0 111.06 1.06l-4.24 4.24a.75.75 0 01-1.06 0L5.21 8.29a.75.75 0 01.02-1.08z"
type="button" clip-rule="evenodd"
class="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" />
@click="openSegmentModal" </svg>
> </button>
Preusmeri v segment </template>
</button> <template #content>
</template> <button
</Dropdown> type="button"
class="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
@click="openSegmentModal"
>
Preusmeri v segment
</button>
</template>
</Dropdown>
</div>
</div> </div>
</template> </template>
<template #header-select> <template #header-select>
@@ -390,6 +547,9 @@ function formatDate(value) {
<template #cell-customer="{ row }"> <template #cell-customer="{ row }">
{{ row.client_case?.person?.full_name || "-" }} {{ row.client_case?.person?.full_name || "-" }}
</template> </template>
<template #cell-address="{ row }">
{{ row.client_case?.person?.address?.address || "-" }}
</template>
<template #cell-start="{ row }"> <template #cell-start="{ row }">
{{ formatDate(row.start_date) }} {{ formatDate(row.start_date) }}
</template> </template>
@@ -462,6 +622,110 @@ function formatDate(value) {
</button> </button>
</template> </template>
</DialogModal> </DialogModal>
<!-- Export Dialog Modal -->
<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="contract-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"
/>
Vse pogodbe ({{ 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 exportableColumns"
: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="contract-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>
</div> </div>
<!-- Pagination handled by DataTableServer --> <!-- Pagination handled by DataTableServer -->
</div> </div>
+142 -9
View File
@@ -1,10 +1,11 @@
<script setup> <script setup>
import AppLayout from "@/Layouts/AppLayout.vue"; import AppLayout from "@/Layouts/AppLayout.vue";
import { Link, router } from "@inertiajs/vue3"; import { Link, router, useForm, usePage } from "@inertiajs/vue3";
import { ref, computed, watch } from "vue"; import { ref, computed, watch } from "vue";
import axios from "axios"; import axios from "axios";
import DataTableServer from "@/Components/DataTable/DataTableServer.vue"; import DataTableServer from "@/Components/DataTable/DataTableServer.vue";
import DialogModal from "@/Components/DialogModal.vue"; import DialogModal from "@/Components/DialogModal.vue";
import ConfirmDialog from "@/Components/ConfirmDialog.vue";
const props = defineProps({ const props = defineProps({
segment: Object, segment: Object,
@@ -20,8 +21,10 @@ const selectedClient = ref(initialClient);
// Column definitions for the server-driven table // Column definitions for the server-driven table
const columns = [ const columns = [
{ key: "select", label: "", sortable: false, width: "50px" },
{ key: "reference", label: "Pogodba", sortable: true }, { key: "reference", label: "Pogodba", sortable: true },
{ key: "client_case", label: "Primer" }, { key: "client_case", label: "Primer" },
{ key: "address", label: "Naslov" },
{ key: "client", label: "Stranka" }, { key: "client", label: "Stranka" },
{ key: "type", label: "Vrsta" }, { key: "type", label: "Vrsta" },
{ key: "start_date", label: "Začetek", sortable: true }, { key: "start_date", label: "Začetek", sortable: true },
@@ -35,6 +38,13 @@ const exportColumns = ref(columns.map((col) => col.key));
const exportError = ref(""); const exportError = ref("");
const isExporting = ref(false); const isExporting = ref(false);
const selectedRows = ref([]);
const showConfirmDialog = ref(false);
const archiveForm = useForm({
contracts: [],
reactivate: false,
});
const contractsCurrentPage = computed(() => props.contracts?.current_page ?? 1); const contractsCurrentPage = computed(() => props.contracts?.current_page ?? 1);
const contractsPerPage = computed(() => props.contracts?.per_page ?? 15); const contractsPerPage = computed(() => props.contracts?.per_page ?? 15);
const totalContracts = computed( const totalContracts = computed(
@@ -43,7 +53,14 @@ const totalContracts = computed(
const currentPageCount = computed(() => props.contracts?.data?.length ?? 0); const currentPageCount = computed(() => props.contracts?.data?.length ?? 0);
const allColumnsSelected = computed(() => exportColumns.value.length === columns.length); const allColumnsSelected = computed(() => exportColumns.value.length === columns.length);
const exportDisabled = computed(() => exportColumns.value.length === 0 || isExporting.value); const exportDisabled = computed(
() => exportColumns.value.length === 0 || isExporting.value
);
const canManageSettings = computed(() => {
const permissions = usePage().props?.auth?.user?.permissions || [];
return permissions.includes("mass-archive");
});
function toggleAllColumns(checked) { function toggleAllColumns(checked) {
exportColumns.value = checked ? columns.map((col) => col.key) : []; exportColumns.value = checked ? columns.map((col) => col.key) : [];
@@ -202,6 +219,67 @@ function extractFilenameFromHeaders(headers) {
const asciiMatch = disposition.match(/filename="?([^";]+)"?/i); const asciiMatch = disposition.match(/filename="?([^";]+)"?/i);
return asciiMatch?.[1] || null; return asciiMatch?.[1] || null;
} }
function toggleSelectAll() {
if (selectedRows.value.length === props.contracts.data.length) {
selectedRows.value = [];
} else {
selectedRows.value = props.contracts.data.map((row) => row.uuid);
}
}
function toggleRowSelection(uuid) {
const index = selectedRows.value.indexOf(uuid);
if (index > -1) {
selectedRows.value.splice(index, 1);
} else {
selectedRows.value.push(uuid);
}
}
function isRowSelected(uuid) {
return selectedRows.value.includes(uuid);
}
function isAllSelected() {
return (
props.contracts.data.length > 0 &&
selectedRows.value.length === props.contracts.data.length
);
}
function isIndeterminate() {
return (
selectedRows.value.length > 0 &&
selectedRows.value.length < props.contracts.data.length
);
}
function openArchiveModal() {
if (!selectedRows.value.length) return;
showConfirmDialog.value = true;
}
function closeConfirmDialog() {
showConfirmDialog.value = false;
}
function submitArchive() {
if (!selectedRows.value.length) return;
showConfirmDialog.value = false;
archiveForm.contracts = [...selectedRows.value];
archiveForm.reactivate = false;
archiveForm.post(route("contracts.archive-batch"), {
preserveScroll: true,
onSuccess: () => {
selectedRows.value = [];
router.reload({ only: ["contracts"] });
},
});
}
</script> </script>
<template> <template>
@@ -256,13 +334,51 @@ function extractFilenameFromHeaders(headers) {
row-key="uuid" row-key="uuid"
> >
<template #toolbar-extra> <template #toolbar-extra>
<button <div class="flex items-center gap-2">
type="button" <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" type="button"
@click="openExportDialog" 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> Izvozi v Excel
</button>
<div
v-if="canManageSettings && selectedRows.length"
class="flex items-center gap-2"
>
<span class="text-sm text-gray-600"
>{{ selectedRows.length }} izbran{{
selectedRows.length === 1 ? "a" : "ih"
}}</span
>
<button
type="button"
class="inline-flex items-center rounded-md border border-red-200 bg-white px-3 py-2 text-sm font-medium text-red-700 shadow-sm hover:bg-red-50"
@click="openArchiveModal"
>
Arhiviraj izbrane
</button>
</div>
</div>
</template>
<template #header-select>
<input
v-if="canManageSettings"
type="checkbox"
:checked="isAllSelected()"
:indeterminate="isIndeterminate()"
@change="toggleSelectAll"
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
</template>
<template #cell-select="{ row }">
<input
v-if="canManageSettings"
type="checkbox"
:checked="isRowSelected(row.uuid)"
@change="toggleRowSelection(row.uuid)"
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
</template> </template>
<!-- Primer (client_case) cell with link when available --> <!-- Primer (client_case) cell with link when available -->
<template #cell-client_case="{ row }"> <template #cell-client_case="{ row }">
@@ -281,6 +397,10 @@ function extractFilenameFromHeaders(headers) {
<span v-else>{{ row.client_case?.person?.full_name || "-" }}</span> <span v-else>{{ row.client_case?.person?.full_name || "-" }}</span>
</template> </template>
<!-- Client case address -->
<template #cell-address="{ row }">
{{ row.client_case?.person?.address?.address || "-" }}
</template>
<!-- Stranka (client) name --> <!-- Stranka (client) name -->
<template #cell-client="{ row }"> <template #cell-client="{ row }">
{{ row.client?.person?.full_name || "-" }} {{ row.client?.person?.full_name || "-" }}
@@ -309,6 +429,19 @@ function extractFilenameFromHeaders(headers) {
</div> </div>
</div> </div>
<ConfirmDialog
:show="showConfirmDialog"
title="Arhiviraj pogodbe"
:message="`Ali ste prepričani, da želite arhivirati ${selectedRows.length} pogodb${
selectedRows.length === 1 ? 'o' : ''
}? Arhivirane pogodbe bodo odstranjene iz aktivnih segmentov.`"
confirm-text="Arhiviraj"
cancel-text="Prekliči"
:danger="true"
@close="closeConfirmDialog"
@confirm="submitArchive"
/>
<DialogModal :show="exportDialogOpen" max-width="3xl" @close="closeExportDialog"> <DialogModal :show="exportDialogOpen" max-width="3xl" @close="closeExportDialog">
<template #title> <template #title>
<div> <div>
+2
View File
@@ -308,6 +308,7 @@
Route::get('clients', [ClientController::class, 'index'])->name('client'); Route::get('clients', [ClientController::class, 'index'])->name('client');
Route::get('clients/{client:uuid}', [ClientController::class, 'show'])->name('client.show'); Route::get('clients/{client:uuid}', [ClientController::class, 'show'])->name('client.show');
Route::get('clients/{client:uuid}/contracts', [ClientController::class, 'contracts'])->name('client.contracts'); Route::get('clients/{client:uuid}/contracts', [ClientController::class, 'contracts'])->name('client.contracts');
Route::post('clients/{client:uuid}/contracts/export', [ClientController::class, 'exportContracts'])->name('client.contracts.export');
Route::middleware('permission:client-edit')->group(function () { Route::middleware('permission:client-edit')->group(function () {
Route::post('clients', [ClientController::class, 'store'])->name('client.store'); Route::post('clients', [ClientController::class, 'store'])->name('client.store');
@@ -321,6 +322,7 @@
Route::get('client-cases/{client_case:uuid}', [ClientCaseContoller::class, 'show'])->name('clientCase.show'); Route::get('client-cases/{client_case:uuid}', [ClientCaseContoller::class, 'show'])->name('clientCase.show');
Route::post('client-cases/{client_case:uuid}/contracts/{uuid}/segment', [ClientCaseContoller::class, 'updateContractSegment'])->name('clientCase.contract.updateSegment'); Route::post('client-cases/{client_case:uuid}/contracts/{uuid}/segment', [ClientCaseContoller::class, 'updateContractSegment'])->name('clientCase.contract.updateSegment');
Route::post('client-cases/{client_case:uuid}/contracts/{uuid}/archive', [ClientCaseContoller::class, 'archiveContract'])->name('clientCase.contract.archive'); Route::post('client-cases/{client_case:uuid}/contracts/{uuid}/archive', [ClientCaseContoller::class, 'archiveContract'])->name('clientCase.contract.archive');
Route::post('contracts/archive-batch', [ClientCaseContoller::class, 'archiveBatch'])->name('contracts.archive-batch')->middleware('permission:mass-archive');
Route::post('client-cases', [ClientCaseContoller::class, 'store'])->name('clientCase.store'); Route::post('client-cases', [ClientCaseContoller::class, 'store'])->name('clientCase.store');
Route::post('client-cases/{client_case:uuid}/emergency-person', [ClientCaseContoller::class, 'emergencyCreatePerson'])->name('clientCase.emergencyPerson'); Route::post('client-cases/{client_case:uuid}/emergency-person', [ClientCaseContoller::class, 'emergencyCreatePerson'])->name('clientCase.emergencyPerson');
// client-case / contract // client-case / contract