Compare commits
27 Commits
85922bdac0
...
previous
| Author | SHA1 | Date | |
|---|---|---|---|
| 229c100cc4 | |||
| 9a4897bf0c | |||
| b2a9350d0f | |||
| 27bdb942ab | |||
| 7eaab16e30 | |||
| 6a2dd860fa | |||
| ca8754cd94 | |||
| 8fdc0d6359 | |||
| 7fc4520dbf | |||
| dc41862afc | |||
| fb6474ab88 | |||
| 2ad24216ae | |||
| 8031501d25 | |||
| adc2a64687 | |||
| 11206fb4f7 | |||
| 39a597f6eb | |||
| 5d4498ac5a | |||
| 622f53e401 | |||
| 96473fd60b | |||
| 5ddca35389 | |||
| 94ad0c0772 | |||
| 2140181a76 | |||
| 06fa443b3e | |||
| 6c45063e47 | |||
| b8c9b51f29 | |||
| a4db37adfa | |||
| 76f76f73b4 |
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -252,11 +252,14 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
|||||||
'action_id' => 'exists:\App\Models\Action,id',
|
'action_id' => 'exists:\App\Models\Action,id',
|
||||||
'decision_id' => 'exists:\App\Models\Decision,id',
|
'decision_id' => 'exists:\App\Models\Decision,id',
|
||||||
'contract_uuid' => 'nullable|uuid',
|
'contract_uuid' => 'nullable|uuid',
|
||||||
|
'phone_view' => 'nullable|boolean',
|
||||||
'send_auto_mail' => 'sometimes|boolean',
|
'send_auto_mail' => 'sometimes|boolean',
|
||||||
'attachment_document_ids' => 'sometimes|array',
|
'attachment_document_ids' => 'sometimes|array',
|
||||||
'attachment_document_ids.*' => 'integer',
|
'attachment_document_ids.*' => 'integer',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$isPhoneView = $attributes['phone_view'] ?? false;
|
||||||
|
|
||||||
// Map contract_uuid to contract_id within the same client case, if provided
|
// Map contract_uuid to contract_id within the same client case, if provided
|
||||||
$contractId = null;
|
$contractId = null;
|
||||||
if (! empty($attributes['contract_uuid'])) {
|
if (! empty($attributes['contract_uuid'])) {
|
||||||
@@ -279,10 +282,23 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
|||||||
'decision_id' => $attributes['decision_id'],
|
'decision_id' => $attributes['decision_id'],
|
||||||
'contract_id' => $contractId,
|
'contract_id' => $contractId,
|
||||||
]);
|
]);
|
||||||
/*foreach ($activity->decision->events as $e) {
|
|
||||||
$class = '\\App\\Events\\' . $e->name;
|
if ($isPhoneView && $contractId) {
|
||||||
event(new $class($clientCase));
|
$fieldJob = $contract->fieldJobs()
|
||||||
}*/
|
->whereNull('completed_at')
|
||||||
|
->whereNull('cancelled_at')
|
||||||
|
->where('assigned_user_id', \Auth::id())
|
||||||
|
->orderByDesc('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($fieldJob) {
|
||||||
|
$fieldJob->update([
|
||||||
|
'added_activity' => true,
|
||||||
|
'last_activity' => $row->created_at,
|
||||||
|
]);
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logger()->info('Activity successfully inserted', $attributes);
|
logger()->info('Activity successfully inserted', $attributes);
|
||||||
|
|
||||||
@@ -297,8 +313,8 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
|||||||
->values();
|
->values();
|
||||||
$validAttachmentIds = collect();
|
$validAttachmentIds = collect();
|
||||||
if ($attachmentIds->isNotEmpty() && $contractId) {
|
if ($attachmentIds->isNotEmpty() && $contractId) {
|
||||||
$validAttachmentIds = \App\Models\Document::query()
|
$validAttachmentIds = Document::query()
|
||||||
->where('documentable_type', \App\Models\Contract::class)
|
->where('documentable_type', Contract::class)
|
||||||
->where('documentable_id', $contractId)
|
->where('documentable_id', $contractId)
|
||||||
->whereIn('id', $attachmentIds)
|
->whereIn('id', $attachmentIds)
|
||||||
->pluck('id');
|
->pluck('id');
|
||||||
@@ -1458,178 +1474,265 @@ public function archiveContract(ClientCase $clientCase, string $uuid, Request $r
|
|||||||
{
|
{
|
||||||
$contract = Contract::query()->where('uuid', $uuid)->firstOrFail();
|
$contract = Contract::query()->where('uuid', $uuid)->firstOrFail();
|
||||||
if ($contract->client_case_id !== $clientCase->id) {
|
if ($contract->client_case_id !== $clientCase->id) {
|
||||||
|
\Log::warning('Contract not found uuid: {uuid}', ['uuid' => $uuid]);
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
$reactivateRequested = (bool) $request->boolean('reactivate');
|
|
||||||
// Determine applicable settings based on intent (archive vs reactivate)
|
$attr = $request->validate([
|
||||||
if ($reactivateRequested) {
|
'reactivate' => 'boolean',
|
||||||
$latestReactivate = \App\Models\ArchiveSetting::query()
|
]);
|
||||||
->where('enabled', true)
|
|
||||||
->where('reactivate', true)
|
$reactivate = $attr['reactivate'] ?? false;
|
||||||
->whereIn('strategy', ['immediate', 'manual'])
|
|
||||||
->orderByDesc('id')
|
$setting = \App\Models\ArchiveSetting::query()
|
||||||
->first();
|
->where('enabled', true)
|
||||||
if (! $latestReactivate) {
|
->whereIn('strategy', ['immediate', 'manual'])
|
||||||
return back()->with('warning', __('contracts.reactivate_not_allowed'));
|
->where('reactivate', $reactivate)
|
||||||
}
|
->orderByDesc('id')
|
||||||
$settings = collect([$latestReactivate]);
|
->first();
|
||||||
$hasReactivateRule = true;
|
|
||||||
} else {
|
if (! $setting->exists()) {
|
||||||
$settings = \App\Models\ArchiveSetting::query()
|
\Log::warning('No archive settings found!');
|
||||||
->where('enabled', true)
|
|
||||||
->whereIn('strategy', ['immediate', 'manual'])
|
return back()->with('warning', 'No settings found');
|
||||||
->where(function ($q) { // exclude reactivate-only rules from archive run
|
|
||||||
$q->whereNull('reactivate')->orWhere('reactivate', false);
|
|
||||||
})
|
|
||||||
->get();
|
|
||||||
if ($settings->isEmpty()) {
|
|
||||||
return back()->with('warning', __('contracts.no_archive_settings'));
|
|
||||||
}
|
|
||||||
$hasReactivateRule = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Service archive executor
|
||||||
$executor = app(\App\Services\Archiving\ArchiveExecutor::class);
|
$executor = app(\App\Services\Archiving\ArchiveExecutor::class);
|
||||||
|
$result = null;
|
||||||
|
|
||||||
$context = [
|
$context = [
|
||||||
'contract_id' => $contract->id,
|
'contract_id' => $contract->id,
|
||||||
'client_case_id' => $clientCase->id,
|
'client_case_id' => $clientCase->id,
|
||||||
|
'account_id' => $contract->account->id ?? null,
|
||||||
];
|
];
|
||||||
if ($contract->account) {
|
|
||||||
$context['account_id'] = $contract->account->id;
|
try {
|
||||||
|
$result = $executor->executeSetting($setting, $context, \Auth::id());
|
||||||
|
} catch (Exception $e) {
|
||||||
|
\Log::error('There was an error executing ArchiveExecutor::executeSetting {msg}', ['msg' => $e->getMessage()]);
|
||||||
|
|
||||||
|
return back()->with('warning', 'Something went wrong!');
|
||||||
}
|
}
|
||||||
|
|
||||||
$overall = [];
|
try {
|
||||||
$hadAnyEffect = false;
|
\DB::transaction(function () use ($contract, $clientCase, $setting, $reactivate) {
|
||||||
foreach ($settings as $setting) {
|
// Create an Activity record logging this archive if an action or decision is tied to any setting
|
||||||
|
if ($setting->action_id && $setting->decision_id) {
|
||||||
$res = $executor->executeSetting($setting, $context, optional($request->user())->id);
|
|
||||||
foreach ($res as $table => $count) {
|
|
||||||
$overall[$table] = ($overall[$table] ?? 0) + $count;
|
|
||||||
if ($count > 0) {
|
|
||||||
$hadAnyEffect = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($reactivateRequested && $hasReactivateRule) {
|
|
||||||
// Reactivation path: ensure contract becomes active and soft-delete cleared.
|
|
||||||
if ($contract->active == 0 || $contract->deleted_at) {
|
|
||||||
$contract->forceFill(['active' => 1, 'deleted_at' => null])->save();
|
|
||||||
$overall['contracts_reactivated'] = ($overall['contracts_reactivated'] ?? 0) + 1;
|
|
||||||
$hadAnyEffect = true;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Ensure the contract itself is archived even if rule conditions would have excluded it
|
|
||||||
if (! empty($contract->getAttributes()) && $contract->active) {
|
|
||||||
if (! array_key_exists('contracts', $overall)) {
|
|
||||||
$contract->update(['active' => 0]);
|
|
||||||
$overall['contracts'] = ($overall['contracts'] ?? 0) + 1;
|
|
||||||
} else {
|
|
||||||
$contract->refresh();
|
|
||||||
}
|
|
||||||
$hadAnyEffect = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create an Activity record logging this archive if an action or decision is tied to any setting
|
|
||||||
if ($hadAnyEffect) {
|
|
||||||
$activitySetting = $settings->first(fn ($s) => ! is_null($s->action_id) || ! is_null($s->decision_id));
|
|
||||||
if ($activitySetting) {
|
|
||||||
try {
|
|
||||||
if ($reactivateRequested) {
|
|
||||||
$note = 'Ponovna aktivacija pogodba '.$contract->reference;
|
|
||||||
} else {
|
|
||||||
$noteKey = 'contracts.archived_activity_note';
|
|
||||||
$note = __($noteKey, ['reference' => $contract->reference]);
|
|
||||||
if ($note === $noteKey) {
|
|
||||||
$note = \Illuminate\Support\Facades\Lang::get($noteKey, ['reference' => $contract->reference], 'sl');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$activityData = [
|
$activityData = [
|
||||||
'client_case_id' => $clientCase->id,
|
'client_case_id' => $clientCase->id,
|
||||||
'action_id' => $activitySetting->action_id,
|
'action_id' => $setting->action_id,
|
||||||
'decision_id' => $activitySetting->decision_id,
|
'decision_id' => $setting->decision_id,
|
||||||
'note' => $note,
|
'note' => ($reactivate)
|
||||||
'active' => 1,
|
? "Ponovno aktivirana pogodba $contract->reference"
|
||||||
'user_id' => optional($request->user())->id,
|
: "Arhivirana pogodba $contract->reference",
|
||||||
];
|
];
|
||||||
if ($reactivateRequested) {
|
|
||||||
// Attach the contract_id when reactivated as per requirement
|
try {
|
||||||
$activityData['contract_id'] = $contract->id;
|
\App\Models\Activity::create($activityData);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
\Log::warning('Activity could not be created!');
|
||||||
}
|
}
|
||||||
\App\Models\Activity::create($activityData);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
logger()->warning('Failed to create archive/reactivate activity', [
|
|
||||||
'error' => $e->getMessage(),
|
|
||||||
'contract_id' => $contract->id,
|
|
||||||
'setting_id' => optional($activitySetting)->id,
|
|
||||||
'reactivate' => $reactivateRequested,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
// If any archive setting specifies a segment_id, move the contract to that segment (archive bucket)
|
||||||
// If any archive setting specifies a segment_id, move the contract to that segment (archive bucket)
|
if ($setting->segment_id) {
|
||||||
$segmentSetting = $settings->first(fn ($s) => ! is_null($s->segment_id)); // for reactivation this is the single reactivation setting if segment specified
|
$segmentId = $setting->segment_id;
|
||||||
if ($segmentSetting && $segmentSetting->segment_id) {
|
|
||||||
try {
|
$contract->segments()
|
||||||
$segmentId = $segmentSetting->segment_id;
|
->allRelatedIds()
|
||||||
\DB::transaction(function () use ($contract, $segmentId, $clientCase) {
|
->map(fn (int $val, int|string $key) => $contract->segments()->updateExistingPivot($val, [
|
||||||
// Ensure the segment is attached to the client case (activate if previously inactive)
|
'active' => false,
|
||||||
$casePivot = \DB::table('client_case_segment')
|
'updated_at' => now(),
|
||||||
->where('client_case_id', $clientCase->id)
|
])
|
||||||
->where('segment_id', $segmentId)
|
);
|
||||||
->first();
|
|
||||||
if (! $casePivot) {
|
if ($contract->attachedSegments()->find($segmentId)->pluck('id')->isNotEmpty()) {
|
||||||
\DB::table('client_case_segment')->insert([
|
$contract->attachedSegments()->updateExistingPivot($segmentId, [
|
||||||
'client_case_id' => $clientCase->id,
|
|
||||||
'segment_id' => $segmentId,
|
|
||||||
'active' => true,
|
'active' => true,
|
||||||
'created_at' => now(),
|
|
||||||
'updated_at' => now(),
|
'updated_at' => now(),
|
||||||
]);
|
]);
|
||||||
} elseif (! $casePivot->active) {
|
|
||||||
\DB::table('client_case_segment')
|
|
||||||
->where('id', $casePivot->id)
|
|
||||||
->update(['active' => true, 'updated_at' => now()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deactivate all current active contract segments
|
|
||||||
\DB::table('contract_segment')
|
|
||||||
->where('contract_id', $contract->id)
|
|
||||||
->where('active', true)
|
|
||||||
->update(['active' => false, 'updated_at' => now()]);
|
|
||||||
|
|
||||||
// Attach or activate the archive segment for this contract
|
|
||||||
$existing = \DB::table('contract_segment')
|
|
||||||
->where('contract_id', $contract->id)
|
|
||||||
->where('segment_id', $segmentId)
|
|
||||||
->first();
|
|
||||||
if ($existing) {
|
|
||||||
\DB::table('contract_segment')
|
|
||||||
->where('id', $existing->id)
|
|
||||||
->update(['active' => true, 'updated_at' => now()]);
|
|
||||||
} else {
|
} else {
|
||||||
\DB::table('contract_segment')->insert([
|
$contract->segments()->attach(
|
||||||
'contract_id' => $contract->id,
|
$segmentId,
|
||||||
'segment_id' => $segmentId,
|
[
|
||||||
'active' => true,
|
'active' => true,
|
||||||
'created_at' => now(),
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$contract->fieldJobs()
|
||||||
|
->whereNull('completed_at')
|
||||||
|
->whereNull('cancelled_at')
|
||||||
|
->update([
|
||||||
|
'cancelled_at' => date('Y-m-d'),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
} catch (Exception $e) {
|
||||||
|
\Log::warning('Something went wrong with inserting / updating archive setting partials!');
|
||||||
|
|
||||||
|
return back()->with('warning', 'Something went wrong!');
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('success', $reactivate
|
||||||
|
? __('contracts.reactivated')
|
||||||
|
: __('contracts.archived')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(),
|
'updated_at' => now(),
|
||||||
]);
|
]);
|
||||||
}
|
|
||||||
});
|
});
|
||||||
} catch (\Throwable $e) {
|
|
||||||
logger()->warning('Failed to move contract to archive segment', [
|
$successCount++;
|
||||||
|
} catch (Exception $e) {
|
||||||
|
\Log::error('Error archiving contract in batch', [
|
||||||
|
'uuid' => $contractUuid,
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
'contract_id' => $contract->id,
|
|
||||||
'segment_id' => $segmentSetting->segment_id,
|
|
||||||
'setting_id' => $segmentSetting->id,
|
|
||||||
]);
|
]);
|
||||||
|
$errors[] = [
|
||||||
|
'uuid' => $contractUuid,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$message = $reactivateRequested ? __('contracts.reactivated') : __('contracts.archived');
|
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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
return back()->with('success', $message);
|
$message = $reactivate
|
||||||
|
? "Successfully reactivated $successCount contracts"
|
||||||
|
: "Successfully archived $successCount contracts";
|
||||||
|
|
||||||
|
if ($skippedCount > 0) {
|
||||||
|
$message .= " ($skippedCount already archived)";
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('flash', [
|
||||||
|
'success' => $message,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|
||||||
|
|||||||
@@ -76,169 +76,81 @@ public function completedToday(Request $request)
|
|||||||
public function showCase(\App\Models\ClientCase $clientCase, Request $request)
|
public function showCase(\App\Models\ClientCase $clientCase, Request $request)
|
||||||
{
|
{
|
||||||
$userId = $request->user()->id;
|
$userId = $request->user()->id;
|
||||||
$completedMode = (bool) $request->boolean('completed');
|
$completedMode = $request->boolean('completed');
|
||||||
|
|
||||||
// Eager load client case with person details
|
// Eager load case with person details
|
||||||
$case = \App\Models\ClientCase::query()
|
$case = $clientCase->load('person.addresses', 'person.phones', 'person.emails', 'person.bankAccounts');
|
||||||
->with(['person' => fn ($q) => $q->with(['addresses', 'phones', 'emails', 'bankAccounts'])])
|
|
||||||
->findOrFail($clientCase->id);
|
|
||||||
|
|
||||||
// Determine contracts of this case relevant to the current user
|
// Query contracts based on field jobs
|
||||||
// - Normal mode: contracts assigned to me and still active (not completed/cancelled)
|
$contractsQuery = FieldJob::query()
|
||||||
// - Completed mode (?completed=1): contracts where my field job was completed today
|
->where('assigned_user_id', $userId)
|
||||||
if ($completedMode) {
|
->whereHas('contract', fn ($q) => $q->where('client_case_id', $case->id))
|
||||||
$start = now()->startOfDay();
|
->when($completedMode,
|
||||||
$end = now()->endOfDay();
|
fn ($q) => $q->whereNull('cancelled_at')->whereBetween('completed_at', [now()->startOfDay(), now()->endOfDay()]),
|
||||||
$contractIds = FieldJob::query()
|
fn ($q) => $q->whereNull('completed_at')->whereNull('cancelled_at')
|
||||||
->where('assigned_user_id', $userId)
|
);
|
||||||
->whereNull('cancelled_at')
|
|
||||||
->whereBetween('completed_at', [$start, $end])
|
|
||||||
->whereHas('contract', fn ($q) => $q->where('client_case_id', $case->id))
|
|
||||||
->pluck('contract_id')
|
|
||||||
->unique()
|
|
||||||
->values();
|
|
||||||
} else {
|
|
||||||
$contractIds = FieldJob::query()
|
|
||||||
->where('assigned_user_id', $userId)
|
|
||||||
->whereNull('completed_at')
|
|
||||||
->whereNull('cancelled_at')
|
|
||||||
->whereHas('contract', fn ($q) => $q->where('client_case_id', $case->id))
|
|
||||||
->pluck('contract_id')
|
|
||||||
->unique()
|
|
||||||
->values();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Get contracts with relationships
|
||||||
$contracts = \App\Models\Contract::query()
|
$contracts = \App\Models\Contract::query()
|
||||||
->where('client_case_id', $case->id)
|
->where('client_case_id', $case->id)
|
||||||
->whereIn('id', $contractIds)
|
->whereIn('id', $contractsQuery->pluck('contract_id')->unique())
|
||||||
->with(['type:id,name', 'account'])
|
->with(['type:id,name', 'account', 'latestObject'])
|
||||||
->orderByDesc('created_at')
|
->orderByDesc('created_at')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
// Attach latest object (if any) to each contract as last_object for display
|
// Build merged documents
|
||||||
if ($contracts->isNotEmpty()) {
|
$documents = $case->documents()
|
||||||
$byId = $contracts->keyBy('id');
|
|
||||||
$latestObjects = \App\Models\CaseObject::query()
|
|
||||||
->whereIn('contract_id', $byId->keys())
|
|
||||||
->whereNull('deleted_at')
|
|
||||||
->select('id', 'reference', 'name', 'description', 'type', 'contract_id', 'created_at')
|
|
||||||
->orderByDesc('created_at')
|
|
||||||
->get()
|
|
||||||
->groupBy('contract_id')
|
|
||||||
->map(function ($group) {
|
|
||||||
return $group->first();
|
|
||||||
});
|
|
||||||
|
|
||||||
foreach ($latestObjects as $cid => $obj) {
|
|
||||||
if (isset($byId[$cid])) {
|
|
||||||
$byId[$cid]->setAttribute('last_object', $obj);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build merged documents: case documents + documents of assigned contracts
|
|
||||||
$contractRefMap = [];
|
|
||||||
foreach ($contracts as $c) {
|
|
||||||
$contractRefMap[$c->id] = $c->reference;
|
|
||||||
}
|
|
||||||
|
|
||||||
$contractDocs = \App\Models\Document::query()
|
|
||||||
->where('documentable_type', \App\Models\Contract::class)
|
|
||||||
->whereIn('documentable_id', $contractIds)
|
|
||||||
->orderByDesc('created_at')
|
->orderByDesc('created_at')
|
||||||
->get()
|
->get()
|
||||||
->map(function ($d) use ($contractRefMap) {
|
->map(fn ($d) => array_merge($d->toArray(), [
|
||||||
$arr = $d->toArray();
|
'documentable_type' => \App\Models\ClientCase::class,
|
||||||
$arr['contract_reference'] = $contractRefMap[$d->documentable_id] ?? null;
|
'client_case_uuid' => $case->uuid,
|
||||||
$arr['documentable_type'] = \App\Models\Contract::class;
|
]))
|
||||||
$arr['contract_uuid'] = optional(\App\Models\Contract::withTrashed()->find($d->documentable_id))->uuid;
|
->concat(
|
||||||
|
\App\Models\Document::query()
|
||||||
return $arr;
|
->where('documentable_type', \App\Models\Contract::class)
|
||||||
});
|
->whereIn('documentable_id', $contracts->pluck('id'))
|
||||||
|
->with('documentable:id,uuid,reference')
|
||||||
$caseDocs = $case->documents()->orderByDesc('created_at')->get()->map(function ($d) use ($case) {
|
->orderByDesc('created_at')
|
||||||
$arr = $d->toArray();
|
->get()
|
||||||
$arr['documentable_type'] = \App\Models\ClientCase::class;
|
->map(fn ($d) => array_merge($d->toArray(), [
|
||||||
$arr['client_case_uuid'] = $case->uuid;
|
'contract_reference' => $d->documentable?->reference,
|
||||||
|
'contract_uuid' => $d->documentable?->uuid,
|
||||||
return $arr;
|
]))
|
||||||
});
|
|
||||||
|
|
||||||
$documents = $caseDocs->concat($contractDocs)->sortByDesc('created_at')->values();
|
|
||||||
|
|
||||||
// Provide minimal types for PersonInfoGrid
|
|
||||||
$types = [
|
|
||||||
'address_types' => \App\Models\Person\AddressType::all(),
|
|
||||||
'phone_types' => \App\Models\Person\PhoneType::all(),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Case activities (compact for phone): latest 20 with relations
|
|
||||||
$activities = $case->activities()
|
|
||||||
->with(['action', 'decision', 'contract:id,uuid,reference', 'user:id,name'])
|
|
||||||
->orderByDesc('created_at')
|
|
||||||
->limit(20)
|
|
||||||
->get()
|
|
||||||
->map(function ($a) {
|
|
||||||
$a->setAttribute('user_name', optional($a->user)->name);
|
|
||||||
|
|
||||||
return $a;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Determine segment filters from FieldJobSettings for this case/user context
|
|
||||||
$settingIds = FieldJob::query()
|
|
||||||
->where('assigned_user_id', $userId)
|
|
||||||
->whereHas('contract', fn ($q) => $q->where('client_case_id', $case->id))
|
|
||||||
->when(
|
|
||||||
$completedMode,
|
|
||||||
function ($q) {
|
|
||||||
$q->whereNull('cancelled_at')
|
|
||||||
->whereBetween('completed_at', [now()->startOfDay(), now()->endOfDay()]);
|
|
||||||
},
|
|
||||||
function ($q) {
|
|
||||||
$q->whereNull('completed_at')->whereNull('cancelled_at');
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
->pluck('field_job_setting_id')
|
->sortByDesc('created_at')
|
||||||
->filter()
|
|
||||||
->unique()
|
|
||||||
->values();
|
->values();
|
||||||
|
|
||||||
$segmentIds = collect();
|
// Get segment IDs for filtering actions
|
||||||
if ($settingIds->isNotEmpty()) {
|
$segmentIds = \App\Models\FieldJobSetting::query()
|
||||||
$segmentIds = \App\Models\FieldJobSetting::query()
|
->whereIn('id', $contractsQuery->pluck('field_job_setting_id')->filter()->unique())
|
||||||
->whereIn('id', $settingIds)
|
->pluck('segment_id')
|
||||||
->pluck('segment_id')
|
->filter()
|
||||||
->filter()
|
->unique();
|
||||||
->unique()
|
|
||||||
->values();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter actions and their decisions by the derived segment ids (decisions.segment_id)
|
|
||||||
$actions = \App\Models\Action::query()
|
|
||||||
->when($segmentIds->isNotEmpty(), function ($q) use ($segmentIds) {
|
|
||||||
// Filter actions by their segment_id matching the FieldJobSetting segment(s)
|
|
||||||
$q->whereIn('segment_id', $segmentIds);
|
|
||||||
})
|
|
||||||
->with([
|
|
||||||
'decisions' => function ($q) {
|
|
||||||
$q->select('decisions.id', 'decisions.name', 'decisions.color_tag', 'decisions.auto_mail', 'decisions.email_template_id');
|
|
||||||
},
|
|
||||||
'decisions.emailTemplate' => function ($q) {
|
|
||||||
$q->select('id', 'name', 'entity_types', 'allow_attachments');
|
|
||||||
},
|
|
||||||
])
|
|
||||||
->get(['id', 'name', 'color_tag', 'segment_id']);
|
|
||||||
|
|
||||||
return Inertia::render('Phone/Case/Index', [
|
return Inertia::render('Phone/Case/Index', [
|
||||||
'client' => $case->client()->with('person', fn ($q) => $q->with(['addresses', 'phones', 'emails', 'bankAccounts']))->firstOrFail(),
|
'client' => $case->client->load('person.addresses', 'person.phones', 'person.emails', 'person.bankAccounts'),
|
||||||
'client_case' => $case,
|
'client_case' => $case,
|
||||||
'contracts' => $contracts,
|
'contracts' => $contracts,
|
||||||
'documents' => $documents,
|
'documents' => $documents,
|
||||||
'types' => $types,
|
'types' => [
|
||||||
|
'address_types' => \App\Models\Person\AddressType::all(),
|
||||||
|
'phone_types' => \App\Models\Person\PhoneType::all(),
|
||||||
|
],
|
||||||
'account_types' => \App\Models\AccountType::all(),
|
'account_types' => \App\Models\AccountType::all(),
|
||||||
// Provide decisions (filtered by segment) with linked email template metadata (entity_types, allow_attachments)
|
'actions' => \App\Models\Action::query()
|
||||||
'actions' => $actions,
|
->when($segmentIds->isNotEmpty(), fn ($q) => $q->whereIn('segment_id', $segmentIds))
|
||||||
'activities' => $activities,
|
->with([
|
||||||
|
'decisions:id,name,color_tag,auto_mail,email_template_id',
|
||||||
|
'decisions.emailTemplate:id,name,entity_types,allow_attachments',
|
||||||
|
])
|
||||||
|
->get(['id', 'name', 'color_tag', 'segment_id']),
|
||||||
|
'activities' => $case->activities()
|
||||||
|
->with(['action', 'decision', 'contract:id,uuid,reference', 'user:id,name'])
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->limit(20)
|
||||||
|
->get()
|
||||||
|
->map(fn ($a) => $a->setAttribute('user_name', $a->user?->name)),
|
||||||
'completed_mode' => $completedMode,
|
'completed_mode' => $completedMode,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,12 @@ public function show(Segment $segment)
|
|||||||
->withQueryString();
|
->withQueryString();
|
||||||
|
|
||||||
$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) {
|
||||||
@@ -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'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -96,6 +96,11 @@ public function segments(): BelongsToMany
|
|||||||
->wherePivot('active', true);
|
->wherePivot('active', true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function attachedSegments(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(\App\Models\Segment::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function account(): HasOne
|
public function account(): HasOne
|
||||||
{
|
{
|
||||||
// Use latestOfMany to always surface newest account snapshot if multiple exist.
|
// Use latestOfMany to always surface newest account snapshot if multiple exist.
|
||||||
@@ -114,6 +119,18 @@ public function documents(): MorphMany
|
|||||||
return $this->morphMany(\App\Models\Document::class, 'documentable');
|
return $this->morphMany(\App\Models\Document::class, 'documentable');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function fieldJobs(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(\App\Models\FieldJob::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function latestObject(): HasOne
|
||||||
|
{
|
||||||
|
return $this->hasOne(\App\Models\CaseObject::class)
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->latest();
|
||||||
|
}
|
||||||
|
|
||||||
protected static function booted(): void
|
protected static function booted(): void
|
||||||
{
|
{
|
||||||
static::created(function (Contract $contract): void {
|
static::created(function (Contract $contract): void {
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ class FieldJob extends Model
|
|||||||
'priority',
|
'priority',
|
||||||
'notes',
|
'notes',
|
||||||
'address_snapshot ',
|
'address_snapshot ',
|
||||||
|
'last_activity',
|
||||||
|
'added_activity'
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
@@ -31,6 +33,8 @@ class FieldJob extends Model
|
|||||||
'completed_at' => 'datetime',
|
'completed_at' => 'datetime',
|
||||||
'cancelled_at' => 'datetime',
|
'cancelled_at' => 'datetime',
|
||||||
'priority' => 'boolean',
|
'priority' => 'boolean',
|
||||||
|
'last_activity' => 'datetime',
|
||||||
|
'added_activity' => 'boolean',
|
||||||
'address_snapshot ' => 'array',
|
'address_snapshot ' => 'array',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -90,7 +94,8 @@ public function user(): BelongsTo
|
|||||||
|
|
||||||
public function contract(): BelongsTo
|
public function contract(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Contract::class, 'contract_id');
|
return $this->belongsTo(Contract::class, 'contract_id')
|
||||||
|
->where('active', true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ public function executeSetting(ArchiveSetting $setting, ?array $context = null,
|
|||||||
$entities = $flat;
|
$entities = $flat;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dd($entities);
|
||||||
|
|
||||||
foreach ($entities as $entityDef) {
|
foreach ($entities as $entityDef) {
|
||||||
$rawTable = $entityDef['table'] ?? null;
|
$rawTable = $entityDef['table'] ?? null;
|
||||||
if (! $rawTable) {
|
if (! $rawTable) {
|
||||||
@@ -97,7 +99,7 @@ public function executeSetting(ArchiveSetting $setting, ?array $context = null,
|
|||||||
// Process in batches to avoid locking large tables
|
// Process in batches to avoid locking large tables
|
||||||
while (true) {
|
while (true) {
|
||||||
$query = DB::table($table)->whereNull('deleted_at');
|
$query = DB::table($table)->whereNull('deleted_at');
|
||||||
if (Schema::hasColumn($table, 'active')) {
|
if (Schema::hasColumn($table, 'active') && ! $reactivate) {
|
||||||
$query->where('active', 1);
|
$query->where('active', 1);
|
||||||
}
|
}
|
||||||
// Apply context filters or chain derived filters
|
// Apply context filters or chain derived filters
|
||||||
|
|||||||
@@ -24,15 +24,19 @@
|
|||||||
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\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
class ImportProcessor
|
class ImportProcessor
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Track contracts that already existed and were matched during history imports.
|
* Track contracts that already existed and were matched during history imports.
|
||||||
|
*
|
||||||
* @var array<int,bool>
|
* @var array<int,bool>
|
||||||
*/
|
*/
|
||||||
private array $historyFoundContractIds = [];
|
private array $historyFoundContractIds = [];
|
||||||
@@ -180,10 +184,7 @@ public function process(Import $import, ?Authenticatable $user = null): array
|
|||||||
}
|
}
|
||||||
// Preflight: warn if any mapped source columns are not present in the header (exact match)
|
// Preflight: warn if any mapped source columns are not present in the header (exact match)
|
||||||
$headerSet = [];
|
$headerSet = [];
|
||||||
foreach ($header as $h) {
|
// Regex validation removed per request; rely on basic length/placeholder checks only
|
||||||
$headerSet[$h] = true;
|
|
||||||
}
|
|
||||||
$missingSources = [];
|
|
||||||
foreach ($mappings as $map) {
|
foreach ($mappings as $map) {
|
||||||
$src = (string) ($map->source_column ?? '');
|
$src = (string) ($map->source_column ?? '');
|
||||||
if ($src !== '' && ! array_key_exists($src, $headerSet)) {
|
if ($src !== '' && ! array_key_exists($src, $headerSet)) {
|
||||||
@@ -216,7 +217,28 @@ public function process(Import $import, ?Authenticatable $user = null): array
|
|||||||
|
|
||||||
if ($isPg) {
|
if ($isPg) {
|
||||||
// Establish a savepoint so a failing row does not poison the whole transaction
|
// Establish a savepoint so a failing row does not poison the whole transaction
|
||||||
DB::statement('SAVEPOINT import_row_'.$rowNum);
|
try {
|
||||||
|
DB::statement('SAVEPOINT import_row_'.$rowNum);
|
||||||
|
} catch (\Throwable $se) {
|
||||||
|
Log::error('Import savepoint_failed', [
|
||||||
|
'import_id' => $import->id,
|
||||||
|
'row_number' => $rowNum,
|
||||||
|
'exception' => $this->exceptionContext($se),
|
||||||
|
]);
|
||||||
|
ImportEvent::create([
|
||||||
|
'import_id' => $import->id,
|
||||||
|
'user_id' => $user?->getAuthIdentifier(),
|
||||||
|
'event' => 'savepoint_failed',
|
||||||
|
'level' => 'error',
|
||||||
|
'message' => 'Failed to create savepoint; transaction already aborted.',
|
||||||
|
'context' => [
|
||||||
|
'row_number' => $rowNum,
|
||||||
|
'exception' => $this->exceptionContext($se),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw $se; // abort import so root cause surfaces
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scope variables per row so they aren't reused after exception
|
// Scope variables per row so they aren't reused after exception
|
||||||
@@ -1067,13 +1089,38 @@ public function process(Import $import, ?Authenticatable $user = null): array
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
$rollbackFailed = false;
|
||||||
|
$rollbackError = null;
|
||||||
if ($isPg) {
|
if ($isPg) {
|
||||||
// Roll back only this row's work
|
// Roll back only this row's work
|
||||||
try {
|
try {
|
||||||
DB::statement('ROLLBACK TO SAVEPOINT import_row_'.$rowNum);
|
DB::statement('ROLLBACK TO SAVEPOINT import_row_'.$rowNum);
|
||||||
} catch (\Throwable $ignored) { /* noop */
|
} catch (\Throwable $ignored) {
|
||||||
|
$rollbackFailed = true;
|
||||||
|
$rollbackError = $ignored;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if ($rollbackFailed) {
|
||||||
|
Log::error('Import row_rollback_failed', [
|
||||||
|
'import_id' => $import->id,
|
||||||
|
'row_number' => $rowNum,
|
||||||
|
'exception' => $this->exceptionContext($rollbackError ?? $e),
|
||||||
|
]);
|
||||||
|
// Abort the whole import if we cannot rollback to the row savepoint (transaction is poisoned)
|
||||||
|
ImportEvent::create([
|
||||||
|
'import_id' => $import->id,
|
||||||
|
'user_id' => $user?->getAuthIdentifier(),
|
||||||
|
'event' => 'row_rollback_failed',
|
||||||
|
'level' => 'error',
|
||||||
|
'message' => 'Rollback to savepoint failed; aborting import.',
|
||||||
|
'context' => [
|
||||||
|
'row_number' => $rowNum,
|
||||||
|
'exception' => $this->exceptionContext($rollbackError ?? $e),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw $rollbackError ?? $e;
|
||||||
|
}
|
||||||
// Ensure importRow exists for logging if failure happened before its creation
|
// Ensure importRow exists for logging if failure happened before its creation
|
||||||
if (! $importRow) {
|
if (! $importRow) {
|
||||||
try {
|
try {
|
||||||
@@ -1100,6 +1147,12 @@ public function process(Import $import, ?Authenticatable $user = null): array
|
|||||||
}
|
}
|
||||||
$failedRows[] = $rowNum;
|
$failedRows[] = $rowNum;
|
||||||
$invalid++;
|
$invalid++;
|
||||||
|
Log::error('Import row_exception', [
|
||||||
|
'import_id' => $import->id,
|
||||||
|
'row_number' => $rowNum,
|
||||||
|
'exception' => $this->exceptionContext($e),
|
||||||
|
'raw_preview' => isset($rawAssoc) ? $this->buildRawDataPreview($rawAssoc) : [],
|
||||||
|
]);
|
||||||
try {
|
try {
|
||||||
ImportEvent::create([
|
ImportEvent::create([
|
||||||
'import_id' => $import->id,
|
'import_id' => $import->id,
|
||||||
@@ -1117,6 +1170,12 @@ public function process(Import $import, ?Authenticatable $user = null): array
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
} catch (\Throwable $evtErr) {
|
} catch (\Throwable $evtErr) {
|
||||||
|
Log::error('Import row_exception_event_failed', [
|
||||||
|
'import_id' => $import->id,
|
||||||
|
'row_number' => $rowNum,
|
||||||
|
'exception' => $this->exceptionContext($evtErr),
|
||||||
|
'original_exception' => $this->exceptionContext($e),
|
||||||
|
]);
|
||||||
// Swallow secondary failure to ensure loop continues
|
// Swallow secondary failure to ensure loop continues
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1189,12 +1248,17 @@ public function process(Import $import, ?Authenticatable $user = null): array
|
|||||||
// Mark failed and log after rollback (so no partial writes persist)
|
// Mark failed and log after rollback (so no partial writes persist)
|
||||||
$import->refresh();
|
$import->refresh();
|
||||||
$import->update(['status' => 'failed', 'failed_at' => now()]);
|
$import->update(['status' => 'failed', 'failed_at' => now()]);
|
||||||
|
Log::error('Import processing_failed', [
|
||||||
|
'import_id' => $import->id,
|
||||||
|
'exception' => $this->exceptionContext($e),
|
||||||
|
]);
|
||||||
ImportEvent::create([
|
ImportEvent::create([
|
||||||
'import_id' => $import->id,
|
'import_id' => $import->id,
|
||||||
'user_id' => $user?->getAuthIdentifier(),
|
'user_id' => $user?->getAuthIdentifier(),
|
||||||
'event' => 'processing_failed',
|
'event' => 'processing_failed',
|
||||||
'level' => 'error',
|
'level' => 'error',
|
||||||
'message' => $e->getMessage(),
|
'message' => $this->safeErrorMessage($e->getMessage()),
|
||||||
|
'context' => $this->exceptionContext($e),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return ['ok' => false, 'status' => 'failed', 'error' => $e->getMessage()];
|
return ['ok' => false, 'status' => 'failed', 'error' => $e->getMessage()];
|
||||||
@@ -1569,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();
|
||||||
|
|
||||||
@@ -1592,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') {
|
||||||
@@ -1621,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);
|
||||||
@@ -1631,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;
|
||||||
@@ -1920,6 +2000,8 @@ private function upsertActivity(Import $import, array $mapped, $mappings, ?array
|
|||||||
} elseif (in_array($field, ['action_id', 'decision_id', 'user_id'], true)) {
|
} elseif (in_array($field, ['action_id', 'decision_id', 'user_id'], true)) {
|
||||||
$normalized = is_null($value) ? null : (int) $value;
|
$normalized = is_null($value) ? null : (int) $value;
|
||||||
} elseif (is_string($normalized)) {
|
} elseif (is_string($normalized)) {
|
||||||
|
// Clean invalid UTF-8 sequences from string fields
|
||||||
|
$normalized = mb_convert_encoding($normalized, 'UTF-8', 'UTF-8');
|
||||||
$normalized = trim($normalized);
|
$normalized = trim($normalized);
|
||||||
}
|
}
|
||||||
if (in_array($applyMode, ['both', 'insert'], true)) {
|
if (in_array($applyMode, ['both', 'insert'], true)) {
|
||||||
@@ -2009,7 +2091,7 @@ private function upsertActivity(Import $import, array $mapped, $mappings, ?array
|
|||||||
}
|
}
|
||||||
|
|
||||||
$data = array_filter($applyInsert, fn ($v) => ! is_null($v));
|
$data = array_filter($applyInsert, fn ($v) => ! is_null($v));
|
||||||
$activityModel = new Activity();
|
$activityModel = new Activity;
|
||||||
$activityModel->forceFill($data);
|
$activityModel->forceFill($data);
|
||||||
if (array_key_exists('created_at', $data)) {
|
if (array_key_exists('created_at', $data)) {
|
||||||
// Preserve provided timestamps by disabling automatic timestamps for this save
|
// Preserve provided timestamps by disabling automatic timestamps for this save
|
||||||
@@ -2233,12 +2315,34 @@ private function upsertContractChain(Import $import, array $mapped, $mappings, b
|
|||||||
if ($existing) {
|
if ($existing) {
|
||||||
if ($historyImport) {
|
if ($historyImport) {
|
||||||
$this->historyFoundContractIds[$existing->id] = true;
|
$this->historyFoundContractIds[$existing->id] = true;
|
||||||
|
|
||||||
return ['action' => 'skipped_history', 'contract' => $existing, 'message' => 'Existing contract left unchanged (history import)'];
|
return ['action' => 'skipped_history', 'contract' => $existing, 'message' => 'Existing contract left unchanged (history import)'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if contract is soft-deleted and needs reactivation
|
||||||
|
$isTrashed = $existing->trashed();
|
||||||
|
|
||||||
// 1) Prepare contract field changes (non-null)
|
// 1) Prepare contract field changes (non-null)
|
||||||
$changes = array_filter($applyUpdate, fn ($v) => ! is_null($v));
|
$changes = array_filter($applyUpdate, fn ($v) => ! is_null($v));
|
||||||
|
|
||||||
|
// 2) Handle reactivation defaults when contract is soft-deleted
|
||||||
|
if ($isTrashed || $existing->active == 0) {
|
||||||
|
// Check if start_date is in the mappings
|
||||||
|
$hasStartDateMapping = $this->mappingIncludes($mappings, 'contract.start_date');
|
||||||
|
if (!$hasStartDateMapping) {
|
||||||
|
// Default to current date when not in mappings
|
||||||
|
$changes['start_date'] = now()->toDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if end_date is in the mappings
|
||||||
|
$hasEndDateMapping = $this->mappingIncludes($mappings, 'contract.end_date');
|
||||||
|
if (!$hasEndDateMapping) {
|
||||||
|
// Default to null when not in mappings
|
||||||
|
$changes['end_date'] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 2) Prepare meta changes if provided via mapping
|
// 3) Prepare meta changes if provided via mapping
|
||||||
$metaUpdated = false;
|
$metaUpdated = false;
|
||||||
$metaAppliedKeys = [];
|
$metaAppliedKeys = [];
|
||||||
if (! empty($contractData['meta'] ?? null) && is_array($contractData['meta'])) {
|
if (! empty($contractData['meta'] ?? null) && is_array($contractData['meta'])) {
|
||||||
@@ -2281,7 +2385,7 @@ private function upsertContractChain(Import $import, array $mapped, $mappings, b
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (empty($changes) && ! $metaUpdated) {
|
if (empty($changes) && ! $metaUpdated && ! $isTrashed) {
|
||||||
// Nothing to change
|
// Nothing to change
|
||||||
return ['action' => 'skipped', 'message' => 'No contract fields or meta changes', 'contract' => $existing];
|
return ['action' => 'skipped', 'message' => 'No contract fields or meta changes', 'contract' => $existing];
|
||||||
}
|
}
|
||||||
@@ -2289,6 +2393,12 @@ private function upsertContractChain(Import $import, array $mapped, $mappings, b
|
|||||||
if (! empty($changes)) {
|
if (! empty($changes)) {
|
||||||
$existing->fill($changes);
|
$existing->fill($changes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restore soft-deleted contract if it was trashed
|
||||||
|
if ($isTrashed) {
|
||||||
|
$existing->restore();
|
||||||
|
}
|
||||||
|
|
||||||
$existing->save();
|
$existing->save();
|
||||||
|
|
||||||
// Build applied fields info, include meta keys if any
|
// Build applied fields info, include meta keys if any
|
||||||
@@ -2298,8 +2408,10 @@ private function upsertContractChain(Import $import, array $mapped, $mappings, b
|
|||||||
$applied['meta:'.$k] = 'updated';
|
$applied['meta:'.$k] = 'updated';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$actionType = $isTrashed ? 'reactivated' : 'updated';
|
||||||
|
|
||||||
return ['action' => 'updated', 'contract' => $existing, 'applied_fields' => $applied];
|
return ['action' => $actionType, 'contract' => $existing, 'applied_fields' => $applied];
|
||||||
} else {
|
} else {
|
||||||
if (empty($applyInsert)) {
|
if (empty($applyInsert)) {
|
||||||
return ['action' => 'skipped', 'message' => 'No contract fields marked for insert'];
|
return ['action' => 'skipped', 'message' => 'No contract fields marked for insert'];
|
||||||
@@ -2475,13 +2587,57 @@ private function safeErrorMessage(string $msg): string
|
|||||||
}
|
}
|
||||||
// Fallback strip invalid bytes
|
// Fallback strip invalid bytes
|
||||||
$msg = @iconv('UTF-8', 'UTF-8//IGNORE', $msg) ?: $msg;
|
$msg = @iconv('UTF-8', 'UTF-8//IGNORE', $msg) ?: $msg;
|
||||||
if (strlen($msg) > 500) {
|
// Use mb_strlen and mb_substr for UTF-8 safety
|
||||||
$msg = substr($msg, 0, 497).'...';
|
if (mb_strlen($msg) > 500) {
|
||||||
|
$msg = mb_substr($msg, 0, 497).'...';
|
||||||
}
|
}
|
||||||
|
|
||||||
return $msg;
|
return $msg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract structured exception details for logging.
|
||||||
|
*/
|
||||||
|
private function exceptionContext(\Throwable $e): array
|
||||||
|
{
|
||||||
|
$ctx = [
|
||||||
|
'exception' => get_class($e),
|
||||||
|
'message' => $this->safeErrorMessage($e->getMessage()),
|
||||||
|
'code' => $e->getCode(),
|
||||||
|
'file' => $e->getFile().':'.$e->getLine(),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (method_exists($e, 'getPrevious') && $e->getPrevious()) {
|
||||||
|
$prev = $e->getPrevious();
|
||||||
|
$ctx['previous'] = [
|
||||||
|
'exception' => get_class($prev),
|
||||||
|
'message' => $this->safeErrorMessage($prev->getMessage()),
|
||||||
|
'code' => $prev->getCode(),
|
||||||
|
'file' => $prev->getFile().':'.$prev->getLine(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($e instanceof QueryException) {
|
||||||
|
$ctx['sql'] = $e->getSql();
|
||||||
|
$ctx['bindings'] = $e->getBindings();
|
||||||
|
$info = $e->errorInfo ?? null;
|
||||||
|
if (is_array($info)) {
|
||||||
|
$ctx['sqlstate'] = $info[0] ?? null;
|
||||||
|
$ctx['driver_error_code'] = $info[1] ?? null;
|
||||||
|
$ctx['driver_error_message'] = $info[2] ?? null;
|
||||||
|
}
|
||||||
|
} elseif (property_exists($e, 'errorInfo')) {
|
||||||
|
$info = $e->errorInfo;
|
||||||
|
if (is_array($info)) {
|
||||||
|
$ctx['sqlstate'] = $info[0] ?? null;
|
||||||
|
$ctx['driver_error_code'] = $info[1] ?? null;
|
||||||
|
$ctx['driver_error_message'] = $info[2] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $ctx;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a trimmed raw data preview (first 8 columns, truncated values) for logging.
|
* Build a trimmed raw data preview (first 8 columns, truncated values) for logging.
|
||||||
*/
|
*/
|
||||||
@@ -2522,9 +2678,9 @@ private function formatAppliedFieldMessage(string $root, array $fields): string
|
|||||||
} else {
|
} else {
|
||||||
$disp = method_exists($v, '__toString') ? (string) $v : gettype($v);
|
$disp = method_exists($v, '__toString') ? (string) $v : gettype($v);
|
||||||
}
|
}
|
||||||
// Truncate very long values for log safety
|
// Truncate very long values for log safety (use mb_substr for UTF-8 safety)
|
||||||
if (strlen($disp) > 60) {
|
if (mb_strlen($disp) > 60) {
|
||||||
$disp = substr($disp, 0, 57).'...';
|
$disp = mb_substr($disp, 0, 57).'...';
|
||||||
}
|
}
|
||||||
$parts[] = $k.'='.$disp;
|
$parts[] = $k.'='.$disp;
|
||||||
}
|
}
|
||||||
@@ -2831,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];
|
||||||
}
|
}
|
||||||
@@ -2844,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();
|
||||||
@@ -3000,8 +3166,10 @@ private function upsertEmail(int $personId, array $emailData, $mappings): array
|
|||||||
private function upsertAddress(int $personId, array $addrData, $mappings): array
|
private function upsertAddress(int $personId, array $addrData, $mappings): array
|
||||||
{
|
{
|
||||||
$addressLine = trim((string) ($addrData['address'] ?? ''));
|
$addressLine = trim((string) ($addrData['address'] ?? ''));
|
||||||
// Normalize whitespace
|
// Normalize whitespace: collapse multiples and tighten around separators
|
||||||
$addressLine = preg_replace('/\s+/', ' ', $addressLine);
|
$addressLine = preg_replace('/\s+/', ' ', $addressLine);
|
||||||
|
$addressLine = preg_replace('/\s*([,;\/])\s*/', '$1 ', $addressLine);
|
||||||
|
$addressLine = trim($addressLine);
|
||||||
// Skip common placeholders or missing values
|
// Skip common placeholders or missing values
|
||||||
if ($addressLine === '' || $addressLine === '0' || strcasecmp($addressLine, '#N/A') === 0 || preg_match('/^(#?n\/?a|na|null|none)$/i', $addressLine)) {
|
if ($addressLine === '' || $addressLine === '0' || strcasecmp($addressLine, '#N/A') === 0 || preg_match('/^(#?n\/?a|na|null|none)$/i', $addressLine)) {
|
||||||
return ['action' => 'skipped', 'message' => 'No address value'];
|
return ['action' => 'skipped', 'message' => 'No address value'];
|
||||||
@@ -3009,15 +3177,49 @@ private function upsertAddress(int $personId, array $addrData, $mappings): array
|
|||||||
if (mb_strlen($addressLine) < 3) {
|
if (mb_strlen($addressLine) < 3) {
|
||||||
return ['action' => 'skipped', 'message' => 'Invalid address value'];
|
return ['action' => 'skipped', 'message' => 'Invalid address value'];
|
||||||
}
|
}
|
||||||
// Allow only basic address characters to avoid noisy special chars
|
// If identical address already exists anywhere, skip to avoid constraint violation
|
||||||
if (! preg_match('/^[A-Za-z0-9\\s\\.,\\-\\/\\#\\\'"\\(\\)&]+$/', $addressLine)) {
|
/*$existingAny = PersonAddress::where('address', $addressLine)->first();
|
||||||
return ['action' => 'skipped', 'message' => 'Invalid address value'];
|
if ($existingAny) {
|
||||||
}
|
return ['action' => 'skipped', 'message' => 'Address already exists in database'];
|
||||||
|
}*/
|
||||||
// Default country SLO if not provided
|
// Default country SLO if not provided
|
||||||
if (! isset($addrData['country']) || $addrData['country'] === null || $addrData['country'] === '') {
|
if (! isset($addrData['country']) || $addrData['country'] === null || $addrData['country'] === '') {
|
||||||
$addrData['country'] = 'SLO';
|
$addrData['country'] = 'SLO';
|
||||||
}
|
}
|
||||||
$existing = PersonAddress::where('person_id', $personId)->where('address', $addressLine)->first();
|
|
||||||
|
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
|
||||||
|
/*$addressLineNoSpaces = preg_replace('/\s+/', '', $addressLine);
|
||||||
|
|
||||||
|
|
||||||
|
$existing = PersonAddress::where('person_id', $personId)
|
||||||
|
->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();
|
||||||
|
|
||||||
$applyInsert = [];
|
$applyInsert = [];
|
||||||
$applyUpdate = [];
|
$applyUpdate = [];
|
||||||
foreach ($mappings as $map) {
|
foreach ($mappings as $map) {
|
||||||
@@ -3060,9 +3262,28 @@ 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();
|
||||||
$created = PersonAddress::create($data);
|
|
||||||
|
if (!empty($addrData['post_code']) && $addrData['post_code'] !== '0' && !isset($applyUpdate['post_code'])) {
|
||||||
|
$data['post_code'] = $addrData['post_code'];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$created = PersonAddress::create($data);
|
||||||
|
|
||||||
return ['action' => 'inserted', 'address' => $created];
|
return ['action' => 'inserted', 'address' => $created];
|
||||||
|
} catch (QueryException $e) {
|
||||||
|
// If unique constraint violation, skip instead of aborting
|
||||||
|
Log::warning('Address constraint violation during import', [
|
||||||
|
'person_id' => $personId,
|
||||||
|
'address' => $addressLine,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
if ($e->getCode() === '23505' || str_contains($e->getMessage(), 'unique') || str_contains($e->getMessage(), 'duplicate')) {
|
||||||
|
return ['action' => 'skipped', 'message' => 'Address already exists (constraint violation)'];
|
||||||
|
}
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -5,7 +5,6 @@
|
|||||||
"keywords": ["laravel", "framework"],
|
"keywords": ["laravel", "framework"],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"require": {
|
"require": {
|
||||||
"tijsverkoyen/css-to-inline-styles": "^2.2",
|
|
||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
"arielmejiadev/larapex-charts": "^2.1",
|
"arielmejiadev/larapex-charts": "^2.1",
|
||||||
"diglactic/laravel-breadcrumbs": "^10.0",
|
"diglactic/laravel-breadcrumbs": "^10.0",
|
||||||
@@ -19,7 +18,8 @@
|
|||||||
"maatwebsite/excel": "^3.1",
|
"maatwebsite/excel": "^3.1",
|
||||||
"meilisearch/meilisearch-php": "^1.11",
|
"meilisearch/meilisearch-php": "^1.11",
|
||||||
"robertboes/inertia-breadcrumbs": "dev-laravel-12",
|
"robertboes/inertia-breadcrumbs": "dev-laravel-12",
|
||||||
"tightenco/ziggy": "^2.0"
|
"tightenco/ziggy": "^2.0",
|
||||||
|
"tijsverkoyen/css-to-inline-styles": "^2.2"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.23",
|
"fakerphp/faker": "^1.23",
|
||||||
|
|||||||
Generated
+591
-2
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "51fd57123c1b9f51c24f28e04a692ec4",
|
"content-hash": "d29c47a4d6813ee8e80a7c8112c2f17e",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "arielmejiadev/larapex-charts",
|
"name": "arielmejiadev/larapex-charts",
|
||||||
@@ -242,6 +242,162 @@
|
|||||||
],
|
],
|
||||||
"time": "2024-02-09T16:56:22+00:00"
|
"time": "2024-02-09T16:56:22+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "composer/pcre",
|
||||||
|
"version": "3.3.2",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/composer/pcre.git",
|
||||||
|
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
|
||||||
|
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^7.4 || ^8.0"
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"phpstan/phpstan": "<1.11.10"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpstan/phpstan": "^1.12 || ^2",
|
||||||
|
"phpstan/phpstan-strict-rules": "^1 || ^2",
|
||||||
|
"phpunit/phpunit": "^8 || ^9"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"phpstan": {
|
||||||
|
"includes": [
|
||||||
|
"extension.neon"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-main": "3.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Composer\\Pcre\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Jordi Boggiano",
|
||||||
|
"email": "j.boggiano@seld.be",
|
||||||
|
"homepage": "http://seld.be"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PCRE wrapping library that offers type-safe preg_* replacements.",
|
||||||
|
"keywords": [
|
||||||
|
"PCRE",
|
||||||
|
"preg",
|
||||||
|
"regex",
|
||||||
|
"regular expression"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/composer/pcre/issues",
|
||||||
|
"source": "https://github.com/composer/pcre/tree/3.3.2"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://packagist.com",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/composer",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/composer/composer",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2024-11-12T16:29:46+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "composer/semver",
|
||||||
|
"version": "3.4.4",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/composer/semver.git",
|
||||||
|
"reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95",
|
||||||
|
"reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^5.3.2 || ^7.0 || ^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpstan/phpstan": "^1.11",
|
||||||
|
"symfony/phpunit-bridge": "^3 || ^7"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-main": "3.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Composer\\Semver\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Nils Adermann",
|
||||||
|
"email": "naderman@naderman.de",
|
||||||
|
"homepage": "http://www.naderman.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Jordi Boggiano",
|
||||||
|
"email": "j.boggiano@seld.be",
|
||||||
|
"homepage": "http://seld.be"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Rob Bast",
|
||||||
|
"email": "rob.bast@gmail.com",
|
||||||
|
"homepage": "http://robbast.nl"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Semver library that offers utilities, version constraint parsing and validation.",
|
||||||
|
"keywords": [
|
||||||
|
"semantic",
|
||||||
|
"semver",
|
||||||
|
"validation",
|
||||||
|
"versioning"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"irc": "ircs://irc.libera.chat:6697/composer",
|
||||||
|
"issues": "https://github.com/composer/semver/issues",
|
||||||
|
"source": "https://github.com/composer/semver/tree/3.4.4"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://packagist.com",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/composer",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2025-08-20T19:15:30+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "dasprid/enum",
|
"name": "dasprid/enum",
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
@@ -737,6 +893,67 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-03-06T22:45:56+00:00"
|
"time": "2025-03-06T22:45:56+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "ezyang/htmlpurifier",
|
||||||
|
"version": "v4.19.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/ezyang/htmlpurifier.git",
|
||||||
|
"reference": "b287d2a16aceffbf6e0295559b39662612b77fcf"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/b287d2a16aceffbf6e0295559b39662612b77fcf",
|
||||||
|
"reference": "b287d2a16aceffbf6e0295559b39662612b77fcf",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"cerdic/css-tidy": "^1.7 || ^2.0",
|
||||||
|
"simpletest/simpletest": "dev-master"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"cerdic/css-tidy": "If you want to use the filter 'Filter.ExtractStyleBlocks'.",
|
||||||
|
"ext-bcmath": "Used for unit conversion and imagecrash protection",
|
||||||
|
"ext-iconv": "Converts text to and from non-UTF-8 encodings",
|
||||||
|
"ext-tidy": "Used for pretty-printing HTML"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"library/HTMLPurifier.composer.php"
|
||||||
|
],
|
||||||
|
"psr-0": {
|
||||||
|
"HTMLPurifier": "library/"
|
||||||
|
},
|
||||||
|
"exclude-from-classmap": [
|
||||||
|
"/library/HTMLPurifier/Language/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"LGPL-2.1-or-later"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Edward Z. Yang",
|
||||||
|
"email": "admin@htmlpurifier.org",
|
||||||
|
"homepage": "http://ezyang.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Standards compliant HTML filter written in PHP",
|
||||||
|
"homepage": "http://htmlpurifier.org/",
|
||||||
|
"keywords": [
|
||||||
|
"html"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/ezyang/htmlpurifier/issues",
|
||||||
|
"source": "https://github.com/ezyang/htmlpurifier/tree/v4.19.0"
|
||||||
|
},
|
||||||
|
"time": "2025-10-17T16:34:55+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "facade/ignition-contracts",
|
"name": "facade/ignition-contracts",
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
@@ -2695,6 +2912,272 @@
|
|||||||
],
|
],
|
||||||
"time": "2024-12-08T08:18:47+00:00"
|
"time": "2024-12-08T08:18:47+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "maatwebsite/excel",
|
||||||
|
"version": "3.1.67",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/SpartnerNL/Laravel-Excel.git",
|
||||||
|
"reference": "e508e34a502a3acc3329b464dad257378a7edb4d"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/SpartnerNL/Laravel-Excel/zipball/e508e34a502a3acc3329b464dad257378a7edb4d",
|
||||||
|
"reference": "e508e34a502a3acc3329b464dad257378a7edb4d",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"composer/semver": "^3.3",
|
||||||
|
"ext-json": "*",
|
||||||
|
"illuminate/support": "5.8.*||^6.0||^7.0||^8.0||^9.0||^10.0||^11.0||^12.0",
|
||||||
|
"php": "^7.0||^8.0",
|
||||||
|
"phpoffice/phpspreadsheet": "^1.30.0",
|
||||||
|
"psr/simple-cache": "^1.0||^2.0||^3.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"laravel/scout": "^7.0||^8.0||^9.0||^10.0",
|
||||||
|
"orchestra/testbench": "^6.0||^7.0||^8.0||^9.0||^10.0",
|
||||||
|
"predis/predis": "^1.1"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"aliases": {
|
||||||
|
"Excel": "Maatwebsite\\Excel\\Facades\\Excel"
|
||||||
|
},
|
||||||
|
"providers": [
|
||||||
|
"Maatwebsite\\Excel\\ExcelServiceProvider"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Maatwebsite\\Excel\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Patrick Brouwers",
|
||||||
|
"email": "patrick@spartner.nl"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Supercharged Excel exports and imports in Laravel",
|
||||||
|
"keywords": [
|
||||||
|
"PHPExcel",
|
||||||
|
"batch",
|
||||||
|
"csv",
|
||||||
|
"excel",
|
||||||
|
"export",
|
||||||
|
"import",
|
||||||
|
"laravel",
|
||||||
|
"php",
|
||||||
|
"phpspreadsheet"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/SpartnerNL/Laravel-Excel/issues",
|
||||||
|
"source": "https://github.com/SpartnerNL/Laravel-Excel/tree/3.1.67"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://laravel-excel.com/commercial-support",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/patrickbrouwers",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2025-08-26T09:13:16+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "maennchen/zipstream-php",
|
||||||
|
"version": "3.2.1",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/maennchen/ZipStream-PHP.git",
|
||||||
|
"reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/682f1098a8fddbaf43edac2306a691c7ad508ec5",
|
||||||
|
"reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-mbstring": "*",
|
||||||
|
"ext-zlib": "*",
|
||||||
|
"php-64bit": "^8.3"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"brianium/paratest": "^7.7",
|
||||||
|
"ext-zip": "*",
|
||||||
|
"friendsofphp/php-cs-fixer": "^3.86",
|
||||||
|
"guzzlehttp/guzzle": "^7.5",
|
||||||
|
"mikey179/vfsstream": "^1.6",
|
||||||
|
"php-coveralls/php-coveralls": "^2.5",
|
||||||
|
"phpunit/phpunit": "^12.0",
|
||||||
|
"vimeo/psalm": "^6.0"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"guzzlehttp/psr7": "^2.4",
|
||||||
|
"psr/http-message": "^2.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"ZipStream\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Paul Duncan",
|
||||||
|
"email": "pabs@pablotron.org"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Jonatan Männchen",
|
||||||
|
"email": "jonatan@maennchen.ch"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Jesse Donat",
|
||||||
|
"email": "donatj@gmail.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "András Kolesár",
|
||||||
|
"email": "kolesar@kolesar.hu"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.",
|
||||||
|
"keywords": [
|
||||||
|
"stream",
|
||||||
|
"zip"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
|
||||||
|
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.1"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://github.com/maennchen",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2025-12-10T09:58:31+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "markbaker/complex",
|
||||||
|
"version": "3.0.2",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/MarkBaker/PHPComplex.git",
|
||||||
|
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
|
||||||
|
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^7.2 || ^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
|
||||||
|
"phpcompatibility/php-compatibility": "^9.3",
|
||||||
|
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
|
||||||
|
"squizlabs/php_codesniffer": "^3.7"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Complex\\": "classes/src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Mark Baker",
|
||||||
|
"email": "mark@lange.demon.co.uk"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PHP Class for working with complex numbers",
|
||||||
|
"homepage": "https://github.com/MarkBaker/PHPComplex",
|
||||||
|
"keywords": [
|
||||||
|
"complex",
|
||||||
|
"mathematics"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/MarkBaker/PHPComplex/issues",
|
||||||
|
"source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2"
|
||||||
|
},
|
||||||
|
"time": "2022-12-06T16:21:08+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "markbaker/matrix",
|
||||||
|
"version": "3.0.1",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/MarkBaker/PHPMatrix.git",
|
||||||
|
"reference": "728434227fe21be27ff6d86621a1b13107a2562c"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c",
|
||||||
|
"reference": "728434227fe21be27ff6d86621a1b13107a2562c",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^7.1 || ^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
|
||||||
|
"phpcompatibility/php-compatibility": "^9.3",
|
||||||
|
"phpdocumentor/phpdocumentor": "2.*",
|
||||||
|
"phploc/phploc": "^4.0",
|
||||||
|
"phpmd/phpmd": "2.*",
|
||||||
|
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
|
||||||
|
"sebastian/phpcpd": "^4.0",
|
||||||
|
"squizlabs/php_codesniffer": "^3.7"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Matrix\\": "classes/src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Mark Baker",
|
||||||
|
"email": "mark@demon-angel.eu"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PHP Class for working with matrices",
|
||||||
|
"homepage": "https://github.com/MarkBaker/PHPMatrix",
|
||||||
|
"keywords": [
|
||||||
|
"mathematics",
|
||||||
|
"matrix",
|
||||||
|
"vector"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/MarkBaker/PHPMatrix/issues",
|
||||||
|
"source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1"
|
||||||
|
},
|
||||||
|
"time": "2022-12-02T22:17:43+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "meilisearch/meilisearch-php",
|
"name": "meilisearch/meilisearch-php",
|
||||||
"version": "v1.13.0",
|
"version": "v1.13.0",
|
||||||
@@ -3475,6 +3958,112 @@
|
|||||||
},
|
},
|
||||||
"time": "2024-10-02T11:20:13+00:00"
|
"time": "2024-10-02T11:20:13+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "phpoffice/phpspreadsheet",
|
||||||
|
"version": "1.30.1",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
|
||||||
|
"reference": "fa8257a579ec623473eabfe49731de5967306c4c"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/fa8257a579ec623473eabfe49731de5967306c4c",
|
||||||
|
"reference": "fa8257a579ec623473eabfe49731de5967306c4c",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"composer/pcre": "^1||^2||^3",
|
||||||
|
"ext-ctype": "*",
|
||||||
|
"ext-dom": "*",
|
||||||
|
"ext-fileinfo": "*",
|
||||||
|
"ext-gd": "*",
|
||||||
|
"ext-iconv": "*",
|
||||||
|
"ext-libxml": "*",
|
||||||
|
"ext-mbstring": "*",
|
||||||
|
"ext-simplexml": "*",
|
||||||
|
"ext-xml": "*",
|
||||||
|
"ext-xmlreader": "*",
|
||||||
|
"ext-xmlwriter": "*",
|
||||||
|
"ext-zip": "*",
|
||||||
|
"ext-zlib": "*",
|
||||||
|
"ezyang/htmlpurifier": "^4.15",
|
||||||
|
"maennchen/zipstream-php": "^2.1 || ^3.0",
|
||||||
|
"markbaker/complex": "^3.0",
|
||||||
|
"markbaker/matrix": "^3.0",
|
||||||
|
"php": ">=7.4.0 <8.5.0",
|
||||||
|
"psr/http-client": "^1.0",
|
||||||
|
"psr/http-factory": "^1.0",
|
||||||
|
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"dealerdirect/phpcodesniffer-composer-installer": "dev-main",
|
||||||
|
"dompdf/dompdf": "^1.0 || ^2.0 || ^3.0",
|
||||||
|
"friendsofphp/php-cs-fixer": "^3.2",
|
||||||
|
"mitoteam/jpgraph": "^10.3",
|
||||||
|
"mpdf/mpdf": "^8.1.1",
|
||||||
|
"phpcompatibility/php-compatibility": "^9.3",
|
||||||
|
"phpstan/phpstan": "^1.1",
|
||||||
|
"phpstan/phpstan-phpunit": "^1.0",
|
||||||
|
"phpunit/phpunit": "^8.5 || ^9.0",
|
||||||
|
"squizlabs/php_codesniffer": "^3.7",
|
||||||
|
"tecnickcom/tcpdf": "^6.5"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"dompdf/dompdf": "Option for rendering PDF with PDF Writer",
|
||||||
|
"ext-intl": "PHP Internationalization Functions",
|
||||||
|
"mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
|
||||||
|
"mpdf/mpdf": "Option for rendering PDF with PDF Writer",
|
||||||
|
"tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Maarten Balliauw",
|
||||||
|
"homepage": "https://blog.maartenballiauw.be"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mark Baker",
|
||||||
|
"homepage": "https://markbakeruk.net"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Franck Lefevre",
|
||||||
|
"homepage": "https://rootslabs.net"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Erik Tilt"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Adrien Crivelli"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
|
||||||
|
"homepage": "https://github.com/PHPOffice/PhpSpreadsheet",
|
||||||
|
"keywords": [
|
||||||
|
"OpenXML",
|
||||||
|
"excel",
|
||||||
|
"gnumeric",
|
||||||
|
"ods",
|
||||||
|
"php",
|
||||||
|
"spreadsheet",
|
||||||
|
"xls",
|
||||||
|
"xlsx"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
|
||||||
|
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/1.30.1"
|
||||||
|
},
|
||||||
|
"time": "2025-10-26T16:01:04+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "phpoption/phpoption",
|
"name": "phpoption/phpoption",
|
||||||
"version": "1.9.3",
|
"version": "1.9.3",
|
||||||
@@ -10335,6 +10924,6 @@
|
|||||||
"platform": {
|
"platform": {
|
||||||
"php": "^8.2"
|
"php": "^8.2"
|
||||||
},
|
},
|
||||||
"platform-dev": {},
|
"platform-dev": [],
|
||||||
"plugin-api-version": "2.6.0"
|
"plugin-api-version": "2.6.0"
|
||||||
}
|
}
|
||||||
|
|||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
<?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('field_jobs', function (Blueprint $table) {
|
||||||
|
$table->boolean('added_activity')->default(false);
|
||||||
|
$table->timestamp('last_activity')->nullable()->default(null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('field_jobs', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('last_activity');
|
||||||
|
$table->dropColumn('added_activity');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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],
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -390,7 +390,7 @@ const buildVarsFromSelectedContract = () => {
|
|||||||
if (!uuid) return {};
|
if (!uuid) return {};
|
||||||
const c = (contractsForCase.value || []).find((x) => x.uuid === uuid);
|
const c = (contractsForCase.value || []).find((x) => x.uuid === uuid);
|
||||||
if (!c) return {};
|
if (!c) return {};
|
||||||
|
|
||||||
const vars = {
|
const vars = {
|
||||||
contract: {
|
contract: {
|
||||||
uuid: c.uuid,
|
uuid: c.uuid,
|
||||||
@@ -407,7 +407,7 @@ const buildVarsFromSelectedContract = () => {
|
|||||||
);
|
);
|
||||||
vars.contract.meta = hasStructuredMeta ? flattenMeta(c.meta) : c.meta;
|
vars.contract.meta = hasStructuredMeta ? flattenMeta(c.meta) : c.meta;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (c.account) {
|
if (c.account) {
|
||||||
vars.account = {
|
vars.account = {
|
||||||
reference: c.account.reference,
|
reference: c.account.reference,
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ const store = async () => {
|
|||||||
form
|
form
|
||||||
.transform((data) => ({
|
.transform((data) => ({
|
||||||
...data,
|
...data,
|
||||||
|
phone_view: props.phoneMode,
|
||||||
due_date: formatDateForSubmit(data.due_date),
|
due_date: formatDateForSubmit(data.due_date),
|
||||||
attachment_document_ids:
|
attachment_document_ids:
|
||||||
templateAllowsAttachments.value && data.attach_documents
|
templateAllowsAttachments.value && data.attach_documents
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import AppPhoneLayout from "@/Layouts/AppPhoneLayout.vue";
|
import AppPhoneLayout from "@/Layouts/AppPhoneLayout.vue";
|
||||||
|
import { Separator } from "reka-ui";
|
||||||
import { computed, ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -11,6 +12,9 @@ const items = computed(() => props.jobs || []);
|
|||||||
|
|
||||||
// Client filter options derived from jobs
|
// Client filter options derived from jobs
|
||||||
const clientFilter = ref("");
|
const clientFilter = ref("");
|
||||||
|
const listNonActivity = ref([]);
|
||||||
|
const listActivity = ref([]);
|
||||||
|
|
||||||
const clientOptions = computed(() => {
|
const clientOptions = computed(() => {
|
||||||
const map = new Map();
|
const map = new Map();
|
||||||
for (const job of items.value) {
|
for (const job of items.value) {
|
||||||
@@ -28,7 +32,7 @@ const clientOptions = computed(() => {
|
|||||||
const search = ref("");
|
const search = ref("");
|
||||||
const filteredJobs = computed(() => {
|
const filteredJobs = computed(() => {
|
||||||
const term = search.value.trim().toLowerCase();
|
const term = search.value.trim().toLowerCase();
|
||||||
return items.value.filter((job) => {
|
const filterList = items.value.filter((job) => {
|
||||||
// Filter by selected client (if any)
|
// Filter by selected client (if any)
|
||||||
if (clientFilter.value) {
|
if (clientFilter.value) {
|
||||||
const juuid = job?.contract?.client_case?.client?.uuid;
|
const juuid = job?.contract?.client_case?.client?.uuid;
|
||||||
@@ -50,6 +54,9 @@ const filteredJobs = computed(() => {
|
|||||||
refStr.includes(term) || nameStr.includes(term) || clientNameStr.includes(term)
|
refStr.includes(term) || nameStr.includes(term) || clientNameStr.includes(term)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
listNonActivity.value = filterList.filter((item) => !item.added_activity);
|
||||||
|
listActivity.value = filterList.filter((item) => !!item.added_activity);
|
||||||
|
return filterList;
|
||||||
});
|
});
|
||||||
|
|
||||||
function formatDateDMY(d) {
|
function formatDateDMY(d) {
|
||||||
@@ -125,10 +132,12 @@ function getCaseUuid(job) {
|
|||||||
Počisti
|
Počisti
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
||||||
<template v-if="filteredJobs.length">
|
<template v-if="filteredJobs.length">
|
||||||
|
<h2 class="py-4">Nove / Ne obdelane</h2>
|
||||||
|
<div class="grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<div
|
<div
|
||||||
v-for="job in filteredJobs"
|
v-for="job in listNonActivity"
|
||||||
:key="job.id"
|
:key="job.id"
|
||||||
class="bg-white rounded-lg shadow border p-3 sm:p-4"
|
class="bg-white rounded-lg shadow border p-3 sm:p-4"
|
||||||
>
|
>
|
||||||
@@ -180,9 +189,6 @@ function getCaseUuid(job) {
|
|||||||
<p class="text-sm text-gray-600 truncate">
|
<p class="text-sm text-gray-600 truncate">
|
||||||
Kontrakt: {{ job.contract?.reference || job.contract?.uuid }}
|
Kontrakt: {{ job.contract?.reference || job.contract?.uuid }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-gray-600">
|
|
||||||
Tip: {{ job.contract?.type?.name || "—" }}
|
|
||||||
</p>
|
|
||||||
<p
|
<p
|
||||||
class="text-sm text-gray-600"
|
class="text-sm text-gray-600"
|
||||||
v-if="
|
v-if="
|
||||||
@@ -205,14 +211,99 @@ function getCaseUuid(job) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="col-span-full bg-white rounded-lg shadow border p-6 text-center text-gray-600"
|
|
||||||
>
|
|
||||||
<span v-if="search">Ni zadetkov za podani filter.</span>
|
|
||||||
<span v-else>Trenutno nimate dodeljenih terenskih opravil.</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<h2 class="py-4">Obdelane pogodbe</h2>
|
||||||
|
<div class="grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<div
|
||||||
|
v-for="job in listActivity"
|
||||||
|
:key="job.id"
|
||||||
|
class="bg-white rounded-lg shadow border p-3 sm:p-4"
|
||||||
|
>
|
||||||
|
<div class="mb-4 flex gap-2">
|
||||||
|
<a
|
||||||
|
v-if="getCaseUuid(job)"
|
||||||
|
:href="
|
||||||
|
route('phone.case', {
|
||||||
|
client_case: getCaseUuid(job),
|
||||||
|
completed: props.view_mode === 'completed-today' ? 1 : undefined,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
class="inline-flex-1 flex-1 text-center px-3 py-2 rounded-md bg-blue-600 text-white text-sm hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Odpri primer
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
type="button"
|
||||||
|
disabled
|
||||||
|
class="inline-flex-1 flex-1 text-center px-3 py-2 rounded-md bg-gray-300 text-gray-600 text-sm cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Manjka primer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
Dodeljeno:
|
||||||
|
<span class="font-medium text-gray-700">{{
|
||||||
|
formatDateDMY(job.assigned_at)
|
||||||
|
}}</span>
|
||||||
|
</p>
|
||||||
|
<span
|
||||||
|
v-if="job.priority"
|
||||||
|
class="inline-block text-xs px-2 py-0.5 rounded bg-amber-100 text-amber-700"
|
||||||
|
>Prioriteta</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<p class="text-base sm:text-lg font-semibold text-gray-800">
|
||||||
|
{{ job.contract?.client_case?.person?.full_name || "—" }}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
Naročnik:
|
||||||
|
<span class="font-semibold text-gray-800">
|
||||||
|
{{ job.contract?.client_case?.client?.person?.full_name || "—" }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-600 truncate">
|
||||||
|
Kontrakt: {{ job.contract?.reference || job.contract?.uuid }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p
|
||||||
|
class="text-sm text-gray-600"
|
||||||
|
v-if="
|
||||||
|
job.contract?.account &&
|
||||||
|
job.contract.account.balance_amount !== null &&
|
||||||
|
job.contract.account.balance_amount !== undefined
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Odprto: {{ formatAmount(job.contract.account.balance_amount) }} €
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 text-sm text-gray-600">
|
||||||
|
<p>
|
||||||
|
<span class="font-medium">Naslov:</span>
|
||||||
|
{{ job.contract?.client_case?.person?.addresses?.[0]?.address || "—" }}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span class="font-medium">Telefon:</span>
|
||||||
|
{{ job.contract?.client_case?.person?.phones?.[0]?.nu || "—" }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 text-sm text-gray-600">
|
||||||
|
<p>
|
||||||
|
<span class="font-medium">Zadnja aktivnost:</span>
|
||||||
|
{{ formatDateDMY(job.last_activity) || "—" }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="col-span-full bg-white rounded-lg shadow border p-6 text-center text-gray-600"
|
||||||
|
>
|
||||||
|
<span v-if="search">Ni zadetkov za podani filter.</span>
|
||||||
|
<span v-else>Trenutno nimate dodeljenih terenskih opravil.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user