24 Commits

Author SHA1 Message Date
Simon Pocrnjič 8147fedd04 workflow fixed multiselect, combobox width was not limited when selecting desicisions 2026-02-01 19:35:38 +01:00
Simon Pocrnjič b1c531bb70 updated sms package creator, removed result for segments with exeption true, replaced some ui elements 2026-02-01 13:43:18 +01:00
Simon Pocrnjič 9cc1b7072c added download button for orignal import csv file 2026-02-01 09:22:34 +01:00
Simon Pocrnjič 2968bcf3f8 fixed some bugs with dialog and viewing docx works again 2026-01-29 19:14:35 +01:00
Simon Pocrnjič ad0f7a7a01 checkmark for confirmed phone numbers 2026-01-28 21:32:13 +01:00
Simon Pocrnjič 368b0a7cf7 fixed some weird problem with special characters 2026-01-28 20:46:52 +01:00
Simon Pocrnjič aa375ce0da bug fixes, sms, smaller screens elements were overlaping parent containers and updated document viewer 2026-01-28 20:12:26 +01:00
Simon Pocrnjič 340e16c610 Increased post_code length varchar. 2026-01-27 21:07:48 +01:00
Simon Pocrnjič 33b236d881 Small changes 2026-01-27 19:49:09 +01:00
sipo fb7704027b Merge pull request 'production' (#1) from production into master
Reviewed-on: #1
2026-01-27 18:02:43 +00:00
Simon Pocrnjič e5902706f1 Merge remote-tracking branch 'origin/master' into Development 2026-01-27 18:42:27 +01:00
Simon Pocrnjič 229c100cc4 again added fix 2026-01-27 18:10:12 +01:00
Simon Pocrnjič 9a4897bf0c fixed normalizing decimal upsertAccount importer 2026-01-27 18:04:50 +01:00
Simon Pocrnjič d779e4d7a1 Merge branch 'master' into Development 2026-01-21 18:32:28 +01:00
Simon Pocrnjič b2a9350d0f Fixed import check for existing address 2026-01-21 18:31:54 +01:00
Simon Pocrnjič d64a67cf76 Visual changes to profile page 2026-01-19 19:24:41 +01:00
Simon Pocrnjič 068bbdf583 Updated Application icon and notifcation pagination items per page, and updated NotificationsBell 2026-01-18 19:49:48 +01:00
Simon Pocrnjič cc4c07717e Changes 2026-01-18 18:21:41 +01:00
Simon Pocrnjič 28f28be1b8 Merge remote-tracking branch 'origin/master' into Development 2026-01-17 18:51:39 +01:00
Simon Pocrnjič 27bdb942ab Changed Import processor removed getting existing account by reference and just keep contract_id and active true 2026-01-17 17:33:19 +01:00
Simon Pocrnjič ebf9f29200 Merge remote-tracking branch 'origin/master' into Development 2026-01-17 16:06:17 +01:00
Simon Pocrnjič 7eaab16e30 added new permission mass-archive instead if limiting mass archiving to admin users 2026-01-15 21:35:53 +01:00
Simon Pocrnjič 6a2dd860fa Mass archiving added to segment view show 2026-01-15 21:16:26 +01:00
Simon Pocrnjič 091fb07646 Update Person grid view vue and reverted import v2 back to v1 (v2 not production ready) 2026-01-15 20:38:08 +01:00
75 changed files with 4018 additions and 1989 deletions
@@ -12,6 +12,7 @@
use App\Models\SmsTemplate; use App\Models\SmsTemplate;
use App\Services\Contact\PhoneSelector; use App\Services\Contact\PhoneSelector;
use App\Services\Sms\SmsService; use App\Services\Sms\SmsService;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Bus;
@@ -23,9 +24,11 @@ class PackageController extends Controller
{ {
public function index(Request $request): Response public function index(Request $request): Response
{ {
$perPage = $request->input('per_page') ?? 25;
$packages = Package::query() $packages = Package::query()
->latest('id') ->latest('id')
->paginate(25); ->paginate($perPage);
return Inertia::render('Admin/Packages/Index', [ return Inertia::render('Admin/Packages/Index', [
'packages' => $packages, 'packages' => $packages,
@@ -48,6 +51,7 @@ public function create(Request $request): Response
->get(['id', 'name', 'content']); ->get(['id', 'name', 'content']);
$segments = \App\Models\Segment::query() $segments = \App\Models\Segment::query()
->where('active', true) ->where('active', true)
->where('exclude', false)
->orderBy('name') ->orderBy('name')
->get(['id', 'name']); ->get(['id', 'name']);
// Provide a lightweight list of recent clients with person names for filtering // Provide a lightweight list of recent clients with person names for filtering
@@ -319,7 +323,6 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
$request->validate([ $request->validate([
'segment_id' => ['nullable', 'integer', 'exists:segments,id'], 'segment_id' => ['nullable', 'integer', 'exists:segments,id'],
'q' => ['nullable', 'string'], 'q' => ['nullable', 'string'],
'client_id' => ['nullable', 'integer', 'exists:clients,id'], 'client_id' => ['nullable', 'integer', 'exists:clients,id'],
'only_mobile' => ['nullable', 'boolean'], 'only_mobile' => ['nullable', 'boolean'],
'only_validated' => ['nullable', 'boolean'], 'only_validated' => ['nullable', 'boolean'],
@@ -331,12 +334,12 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
$segmentId = $request->input('segment_id') ? (int) $request->input('segment_id') : null; $segmentId = $request->input('segment_id') ? (int) $request->input('segment_id') : null;
$query = Contract::query() $query = Contract::query()
->with([ ->with([
'clientCase.person.phones', 'clientCase.person.phones',
'clientCase.client.person', 'clientCase.client.person',
'account', 'account',
'segments:id,name',
]) ])
->select('contracts.*') ->select('contracts.*')
->latest('contracts.id'); ->latest('contracts.id');
@@ -348,6 +351,15 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
->where('contract_segment.segment_id', '=', $segmentId) ->where('contract_segment.segment_id', '=', $segmentId)
->where('contract_segment.active', true); ->where('contract_segment.active', true);
}); });
} else {
// Only include contracts that have at least one active, non-excluded segment
$query->whereExists(fn ($exist) => $exist->select(\DB::raw(1))
->from('contract_segment')
->join('segments', 'segments.id', '=', 'contract_segment.segment_id')
->where('contract_segment.active', true)
->where('segments.exclude', false)
->whereColumn('contract_segment.contract_id', 'contracts.id')
);
} }
if ($q = trim((string) $request->input('q'))) { if ($q = trim((string) $request->input('q'))) {
@@ -397,13 +409,14 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
}); });
} }
$contracts = $query->get(); $contracts = $query->limit(500)->get();
$data = collect($contracts)->map(function (Contract $contract) use ($selector) { $data = collect($contracts)->map(function (Contract $contract) use ($selector) {
$person = $contract->clientCase?->person; $person = $contract->clientCase?->person;
$selected = $person ? $selector->selectForPerson($person) : ['phone' => null, 'reason' => 'no_person']; $selected = $person ? $selector->selectForPerson($person) : ['phone' => null, 'reason' => 'no_person'];
$phone = $selected['phone']; $phone = $selected['phone'];
$clientPerson = $contract->clientCase?->client?->person; $clientPerson = $contract->clientCase?->client?->person;
$segment = collect($contract->segments)->last();
return [ return [
'id' => $contract->id, 'id' => $contract->id,
@@ -421,6 +434,7 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
'uuid' => $person?->uuid, 'uuid' => $person?->uuid,
'full_name' => $person?->full_name, 'full_name' => $person?->full_name,
], ],
'segment' => $segment,
// Stranka: the client person // Stranka: the client person
'client' => $clientPerson ? [ 'client' => $clientPerson ? [
'id' => $contract->clientCase?->client?->id, 'id' => $contract->clientCase?->client?->id,
@@ -438,7 +452,7 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
}); });
return response()->json([ return response()->json([
'data' => $data 'data' => $data,
]); ]);
} }
@@ -1079,6 +1079,156 @@ public function archiveContract(ClientCase $clientCase, string $uuid, Request $r
); );
} }
/**
* Archive multiple contracts in a batch operation
*/
public function archiveBatch(Request $request)
{
$validated = $request->validate([
'contracts' => 'required|array',
'contracts.*' => 'required|uuid|exists:contracts,uuid',
'reactivate' => 'boolean',
]);
$reactivate = $validated['reactivate'] ?? false;
// Get archive setting
$setting = \App\Models\ArchiveSetting::query()
->where('enabled', true)
->whereIn('strategy', ['immediate', 'manual'])
->where('reactivate', $reactivate)
->orderByDesc('id')
->first();
if (! $setting) {
\Log::warning('No archive settings found for batch archive');
return back()->with('flash', [
'error' => 'No archive settings found',
]);
}
$executor = app(\App\Services\Archiving\ArchiveExecutor::class);
$successCount = 0;
$skippedCount = 0;
$errors = [];
foreach ($validated['contracts'] as $contractUuid) {
try {
$contract = Contract::where('uuid', $contractUuid)->firstOrFail();
// Skip if contract is already archived (active = 0)
if (!$contract->active) {
$skippedCount++;
continue;
}
$clientCase = $contract->clientCase;
$context = [
'contract_id' => $contract->id,
'client_case_id' => $clientCase->id,
'account_id' => $contract->account->id ?? null,
];
// Execute archive setting
$executor->executeSetting($setting, $context, \Auth::id());
// Transaction for segment updates and activity logging
\DB::transaction(function () use ($contract, $clientCase, $setting, $reactivate) {
// Create activity log
if ($setting->action_id && $setting->decision_id) {
$activityData = [
'client_case_id' => $clientCase->id,
'action_id' => $setting->action_id,
'decision_id' => $setting->decision_id,
'note' => ($reactivate)
? "Ponovno aktivirana pogodba $contract->reference"
: "Arhivirana pogodba $contract->reference",
];
try {
\App\Models\Activity::create($activityData);
} catch (Exception $e) {
\Log::warning('Activity could not be created during batch archive');
}
}
// Move to archive segment if specified
if ($setting->segment_id) {
$segmentId = $setting->segment_id;
// Deactivate all current segments
$contract->segments()
->allRelatedIds()
->map(fn (int $val) => $contract->segments()->updateExistingPivot($val, [
'active' => false,
'updated_at' => now(),
]));
// Activate archive segment
if ($contract->attachedSegments()->find($segmentId)->pluck('id')->isNotEmpty()) {
$contract->attachedSegments()->updateExistingPivot($segmentId, [
'active' => true,
'updated_at' => now(),
]);
} else {
$contract->segments()->attach($segmentId, [
'active' => true,
'created_at' => now(),
'updated_at' => now(),
]);
}
}
// Cancel pending field jobs
$contract->fieldJobs()
->whereNull('completed_at')
->whereNull('cancelled_at')
->update([
'cancelled_at' => date('Y-m-d'),
'updated_at' => now(),
]);
});
$successCount++;
} catch (Exception $e) {
\Log::error('Error archiving contract in batch', [
'uuid' => $contractUuid,
'error' => $e->getMessage(),
]);
$errors[] = [
'uuid' => $contractUuid,
'error' => $e->getMessage(),
];
}
}
if (count($errors) > 0) {
$message = "Archived $successCount contracts";
if ($skippedCount > 0) {
$message .= ", skipped $skippedCount already archived";
}
$message .= ", " . count($errors) . " failed";
return back()->with('flash', [
'error' => $message,
'details' => $errors,
]);
}
$message = $reactivate
? "Successfully reactivated $successCount contracts"
: "Successfully archived $successCount contracts";
if ($skippedCount > 0) {
$message .= " ($skippedCount already archived)";
}
return back()->with('flash', [
'success' => $message,
]);
}
/** /**
* Emergency: recreate a missing / soft-deleted person for a client case and re-link related data. * Emergency: recreate a missing / soft-deleted person for a client case and re-link related data.
*/ */
+2 -2
View File
@@ -27,7 +27,7 @@ public function index(Client $client, Request $request)
->where('person.full_name', 'ilike', '%'.$search.'%') ->where('person.full_name', 'ilike', '%'.$search.'%')
->groupBy('clients.id'); ->groupBy('clients.id');
}) })
->where('clients.active', 1) //->where('clients.active', 1)
// Use LEFT JOINs for aggregated data to avoid subqueries // Use LEFT JOINs for aggregated data to avoid subqueries
->leftJoin('client_cases', 'client_cases.client_id', '=', 'clients.id') ->leftJoin('client_cases', 'client_cases.client_id', '=', 'clients.id')
->leftJoin('contracts', function ($join) { ->leftJoin('contracts', function ($join) {
@@ -51,7 +51,7 @@ public function index(Client $client, Request $request)
return Inertia::render('Client/Index', [ return Inertia::render('Client/Index', [
'clients' => $query 'clients' => $query
->paginate($request->integer('per_page', 15)) ->paginate($request->integer('per_page', default: 100))
->withQueryString(), ->withQueryString(),
'filters' => $request->only(['search']), 'filters' => $request->only(['search']),
]); ]);
+4 -2
View File
@@ -62,7 +62,8 @@ public function index(Request $request)
$unassignedClients = $unassignedContracts->get() $unassignedClients = $unassignedContracts->get()
->pluck('clientCase.client') ->pluck('clientCase.client')
->filter() ->filter()
->unique('id'); ->unique('id')
->values();
$assignedContracts = Contract::query() $assignedContracts = Contract::query()
@@ -98,7 +99,8 @@ public function index(Request $request)
$assignedClients = $assignedContracts->get() $assignedClients = $assignedContracts->get()
->pluck('clientCase.client') ->pluck('clientCase.client')
->filter() ->filter()
->unique('id'); ->unique('id')
->values();
$users = User::query()->orderBy('name')->get(['id', 'name']); $users = User::query()->orderBy('name')->get(['id', 'name']);
+18 -5
View File
@@ -9,7 +9,6 @@
use App\Models\ImportEvent; use App\Models\ImportEvent;
use App\Models\ImportTemplate; use App\Models\ImportTemplate;
use App\Services\CsvImportService; use App\Services\CsvImportService;
use App\Services\Import\ImportServiceV2;
use App\Services\Import\ImportSimulationServiceV2; use App\Services\Import\ImportSimulationServiceV2;
use App\Services\ImportProcessor; use App\Services\ImportProcessor;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -184,12 +183,13 @@ public function store(Request $request)
} }
// Kick off processing of an import - simple synchronous step for now // Kick off processing of an import - simple synchronous step for now
public function process(Import $import, Request $request, ImportServiceV2 $processor) public function process(Import $import, Request $request, ImportProcessor $processor)
{ {
$import->update(['status' => 'validating', 'started_at' => now()]); $import->update(['status' => 'validating', 'started_at' => now()]);
try { try {
$result = $processor->process($import, user: $request->user()); $result = $processor->process($import, user: $request->user());
return response()->json($result); return response()->json($result);
} catch (\Throwable $e) { } catch (\Throwable $e) {
\Log::error('Import processing failed', [ \Log::error('Import processing failed', [
@@ -202,7 +202,7 @@ public function process(Import $import, Request $request, ImportServiceV2 $proce
return response()->json([ return response()->json([
'success' => false, 'success' => false,
'message' => 'Import processing failed: ' . $e->getMessage(), 'message' => 'Import processing failed: '.$e->getMessage(),
], 500); ], 500);
} }
} }
@@ -712,8 +712,6 @@ public function simulatePayments(Import $import, Request $request)
* templates. For payments templates, payment-specific summaries/entities will be included * templates. For payments templates, payment-specific summaries/entities will be included
* automatically by the simulation service when mappings contain the payment root. * automatically by the simulation service when mappings contain the payment root.
* *
* @param Import $import
* @param Request $request
* @return \Illuminate\Http\JsonResponse * @return \Illuminate\Http\JsonResponse
*/ */
public function simulate(Import $import, Request $request) public function simulate(Import $import, Request $request)
@@ -829,4 +827,19 @@ public function destroy(Request $request, Import $import)
return back()->with('success', 'Import deleted successfully'); return back()->with('success', 'Import deleted successfully');
} }
// Download the original import file
public function download(Import $import)
{
// Verify file exists
if (! $import->disk || ! $import->path || ! Storage::disk($import->disk)->exists($import->path)) {
return response()->json([
'error' => 'File not found',
], 404);
}
$fileName = $import->original_name ?? 'import_'.$import->uuid;
return Storage::disk($import->disk)->download($import->path, $fileName);
}
} }
@@ -19,7 +19,7 @@ public function unread(Request $request)
} }
$today = now()->toDateString(); $today = now()->toDateString();
$perPage = max(1, min(100, (int) $request->integer('perPage', 15))); $perPage = max(1, min(100, (int) $request->integer('per_page', 15)));
$search = trim((string) $request->input('search', '')); $search = trim((string) $request->input('search', ''));
$clientUuid = trim((string) $request->input('client', '')); $clientUuid = trim((string) $request->input('client', ''));
$clientId = null; $clientId = null;
+2 -2
View File
@@ -118,10 +118,10 @@ public function handle(SmsService $sms): void
if ($template && $case) { if ($template && $case) {
$note = ''; $note = '';
if ($log->status === 'sent') { if ($log->status === 'sent') {
$note = sprintf('Št: %s | Telo: %s', (string) $this->to, (string) $this->content); $note = sprintf('Tel: %s | Telo: %s', (string) $this->to, (string) $this->content);
} elseif ($log->status === 'failed') { } elseif ($log->status === 'failed') {
$note = sprintf( $note = sprintf(
'Št: %s | Telo: %s | Napaka: %s', 'Tel: %s | Telo: %s | Napaka: %s',
(string) $this->to, (string) $this->to,
(string) $this->content, (string) $this->content,
'SMS ni bil poslan!' 'SMS ni bil poslan!'
+2
View File
@@ -6,10 +6,12 @@
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Account extends Model class Account extends Model
{ {
/** @use HasFactory<\Database\Factories\Person/AccountFactory> */ /** @use HasFactory<\Database\Factories\Person/AccountFactory> */
use SoftDeletes;
use HasFactory; use HasFactory;
protected $fillable = [ protected $fillable = [
@@ -103,7 +103,7 @@ public function process(Import $import, array $mapped, array $raw, array $contex
$payload = $this->buildPayloadForAddress($address); $payload = $this->buildPayloadForAddress($address);
$payload['person_id'] = $personId; $payload['person_id'] = $personId;
$addressEntity = new \App\Models\Person\PersonAddress; $addressEntity = new PersonAddress;
$addressEntity->fill($payload); $addressEntity->fill($payload);
$addressEntity->save(); $addressEntity->save();
@@ -129,7 +129,7 @@ public function process(Import $import, array $mapped, array $raw, array $contex
protected function resolveAddress(string $address, int $personId): mixed protected function resolveAddress(string $address, int $personId): mixed
{ {
return \App\Models\Person\PersonAddress::where('person_id', $personId) return PersonAddress::where('person_id', $personId)
->where('address', $address) ->where('address', $address)
->first(); ->first();
} }
+18 -6
View File
@@ -1633,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();
@@ -1656,6 +1656,10 @@ 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 // Convert empty string to 0 for amount fields
if (in_array($field, ['balance_amount', 'initial_amount'], true) && ($value === '' || $value === null)) { if (in_array($field, ['balance_amount', 'initial_amount'], true) && ($value === '' || $value === null)) {
@@ -1689,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);
@@ -1699,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;
@@ -3194,7 +3206,7 @@ private function upsertAddress(int $personId, array $addrData, $mappings): array
->first();*/ ->first();*/
// Build search query combining address, post_code and city // Build search query combining address, post_code and city
$searchParts = [$addrData['post_code']]; $searchParts = [$addrData['address']];
if (!empty($addrData['post_code'])) { if (!empty($addrData['post_code'])) {
$searchParts[] = $addrData['post_code']; $searchParts[] = $addrData['post_code'];
} }
@@ -3204,7 +3216,7 @@ private function upsertAddress(int $personId, array $addrData, $mappings): array
$searchQuery = implode(' ', $searchParts); $searchQuery = implode(' ', $searchParts);
// Use fulltext search (GIN index optimized) // Use fulltext search (GIN index optimized)
$existing = PersonAddress::where('person_id', $personId) $existing = PersonAddress::query()->where('person_id', $personId)
->whereRaw("search_vector @@ plainto_tsquery('simple', ?)", [$searchQuery]) ->whereRaw("search_vector @@ plainto_tsquery('simple', ?)", [$searchQuery])
->first(); ->first();
+1 -1
View File
@@ -60,7 +60,7 @@
'features' => [ 'features' => [
// Features::termsAndPrivacyPolicy(), // Features::termsAndPrivacyPolicy(),
// Features::profilePhotos(), // Features::profilePhotos(),
Features::api(), // Features::api(),
// Features::teams(['invitations' => true]), // Features::teams(['invitations' => true]),
Features::accountDeletion(), Features::accountDeletion(),
], ],
@@ -0,0 +1,49 @@
<?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_addresses', function (Blueprint $table) {
$table->dropIndex('person_addresses_search_vector_idx');
$table->dropColumn('search_vector');
$table->string('post_code', 50)->nullable()->change();
});
// 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)');
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('person_addresses', function (Blueprint $table) {
$table->string('post_code', 20)->change();
});
}
};
+25 -25
View File
@@ -1,42 +1,42 @@
<script setup> <script setup>
import { ref, reactive, nextTick } from 'vue'; import { ref, reactive, nextTick } from "vue";
import DialogModal from './DialogModal.vue'; import DialogModal from "./DialogModal.vue";
import InputError from './InputError.vue'; import InputError from "./InputError.vue";
import PrimaryButton from './PrimaryButton.vue'; import PrimaryButton from "./PrimaryButton.vue";
import SecondaryButton from './SecondaryButton.vue'; import SecondaryButton from "./SecondaryButton.vue";
import TextInput from './TextInput.vue'; import { Input } from "@/Components/ui/input";
const emit = defineEmits(['confirmed']); const emit = defineEmits(["confirmed"]);
defineProps({ defineProps({
title: { title: {
type: String, type: String,
default: 'Confirm Password', default: "Confirm Password",
}, },
content: { content: {
type: String, type: String,
default: 'For your security, please confirm your password to continue.', default: "For your security, please confirm your password to continue.",
}, },
button: { button: {
type: String, type: String,
default: 'Confirm', default: "Confirm",
}, },
}); });
const confirmingPassword = ref(false); const confirmingPassword = ref(false);
const form = reactive({ const form = reactive({
password: '', password: "",
error: '', error: "",
processing: false, processing: false,
}); });
const passwordInput = ref(null); const passwordInput = ref(null);
const startConfirmingPassword = () => { const startConfirmingPassword = () => {
axios.get(route('password.confirmation')).then(response => { axios.get(route("password.confirmation")).then((response) => {
if (response.data.confirmed) { if (response.data.confirmed) {
emit('confirmed'); emit("confirmed");
} else { } else {
confirmingPassword.value = true; confirmingPassword.value = true;
@@ -48,15 +48,17 @@ const startConfirmingPassword = () => {
const confirmPassword = () => { const confirmPassword = () => {
form.processing = true; form.processing = true;
axios.post(route('password.confirm'), { axios
.post(route("password.confirm"), {
password: form.password, password: form.password,
}).then(() => { })
.then(() => {
form.processing = false; form.processing = false;
closeModal(); closeModal();
nextTick().then(() => emit('confirmed')); nextTick().then(() => emit("confirmed"));
})
}).catch(error => { .catch((error) => {
form.processing = false; form.processing = false;
form.error = error.response.data.errors.password[0]; form.error = error.response.data.errors.password[0];
passwordInput.value.focus(); passwordInput.value.focus();
@@ -65,8 +67,8 @@ const confirmPassword = () => {
const closeModal = () => { const closeModal = () => {
confirmingPassword.value = false; confirmingPassword.value = false;
form.password = ''; form.password = "";
form.error = ''; form.error = "";
}; };
</script> </script>
@@ -85,7 +87,7 @@ const closeModal = () => {
{{ content }} {{ content }}
<div class="mt-4"> <div class="mt-4">
<TextInput <Input
ref="passwordInput" ref="passwordInput"
v-model="form.password" v-model="form.password"
type="password" type="password"
@@ -100,9 +102,7 @@ const closeModal = () => {
</template> </template>
<template #footer> <template #footer>
<SecondaryButton @click="closeModal"> <SecondaryButton @click="closeModal"> Cancel </SecondaryButton>
Cancel
</SecondaryButton>
<PrimaryButton <PrimaryButton
class="ms-3" class="ms-3"
@@ -69,7 +69,7 @@ const maxWidthClass = computed(() => {
<template> <template>
<Dialog v-model:open="open"> <Dialog v-model:open="open">
<DialogContent :class="maxWidthClass"> <DialogContent class="overflow-auto max-h-3/4" :class="maxWidthClass">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -6,34 +6,40 @@ import {
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/Components/ui/dialog'; } from "@/Components/ui/dialog";
import { Button } from '@/Components/ui/button'; import { Button } from "@/Components/ui/button";
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { faTrashCan, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'; import { faTrashCan, faTriangleExclamation } from "@fortawesome/free-solid-svg-icons";
import { ref, watch } from 'vue'; import { ref, watch } from "vue";
const props = defineProps({ const props = defineProps({
show: { type: Boolean, default: false }, show: { type: Boolean, default: false },
title: { type: String, default: 'Izbriši' }, title: { type: String, default: "Izbriši" },
message: { type: String, default: 'Ali ste prepričani, da želite izbrisati ta element?' }, message: {
confirmText: { type: String, default: 'Izbriši' }, type: String,
cancelText: { type: String, default: 'Prekliči' }, default: "Ali ste prepričani, da želite izbrisati ta element?",
},
confirmText: { type: String, default: "Izbriši" },
cancelText: { type: String, default: "Prekliči" },
processing: { type: Boolean, default: false }, processing: { type: Boolean, default: false },
itemName: { type: String, default: null }, // Optional name to show in confirmation itemName: { type: String, default: null }, // Optional name to show in confirmation
}); });
const emit = defineEmits(['update:show', 'close', 'confirm']); const emit = defineEmits(["update:show", "close", "confirm"]);
const open = ref(props.show); const open = ref(props.show);
watch(() => props.show, (newVal) => { watch(
() => props.show,
(newVal) => {
open.value = newVal; open.value = newVal;
}); }
);
watch(open, (newVal) => { watch(open, (newVal) => {
emit('update:show', newVal); emit("update:show", newVal);
if (!newVal) { if (!newVal) {
emit('close'); emit("close");
} }
}); });
@@ -42,7 +48,7 @@ const onClose = () => {
}; };
const onConfirm = () => { const onConfirm = () => {
emit('confirm'); emit("confirm");
}; };
</script> </script>
@@ -59,8 +65,13 @@ const onConfirm = () => {
<DialogDescription> <DialogDescription>
<div class="flex items-start gap-4 pt-4"> <div class="flex items-start gap-4 pt-4">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<div class="flex items-center justify-center h-12 w-12 rounded-full bg-red-100"> <div
<FontAwesomeIcon :icon="faTriangleExclamation" class="h-6 w-6 text-red-600" /> class="flex items-center justify-center h-12 w-12 rounded-full bg-red-100"
>
<FontAwesomeIcon
:icon="faTriangleExclamation"
class="h-6 w-6 text-red-600"
/>
</div> </div>
</div> </div>
<div class="flex-1 space-y-2"> <div class="flex-1 space-y-2">
@@ -70,9 +81,7 @@ const onConfirm = () => {
<p v-if="itemName" class="text-sm font-medium text-gray-900"> <p v-if="itemName" class="text-sm font-medium text-gray-900">
{{ itemName }} {{ itemName }}
</p> </p>
<p class="text-sm text-gray-500"> <p class="text-sm text-gray-500">Ta dejanje ni mogoče razveljaviti.</p>
Ta dejanje ni mogoče razveljaviti.
</p>
</div> </div>
</div> </div>
</DialogDescription> </DialogDescription>
@@ -82,15 +91,10 @@ const onConfirm = () => {
<Button variant="outline" @click="onClose" :disabled="processing"> <Button variant="outline" @click="onClose" :disabled="processing">
{{ cancelText }} {{ cancelText }}
</Button> </Button>
<Button <Button variant="destructive" @click="onConfirm" :disabled="processing">
variant="destructive"
@click="onConfirm"
:disabled="processing"
>
{{ confirmText }} {{ confirmText }}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</template> </template>
@@ -69,7 +69,7 @@ const maxWidthClass = computed(() => {
<template> <template>
<Dialog v-model:open="open"> <Dialog v-model:open="open">
<DialogContent :class="maxWidthClass"> <DialogContent class="overflow-auto max-h-3/4" :class="maxWidthClass">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -1,15 +1,27 @@
<script setup> <script setup>
import CreateDialog from '@/Components/Dialogs/CreateDialog.vue' import CreateDialog from "@/Components/Dialogs/CreateDialog.vue";
import { useForm } from 'vee-validate' import { useForm } from "vee-validate";
import { toTypedSchema } from '@vee-validate/zod' import { toTypedSchema } from "@vee-validate/zod";
import * as z from 'zod' import * as z from "zod";
import { ref, watch } from 'vue' import { ref, watch } from "vue";
import { router } from '@inertiajs/vue3' import { router } from "@inertiajs/vue3";
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/Components/ui/form' import {
import { Input } from '@/Components/ui/input' FormControl,
import { Textarea } from '@/Components/ui/textarea' FormField,
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/Components/ui/select' FormItem,
import { Switch } from '@/Components/ui/switch' FormLabel,
FormMessage,
} from "@/Components/ui/form";
import { Input } from "@/Components/ui/input";
import { Textarea } from "@/Components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
import { Switch } from "@/Components/ui/switch";
const props = defineProps({ const props = defineProps({
show: { type: Boolean, default: false }, show: { type: Boolean, default: false },
@@ -17,112 +29,128 @@ const props = defineProps({
// Optional list of contracts to allow attaching the document directly to a contract // Optional list of contracts to allow attaching the document directly to a contract
// Each item should have at least: { uuid, reference } // Each item should have at least: { uuid, reference }
contracts: { type: Array, default: () => [] }, contracts: { type: Array, default: () => [] },
}) });
const emit = defineEmits(['close', 'uploaded']) const emit = defineEmits(["close", "uploaded"]);
const MAX_SIZE = 25 * 1024 * 1024 // 25MB const MAX_SIZE = 25 * 1024 * 1024; // 25MB
const ALLOWED_EXTS = ['doc','docx','pdf','txt','csv','xls','xlsx','jpeg','jpg','png'] const ALLOWED_EXTS = [
"doc",
"docx",
"pdf",
"txt",
"csv",
"xls",
"xlsx",
"jpeg",
"jpg",
"png",
];
const formSchema = toTypedSchema(z.object({ const formSchema = toTypedSchema(
name: z.string().min(1, 'Ime je obvezno'), z.object({
name: z.string().min(1, "Ime je obvezno"),
description: z.string().optional(), description: z.string().optional(),
file: z.instanceof(File).refine((file) => file.size > 0, 'Izberite datoteko'), file: z.instanceof(File).refine((file) => file.size > 0, "Izberite datoteko"),
is_public: z.boolean().default(true), is_public: z.boolean().default(true),
contract_uuid: z.string().nullable().optional(), contract_uuid: z.string().nullable().optional(),
})) })
);
const form = useForm({ const form = useForm({
validationSchema: formSchema, validationSchema: formSchema,
initialValues: { initialValues: {
name: '', name: "",
description: '', description: "",
file: null, file: null,
is_public: true, is_public: true,
contract_uuid: null, contract_uuid: null,
}, },
}) });
const localError = ref('') const localError = ref("");
watch(() => props.show, (v) => { watch(
if (!v) return () => props.show,
localError.value = '' (v) => {
form.resetForm() if (!v) return;
}) localError.value = "";
form.resetForm();
}
);
const onFileChange = (e) => { const onFileChange = (e) => {
localError.value = '' localError.value = "";
const f = e.target.files?.[0] const f = e.target.files?.[0];
if (!f) { if (!f) {
form.setFieldValue('file', null) form.setFieldValue("file", null);
return return;
} }
const ext = (f.name.split('.').pop() || '').toLowerCase() const ext = (f.name.split(".").pop() || "").toLowerCase();
if (!ALLOWED_EXTS.includes(ext)) { if (!ALLOWED_EXTS.includes(ext)) {
localError.value = 'Nepodprta vrsta datoteke. Dovoljeno: ' + ALLOWED_EXTS.join(', ') localError.value = "Nepodprta vrsta datoteke. Dovoljeno: " + ALLOWED_EXTS.join(", ");
e.target.value = '' e.target.value = "";
form.setFieldValue('file', null) form.setFieldValue("file", null);
return return;
} }
if (f.size > MAX_SIZE) { if (f.size > MAX_SIZE) {
localError.value = 'Datoteka je prevelika. Največja velikost je 25MB.' localError.value = "Datoteka je prevelika. Največja velikost je 25MB.";
e.target.value = '' e.target.value = "";
form.setFieldValue('file', null) form.setFieldValue("file", null);
return return;
} }
form.setFieldValue('file', f) form.setFieldValue("file", f);
if (!form.values.name) { if (!form.values.name) {
form.setFieldValue('name', f.name.replace(/\.[^.]+$/, '')) form.setFieldValue("name", f.name.replace(/\.[^.]+$/, ""));
} }
} };
const submit = form.handleSubmit(async (values) => { const submit = form.handleSubmit(async (values) => {
localError.value = '' localError.value = "";
if (!values.file) { if (!values.file) {
localError.value = 'Prosimo izberite datoteko.' localError.value = "Prosimo izberite datoteko.";
return return;
} }
const ext = (values.file.name.split('.').pop() || '').toLowerCase() const ext = (values.file.name.split(".").pop() || "").toLowerCase();
if (!ALLOWED_EXTS.includes(ext)) { if (!ALLOWED_EXTS.includes(ext)) {
localError.value = 'Nepodprta vrsta datoteke. Dovoljeno: ' + ALLOWED_EXTS.join(', ') localError.value = "Nepodprta vrsta datoteke. Dovoljeno: " + ALLOWED_EXTS.join(", ");
return return;
} }
if (values.file.size > MAX_SIZE) { if (values.file.size > MAX_SIZE) {
localError.value = 'Datoteka je prevelika. Največja velikost je 25MB.' localError.value = "Datoteka je prevelika. Največja velikost je 25MB.";
return return;
} }
const formData = new FormData() const formData = new FormData();
formData.append('name', values.name) formData.append("name", values.name);
formData.append('description', values.description || '') formData.append("description", values.description || "");
formData.append('file', values.file) formData.append("file", values.file);
formData.append('is_public', values.is_public ? '1' : '0') formData.append("is_public", values.is_public ? "1" : "0");
if (values.contract_uuid) { if (values.contract_uuid) {
formData.append('contract_uuid', values.contract_uuid) formData.append("contract_uuid", values.contract_uuid);
} }
router.post(props.postUrl, formData, { router.post(props.postUrl, formData, {
forceFormData: true, forceFormData: true,
onSuccess: () => { onSuccess: () => {
emit('uploaded') emit("uploaded");
emit('close') emit("close");
form.resetForm() form.resetForm();
}, },
onError: (errors) => { onError: (errors) => {
// Set form errors if any // Set form errors if any
if (errors.name) form.setFieldError('name', errors.name) if (errors.name) form.setFieldError("name", errors.name);
if (errors.description) form.setFieldError('description', errors.description) if (errors.description) form.setFieldError("description", errors.description);
if (errors.file) form.setFieldError('file', errors.file) if (errors.file) form.setFieldError("file", errors.file);
if (errors.contract_uuid) form.setFieldError('contract_uuid', errors.contract_uuid) if (errors.contract_uuid) form.setFieldError("contract_uuid", errors.contract_uuid);
}, },
}) });
}) });
const close = () => emit('close') const close = () => emit("close");
const onConfirm = () => { const onConfirm = () => {
submit() submit();
} };
</script> </script>
<template> <template>
@@ -137,7 +165,11 @@ const onConfirm = () => {
@confirm="onConfirm" @confirm="onConfirm"
> >
<form @submit.prevent="submit" class="space-y-4"> <form @submit.prevent="submit" class="space-y-4">
<FormField v-if="props.contracts && props.contracts.length" v-slot="{ value, handleChange }" name="contract_uuid"> <FormField
v-if="props.contracts && props.contracts.length"
v-slot="{ value, handleChange }"
name="contract_uuid"
>
<FormItem> <FormItem>
<FormLabel>Pripiši k</FormLabel> <FormLabel>Pripiši k</FormLabel>
<Select :model-value="value" @update:model-value="handleChange"> <Select :model-value="value" @update:model-value="handleChange">
@@ -148,11 +180,7 @@ const onConfirm = () => {
</FormControl> </FormControl>
<SelectContent> <SelectContent>
<SelectItem :value="null">Primer</SelectItem> <SelectItem :value="null">Primer</SelectItem>
<SelectItem <SelectItem v-for="c in props.contracts" :key="c.uuid" :value="c.uuid">
v-for="c in props.contracts"
:key="c.uuid"
:value="c.uuid"
>
Pogodba: {{ c.reference }} Pogodba: {{ c.reference }}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
@@ -165,7 +193,11 @@ const onConfirm = () => {
<FormItem> <FormItem>
<FormLabel>Ime</FormLabel> <FormLabel>Ime</FormLabel>
<FormControl> <FormControl>
<Input id="doc_name" v-bind="componentField" /> <Input
id="doc_name"
v-bind="componentField"
class="w-full max-w-full overflow-hidden text-ellipsis"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -184,29 +216,24 @@ const onConfirm = () => {
<FormField v-slot="{ value, handleChange }" name="file"> <FormField v-slot="{ value, handleChange }" name="file">
<FormItem> <FormItem>
<FormLabel>Datoteka (max 25MB)</FormLabel> <FormLabel>Datoteka (max 25MB)</FormLabel>
<FormControl> <FormControl class="flex w-full">
<Input <Input
id="doc_file" id="doc_file"
type="file" type="file"
@change="onFileChange" @change="onFileChange"
accept=".doc,.docx,.pdf,.txt,.csv,.xls,.xlsx,.jpeg,.jpg,.png" accept=".doc,.docx,.pdf,.txt,.csv,.xls,.xlsx,.jpeg,.jpg,.png"
class="min-w-0 w-full"
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
<div v-if="localError" class="text-sm text-red-600 mt-1">{{ localError }}</div> <div v-if="localError" class="text-sm text-red-600 mt-1">{{ localError }}</div>
<div v-if="value" class="text-sm text-gray-600 mt-1">
Izbrana datoteka: {{ value.name }} ({{ (value.size / 1024).toFixed(2) }} KB)
</div>
</FormItem> </FormItem>
</FormField> </FormField>
<FormField v-slot="{ value, handleChange }" name="is_public"> <FormField v-slot="{ value, handleChange }" name="is_public">
<FormItem class="flex flex-row items-start space-x-3 space-y-0"> <FormItem class="flex flex-row items-start space-x-3 space-y-0">
<FormControl> <FormControl>
<Switch <Switch :model-value="value" @update:model-value="handleChange" />
:model-value="value"
@update:model-value="handleChange"
/>
</FormControl> </FormControl>
<div class="space-y-1 leading-none"> <div class="space-y-1 leading-none">
<FormLabel>Javno</FormLabel> <FormLabel>Javno</FormLabel>
@@ -1,30 +1,219 @@
<script setup> <script setup>
import { ref, computed, watch } from "vue";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/Components/ui/dialog' } from "@/Components/ui/dialog";
import { Button } from '@/Components/ui/button' import { Button } from "@/Components/ui/button";
import { Badge } from "../ui/badge";
import { Loader2 } from "lucide-vue-next";
import axios from "axios";
const props = defineProps({ const props = defineProps({
show: { type: Boolean, default: false }, show: { type: Boolean, default: false },
src: { type: String, default: '' }, src: { type: String, default: "" },
title: { type: String, default: 'Dokument' } title: { type: String, default: "Dokument" },
}) mimeType: { type: String, default: "" },
const emit = defineEmits(['close']) filename: { type: String, default: "" },
});
const emit = defineEmits(["close"]);
const textContent = ref("");
const loading = ref(false);
const previewGenerating = ref(false);
const previewError = ref("");
const fileExtension = computed(() => {
if (props.filename) {
return props.filename.split(".").pop()?.toLowerCase() || "";
}
return "";
});
const viewerType = computed(() => {
const ext = fileExtension.value;
const mime = props.mimeType.toLowerCase();
if (ext === "pdf" || mime === "application/pdf") return "pdf";
// DOCX/DOC files are converted to PDF by backend - treat as PDF viewer
if (["doc", "docx"].includes(ext) || mime.includes("word") || mime.includes("msword"))
return "docx";
if (["jpg", "jpeg", "png", "gif", "webp"].includes(ext) || mime.startsWith("image/"))
return "image";
if (["txt", "csv", "xml"].includes(ext) || mime.startsWith("text/")) return "text";
return "unsupported";
});
const loadTextContent = async () => {
if (!props.src || viewerType.value !== "text") return;
loading.value = true;
try {
const response = await axios.get(props.src);
textContent.value = response.data;
} catch (e) {
textContent.value = "Napaka pri nalaganju vsebine.";
} finally {
loading.value = false;
}
};
// For DOCX files, the backend converts to PDF. If the preview isn't ready yet (202 status),
// we poll until it's available.
const docxPreviewUrl = ref("");
const loadDocxPreview = async () => {
if (!props.src || viewerType.value !== "docx") return;
previewGenerating.value = true;
previewError.value = "";
docxPreviewUrl.value = "";
const maxRetries = 15;
const retryDelay = 2000; // 2 seconds between retries
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await axios.head(props.src, { validateStatus: () => true });
if (response.status >= 200 && response.status < 300) {
// Preview is ready
docxPreviewUrl.value = props.src;
previewGenerating.value = false;
return;
} else if (response.status === 202) {
// Preview is being generated, wait and retry
await new Promise((resolve) => setTimeout(resolve, retryDelay));
} else {
// Other error
previewError.value = "Napaka pri nalaganju predogleda.";
previewGenerating.value = false;
return;
}
} catch (e) {
previewError.value = "Napaka pri nalaganju predogleda.";
previewGenerating.value = false;
return;
}
}
// Max retries reached
previewError.value = "Predogled ni na voljo. Prosimo poskusite znova kasneje.";
previewGenerating.value = false;
};
watch(
() => [props.show, props.src],
([show]) => {
if (show && viewerType.value === "text") {
loadTextContent();
}
if (show && viewerType.value === "docx") {
loadDocxPreview();
}
// Reset states when dialog closes
if (!show) {
previewGenerating.value = false;
previewError.value = "";
docxPreviewUrl.value = "";
}
},
{ immediate: true }
);
</script> </script>
<template> <template>
<Dialog :open="show" @update:open="(open) => !open && $emit('close')"> <Dialog :open="show" @update:open="(open) => !open && $emit('close')">
<DialogContent class="max-w-4xl"> <DialogContent class="max-w-full xl:max-w-7xl">
<DialogHeader> <DialogHeader>
<DialogTitle>{{ props.title }}</DialogTitle> <DialogTitle>
{{ title }}
</DialogTitle>
<DialogDescription>
<Badge>
{{ fileExtension }}
</Badge>
</DialogDescription>
</DialogHeader> </DialogHeader>
<div class="h-[70vh]">
<iframe v-if="props.src" :src="props.src" class="w-full h-full rounded border" /> <div class="h-[70vh] overflow-auto">
<!-- PDF Viewer (browser native) -->
<template v-if="viewerType === 'pdf' && props.src">
<iframe
:src="props.src"
class="w-full h-full rounded border"
type="application/pdf"
/>
</template>
<!-- DOCX Viewer (converted to PDF by backend) -->
<template v-else-if="viewerType === 'docx'">
<!-- Loading/generating state -->
<div
v-if="previewGenerating"
class="flex flex-col items-center justify-center h-full gap-4"
>
<Loader2 class="h-8 w-8 animate-spin text-indigo-600" />
<span class="text-gray-500">Priprava predogleda dokumenta...</span>
</div>
<!-- Error state -->
<div
v-else-if="previewError"
class="flex flex-col items-center justify-center h-full gap-4 text-gray-500"
>
<span>{{ previewError }}</span>
<Button as="a" :href="props.src" target="_blank" variant="outline">
Prenesi datoteko
</Button>
</div>
<!-- Preview ready -->
<iframe
v-else-if="docxPreviewUrl"
:src="docxPreviewUrl"
class="w-full h-full rounded border"
type="application/pdf"
/>
</template>
<!-- Image Viewer -->
<template v-else-if="viewerType === 'image' && props.src">
<img
:src="props.src"
:alt="props.title"
class="max-w-full max-h-full mx-auto object-contain"
/>
</template>
<!-- Text/CSV/XML Viewer -->
<template v-else-if="viewerType === 'text'">
<div v-if="loading" class="flex items-center justify-center h-full">
<div class="animate-pulse text-gray-500">Nalaganje...</div>
</div>
<pre
v-else
class="p-4 bg-gray-50 dark:bg-gray-900 rounded border text-sm overflow-auto h-full whitespace-pre-wrap wrap-break-word"
>{{ textContent }}</pre
>
</template>
<!-- Unsupported -->
<template v-else-if="viewerType === 'unsupported'">
<div
class="flex flex-col items-center justify-center h-full gap-4 text-gray-500"
>
<span>Predogled ni na voljo za to vrsto datoteke.</span>
<Button as="a" :href="props.src" target="_blank" variant="outline">
Prenesi datoteko
</Button>
</div>
</template>
<!-- No source -->
<div v-else class="text-sm text-gray-500">Ni dokumenta za prikaz.</div> <div v-else class="text-sm text-gray-500">Ni dokumenta za prikaz.</div>
</div> </div>
<div class="flex justify-end mt-4"> <div class="flex justify-end mt-4">
<Button type="button" variant="outline" @click="$emit('close')">Zapri</Button> <Button type="button" variant="outline" @click="$emit('close')">Zapri</Button>
</div> </div>
@@ -1,70 +0,0 @@
<script setup lang="ts">
import type { LucideIcon } from "lucide-vue-next";
import { ChevronRight } from "lucide-vue-next";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/Components/ui/collapsible";
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
} from "@/Components/ui/sidebar";
defineProps<{
items: {
title: string;
url: string;
icon?: LucideIcon;
isActive?: boolean;
items?: {
title: string;
url: string;
}[];
}[];
}>();
</script>
<template>
<SidebarGroup>
<SidebarGroupLabel>Platform</SidebarGroupLabel>
<SidebarMenu>
<Collapsible
v-for="item in items"
:key="item.title"
as-child
:default-open="item.isActive"
class="group/collapsible"
>
<SidebarMenuItem>
<CollapsibleTrigger as-child>
<SidebarMenuButton :tooltip="item.title">
<component :is="item.icon" v-if="item.icon" />
<span>{{ item.title }}</span>
<ChevronRight
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
/>
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
<SidebarMenuSubItem v-for="subItem in item.items" :key="subItem.title">
<SidebarMenuSubButton as-child>
<a :href="subItem.url">
<span>{{ subItem.title }}</span>
</a>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
</SidebarMenu>
</SidebarGroup>
</template>
@@ -7,12 +7,7 @@ import { router } from "@inertiajs/vue3";
import CreateDialog from "../Dialogs/CreateDialog.vue"; import CreateDialog from "../Dialogs/CreateDialog.vue";
import UpdateDialog from "../Dialogs/UpdateDialog.vue"; import UpdateDialog from "../Dialogs/UpdateDialog.vue";
import SectionTitle from "../SectionTitle.vue"; import SectionTitle from "../SectionTitle.vue";
import { import { FormControl, FormItem, FormLabel, FormMessage } from "@/Components/ui/form";
FormControl,
FormItem,
FormLabel,
FormMessage,
} from "@/Components/ui/form";
import { Input } from "@/Components/ui/input"; import { Input } from "@/Components/ui/input";
import { import {
Select, Select,
@@ -97,7 +92,7 @@ watch(
country: a.country || "", country: a.country || "",
post_code: a.post_code || a.postal_code || "", post_code: a.post_code || a.postal_code || "",
city: a.city || "", city: a.city || "",
type_id: a.type_id ?? (props.types?.[0]?.id ?? null), type_id: a.type_id ?? props.types?.[0]?.id ?? null,
description: a.description || "", description: a.description || "",
}); });
return; return;
@@ -108,7 +103,9 @@ watch(
{ immediate: true } { immediate: true }
); );
watch(() => props.show, (val) => { watch(
() => props.show,
(val) => {
if (val && props.edit && props.id) { if (val && props.edit && props.id) {
const a = props.person.addresses?.find((x) => x.id === props.id); const a = props.person.addresses?.find((x) => x.id === props.id);
if (a) { if (a) {
@@ -117,23 +114,21 @@ watch(() => props.show, (val) => {
country: a.country || "", country: a.country || "",
post_code: a.post_code || a.postal_code || "", post_code: a.post_code || a.postal_code || "",
city: a.city || "", city: a.city || "",
type_id: a.type_id ?? (props.types?.[0]?.id ?? null), type_id: a.type_id ?? props.types?.[0]?.id ?? null,
description: a.description || "", description: a.description || "",
}); });
} }
} else if (val && !props.edit) { } else if (val && !props.edit) {
resetForm(); resetForm();
} }
}); }
);
const create = async () => { const create = async () => {
processing.value = true; processing.value = true;
const { values } = form; const { values } = form;
router.post( router.post(route("person.address.create", props.person), values, {
route("person.address.create", props.person),
values,
{
preserveScroll: true, preserveScroll: true,
onSuccess: () => { onSuccess: () => {
processing.value = false; processing.value = false;
@@ -152,8 +147,7 @@ const create = async () => {
onFinish: () => { onFinish: () => {
processing.value = false; processing.value = false;
}, },
} });
);
}; };
const update = async () => { const update = async () => {
@@ -223,7 +217,12 @@ const onConfirm = () => {
<FormItem> <FormItem>
<FormLabel>Naslov</FormLabel> <FormLabel>Naslov</FormLabel>
<FormControl> <FormControl>
<Input type="text" placeholder="Naslov" autocomplete="street-address" v-bind="componentField" /> <Input
type="text"
placeholder="Naslov"
autocomplete="street-address"
v-bind="componentField"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -233,7 +232,12 @@ const onConfirm = () => {
<FormItem> <FormItem>
<FormLabel>Država</FormLabel> <FormLabel>Država</FormLabel>
<FormControl> <FormControl>
<Input type="text" placeholder="Država" autocomplete="country" v-bind="componentField" /> <Input
type="text"
placeholder="Država"
autocomplete="country"
v-bind="componentField"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -243,7 +247,12 @@ const onConfirm = () => {
<FormItem> <FormItem>
<FormLabel>Poštna številka</FormLabel> <FormLabel>Poštna številka</FormLabel>
<FormControl> <FormControl>
<Input type="text" placeholder="Poštna številka" autocomplete="postal-code" v-bind="componentField" /> <Input
type="text"
placeholder="Poštna številka"
autocomplete="postal-code"
v-bind="componentField"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -253,7 +262,22 @@ const onConfirm = () => {
<FormItem> <FormItem>
<FormLabel>Mesto</FormLabel> <FormLabel>Mesto</FormLabel>
<FormControl> <FormControl>
<Input type="text" placeholder="Mesto" autocomplete="address-level2" v-bind="componentField" /> <Input
type="text"
placeholder="Mesto"
autocomplete="address-level2"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="description">
<FormItem>
<FormLabel>Opis</FormLabel>
<FormControl>
<Input type="text" placeholder="Opis" v-bind="componentField" />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -6,12 +6,7 @@ import * as z from "zod";
import { router } from "@inertiajs/vue3"; import { router } from "@inertiajs/vue3";
import UpdateDialog from "../Dialogs/UpdateDialog.vue"; import UpdateDialog from "../Dialogs/UpdateDialog.vue";
import SectionTitle from "../SectionTitle.vue"; import SectionTitle from "../SectionTitle.vue";
import { import { FormControl, FormItem, FormLabel, FormMessage } from "@/Components/ui/form";
FormControl,
FormItem,
FormLabel,
FormMessage,
} from "@/Components/ui/form";
import { Input } from "@/Components/ui/input"; import { Input } from "@/Components/ui/input";
import { import {
Select, Select,
@@ -85,7 +80,7 @@ const hydrate = () => {
country: a.country || "", country: a.country || "",
post_code: a.post_code || a.postal_code || "", post_code: a.post_code || a.postal_code || "",
city: a.city || "", city: a.city || "",
type_id: a.type_id ?? (props.types?.[0]?.id ?? null), type_id: a.type_id ?? props.types?.[0]?.id ?? null,
description: a.description || "", description: a.description || "",
}); });
return; return;
@@ -94,10 +89,17 @@ const hydrate = () => {
resetForm(); resetForm();
}; };
watch(() => props.id, () => hydrate(), { immediate: true }); watch(
watch(() => props.show, (v) => { () => props.id,
() => hydrate(),
{ immediate: true }
);
watch(
() => props.show,
(v) => {
if (v) hydrate(); if (v) hydrate();
}); }
);
const update = async () => { const update = async () => {
processing.value = true; processing.value = true;
@@ -157,7 +159,12 @@ const onConfirm = () => {
<FormItem> <FormItem>
<FormLabel>Naslov</FormLabel> <FormLabel>Naslov</FormLabel>
<FormControl> <FormControl>
<Input type="text" placeholder="Naslov" autocomplete="street-address" v-bind="componentField" /> <Input
type="text"
placeholder="Naslov"
autocomplete="street-address"
v-bind="componentField"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -167,7 +174,12 @@ const onConfirm = () => {
<FormItem> <FormItem>
<FormLabel>Država</FormLabel> <FormLabel>Država</FormLabel>
<FormControl> <FormControl>
<Input type="text" placeholder="Država" autocomplete="country" v-bind="componentField" /> <Input
type="text"
placeholder="Država"
autocomplete="country"
v-bind="componentField"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -177,7 +189,12 @@ const onConfirm = () => {
<FormItem> <FormItem>
<FormLabel>Poštna številka</FormLabel> <FormLabel>Poštna številka</FormLabel>
<FormControl> <FormControl>
<Input type="text" placeholder="Poštna številka" autocomplete="postal-code" v-bind="componentField" /> <Input
type="text"
placeholder="Poštna številka"
autocomplete="postal-code"
v-bind="componentField"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -187,7 +204,22 @@ const onConfirm = () => {
<FormItem> <FormItem>
<FormLabel>Mesto</FormLabel> <FormLabel>Mesto</FormLabel>
<FormControl> <FormControl>
<Input type="text" placeholder="Mesto" autocomplete="address-level2" v-bind="componentField" /> <Input
type="text"
placeholder="Mesto"
autocomplete="address-level2"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="description">
<FormItem>
<FormLabel>Opis</FormLabel>
<FormControl>
<Input type="text" placeholder="Opis" v-bind="componentField" />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -24,9 +24,9 @@ const handleDelete = (id, label) => emit("delete", id, label);
<template> <template>
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"> <div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<Card class="p-2 gap-1" v-for="address in person.addresses" :key="address.id"> <Card class="p-2 gap-0" v-for="address in person.addresses" :key="address.id">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between">
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-1">
<span <span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800" class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800"
> >
@@ -61,13 +61,16 @@ const handleDelete = (id, label) => emit("delete", id, label);
</DropdownMenu> </DropdownMenu>
</div> </div>
</div> </div>
<p class="text-sm font-medium text-gray-900 leading-relaxed p-1"> <p class="font-medium text-gray-900 leading-relaxed p-1">
{{ {{
address.post_code && address.city address.post_code && address.city
? `${address.address}, ${address.post_code} ${address.city}` ? `${address.address}, ${address.post_code} ${address.city}`
: address.address : address.address
}} }}
</p> </p>
<p class="text-sm text-muted-foreground p-1" v-if="address.description">
{{ address.description }}
</p>
</Card> </Card>
<button <button
v-if="edit" v-if="edit"
@@ -27,9 +27,9 @@ const handleDelete = (id, label) => emit("delete", id, label);
<template> <template>
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"> <div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<template v-if="getEmails(person).length"> <template v-if="getEmails(person).length">
<Card class="p-2 gap-1" v-for="(email, idx) in getEmails(person)" :key="idx"> <Card class="p-2 gap-0" v-for="(email, idx) in getEmails(person)" :key="idx">
<div class="flex items-center justify-between mb-2" v-if="edit"> <div class="flex items-center justify-between" v-if="edit">
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-1">
<span <span
v-if="email?.label" v-if="email?.label"
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800" class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800"
@@ -69,7 +69,7 @@ const handleDelete = (id, label) => emit("delete", id, label);
</div> </div>
</div> </div>
<div class="p-1"> <div class="p-1">
<p class="text-sm font-medium text-gray-900 leading-relaxed"> <p class="font-medium text-gray-900 leading-relaxed">
{{ email?.value || email?.email || email?.address || "-" }} {{ email?.value || email?.email || email?.address || "-" }}
</p> </p>
<p <p
@@ -299,7 +299,7 @@ const switchToTab = (tab) => {
<template> <template>
<Tabs v-model="activeTab" class="mt-2"> <Tabs v-model="activeTab" class="mt-2">
<TabsList class="flex w-full bg-white gap-2 p-1"> <TabsList class="flex flex-row flex-wrap bg-white gap-2 p-1">
<TabsTrigger <TabsTrigger
value="person" value="person"
class="border border-gray-200 data-[state=active]:bg-primary-50 data-[state=active]:text-primary-700 flex-1 py-2" class="border border-gray-200 data-[state=active]:bg-primary-50 data-[state=active]:text-primary-700 flex-1 py-2"
@@ -384,6 +384,7 @@ const switchToTab = (tab) => {
</TabsList> </TabsList>
<TabsContent value="person" class="py-2"> <TabsContent value="person" class="py-2">
<PersonInfoPersonTab <PersonInfoPersonTab
:is-client-case="clientCaseUuid ? true : false"
:person="person" :person="person"
:edit="edit" :edit="edit"
:person-edit="personEdit" :person-edit="personEdit"
@@ -1,14 +1,16 @@
<script setup> <script setup>
import { UserEditIcon } from "@/Utilities/Icons"; import { UserEditIcon } from "@/Utilities/Icons";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { fmtDateDMY } from "@/Utilities/functions";
const props = defineProps({ const props = defineProps({
person: Object, person: Object,
isClientCase: { type: Boolean, default: false },
edit: { type: Boolean, default: true }, edit: { type: Boolean, default: true },
personEdit: { type: Boolean, default: true }, personEdit: { type: Boolean, default: true },
}); });
const emit = defineEmits(['edit']); const emit = defineEmits(["edit"]);
const getMainAddress = (adresses) => { const getMainAddress = (adresses) => {
const addr = adresses.filter((a) => a.type.id === 1)[0] ?? ""; const addr = adresses.filter((a) => a.type.id === 1)[0] ?? "";
@@ -30,7 +32,7 @@ const getMainPhone = (phones) => {
}; };
const handleEdit = () => { const handleEdit = () => {
emit('edit'); emit("edit");
}; };
</script> </script>
@@ -44,51 +46,126 @@ const handleEdit = () => {
> >
<UserEditIcon size="md" /> <UserEditIcon size="md" />
<span>Uredi</span> <span>Uredi</span>
</button> </Button>
</div> </div>
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3"> <div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"> <div
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Nu.</p> class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
>
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">
Primer ref.
</p>
<p class="text-sm font-semibold text-gray-900">{{ person.nu }}</p> <p class="text-sm font-semibold text-gray-900">{{ person.nu }}</p>
</div> </div>
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"> <div
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Name.</p> class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
>
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Naziv</p>
<p class="text-sm font-semibold text-gray-900"> <p class="text-sm font-semibold text-gray-900">
{{ person.full_name }} {{ person.full_name }}
</p> </p>
</div> </div>
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"> <div
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Tax NU.</p> class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
>
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">
Davčna
</p>
<p class="text-sm font-semibold text-gray-900"> <p class="text-sm font-semibold text-gray-900">
{{ person.tax_number }} {{ person.tax_number }}
</p> </p>
</div> </div>
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"> <div
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Social security NU.</p> class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
>
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Emšo</p>
<p class="text-sm font-semibold text-gray-900"> <p class="text-sm font-semibold 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-3 mt-3"> <div
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"> v-if="isClientCase"
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Address</p> class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 mt-3"
>
<div
class="md:col-span-full lg:col-span-1 rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
>
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">
Naslov
</p>
<p class="text-sm font-medium text-gray-900"> <p class="text-sm font-medium text-gray-900">
{{ getMainAddress(person.addresses) }} {{ getMainAddress(person.addresses) }}
</p> </p>
</div> </div>
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"> <div
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Phone</p> class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
>
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">
Telefon
</p>
<p class="text-sm font-medium text-gray-900"> <p class="text-sm font-medium text-gray-900">
{{ getMainPhone(person.phones) }} {{ getMainPhone(person.phones) }}
</p> </p>
</div> </div>
<div class="md:col-span-full lg:col-span-1 rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"> <div
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Description</p> class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
>
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">
Dat. rojstva
</p>
<p class="text-sm font-medium text-gray-900">
{{ fmtDateDMY(person.birthday) }}
</p>
</div>
</div>
<div v-else class="grid grid-rows-* grid-cols-1 md:grid-cols-2 gap-3 mt-3">
<div
class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
>
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">
Naslov
</p>
<p class="text-sm font-medium text-gray-900">
{{ getMainAddress(person.addresses) }}
</p>
</div>
<div
class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
>
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">
Telefon
</p>
<p class="text-sm font-medium text-gray-900">
{{ getMainPhone(person.phones) }}
</p>
</div>
</div>
<div
class="grid grid-rows-* grid-cols-1 md:grid-cols-2 gap-3 mt-3"
:class="[isClientCase ? 'md:grid-cols-2' : '']"
>
<div
v-if="isClientCase"
class="md:col-span-full lg:col-span-1 rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
>
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">
Delodajalec
</p>
<p class="text-sm font-medium text-gray-900">
{{ person.employer }}
</p>
</div>
<div
class="md:col-span-full rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
:class="[isClientCase ? 'lg:col-span-1' : '']"
>
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Opis</p>
<p class="text-sm font-medium text-gray-900"> <p class="text-sm font-medium text-gray-900">
{{ person.description }} {{ person.description }}
</p> </p>
</div> </div>
</div> </div>
</template> </template>
@@ -8,7 +8,13 @@ import {
} from "@/Components/ui/dropdown-menu"; } from "@/Components/ui/dropdown-menu";
import { Card } from "@/Components/ui/card"; import { Card } from "@/Components/ui/card";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { EllipsisVertical, MessageSquare, MessageSquareText } from "lucide-vue-next"; import {
CircleCheckBigIcon,
CircleCheckIcon,
EllipsisVertical,
MessageSquare,
MessageSquareText,
} from "lucide-vue-next";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
const props = defineProps({ const props = defineProps({
@@ -30,9 +36,9 @@ const handleSms = (phone) => emit("sms", phone);
<template> <template>
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"> <div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<template v-if="getPhones(person).length"> <template v-if="getPhones(person).length">
<Card class="p-2 gap-1" v-for="phone in getPhones(person)" :key="phone.id"> <Card class="p-2 gap-0" v-for="phone in getPhones(person)" :key="phone.id">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between">
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-1">
<span <span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800" class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800"
> >
@@ -79,8 +85,12 @@ const handleSms = (phone) => emit("sms", phone);
</DropdownMenu> </DropdownMenu>
</div> </div>
</div> </div>
<p class="text-sm font-medium text-gray-900 leading-relaxed p-1"> <p class="font-medium leading-relaxed p-1 flex gap-1 items-center">
{{ phone.nu }} {{ phone.nu }}
<CircleCheckBigIcon color="#3e9392" size="20" v-if="phone.validated" />
</p>
<p class="text-sm text-muted-foreground p-1" v-if="phone.description">
{{ phone.description }}
</p> </p>
</Card> </Card>
</template> </template>
@@ -1,5 +1,6 @@
<script setup> <script setup>
import { ref, watch, computed } from "vue"; import { ref, watch, computed } from "vue";
import axios from "axios";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -301,28 +302,14 @@ const updateSmsFromSelection = async () => {
const url = route("clientCase.sms.preview", { const url = route("clientCase.sms.preview", {
client_case: props.clientCaseUuid, client_case: props.clientCaseUuid,
}); });
const res = await fetch(url, { const { data } = await axios.post(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Requested-With": "XMLHttpRequest",
"X-CSRF-TOKEN":
document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") ||
"",
},
body: JSON.stringify({
template_id: form.values.template_id, template_id: form.values.template_id,
contract_uuid: form.values.contract_uuid || null, contract_uuid: form.values.contract_uuid || null,
}),
credentials: "same-origin",
}); });
if (res.ok) {
const data = await res.json();
if (typeof data?.content === "string" && data.content.trim() !== "") { if (typeof data?.content === "string" && data.content.trim() !== "") {
form.setFieldValue("message", data.content); form.setFieldValue("message", data.content);
return; return;
} }
}
} catch (e) { } catch (e) {
// ignore and fallback // ignore and fallback
} }
@@ -1,73 +1,72 @@
<script setup> <script setup>
import UpdateDialog from '@/Components/Dialogs/UpdateDialog.vue'; import UpdateDialog from "@/Components/Dialogs/UpdateDialog.vue";
import SectionTitle from '@/Components/SectionTitle.vue'; import SectionTitle from "@/Components/SectionTitle.vue";
import { useForm, Field as FormField } from "vee-validate"; import { useForm, Field as FormField } from "vee-validate";
import { toTypedSchema } from "@vee-validate/zod"; import { toTypedSchema } from "@vee-validate/zod";
import * as z from "zod"; import * as z from "zod";
import { router } from '@inertiajs/vue3'; import { router } from "@inertiajs/vue3";
import { ref } from 'vue'; import { ref } from "vue";
import { import { FormControl, FormItem, FormLabel, FormMessage } from "@/Components/ui/form";
FormControl,
FormItem,
FormLabel,
FormMessage,
} from "@/Components/ui/form";
import { Input } from "@/Components/ui/input"; import { Input } from "@/Components/ui/input";
import { Textarea } from "@/Components/ui/textarea"; import { Textarea } from "@/Components/ui/textarea";
import DatePicker from "../DatePicker.vue";
const props = defineProps({ const props = defineProps({
show: { show: {
type: Boolean, type: Boolean,
default: false default: false,
}, },
person: Object person: Object,
}); });
const processingUpdate = ref(false); const processingUpdate = ref(false);
const emit = defineEmits(['close']); const emit = defineEmits(["close"]);
const formSchema = toTypedSchema( const formSchema = toTypedSchema(
z.object({ z.object({
full_name: z.string().min(1, "Naziv je obvezen."), full_name: z.string().min(1, "Naziv je obvezen."),
tax_number: z.string().optional(), tax_number: z.string().optional(),
social_security_number: z.string().optional(), social_security_number: z.string().optional(),
birthday: z.string().optional(),
description: z.string().optional(), description: z.string().optional(),
employer: z.string().optional(),
}) })
); );
const form = useForm({ const form = useForm({
validationSchema: formSchema, validationSchema: formSchema,
initialValues: { initialValues: {
full_name: props.person?.full_name || '', full_name: props.person?.full_name || "",
tax_number: props.person?.tax_number || '', tax_number: props.person?.tax_number || "",
social_security_number: props.person?.social_security_number || '', social_security_number: props.person?.social_security_number || "",
description: props.person?.description || '' birthday: props.person?.birthday || "",
description: props.person?.description || "",
employer: props.person?.employer || "",
}, },
}); });
const close = () => { const close = () => {
emit('close'); emit("close");
setTimeout(() => { setTimeout(() => {
form.resetForm({ form.resetForm({
values: { values: {
full_name: props.person?.full_name || '', full_name: props.person?.full_name || "",
tax_number: props.person?.tax_number || '', tax_number: props.person?.tax_number || "",
social_security_number: props.person?.social_security_number || '', social_security_number: props.person?.social_security_number || "",
description: props.person?.description || '' birthday: props.person?.birthday || "",
} description: props.person?.description || "",
employer: props.person?.employer || "",
},
}); });
}, 500); }, 500);
} };
const updatePerson = async () => { const updatePerson = async () => {
processingUpdate.value = true; processingUpdate.value = true;
const { values } = form; const { values } = form;
router.put( router.put(route("person.update", props.person), values, {
route('person.update', props.person),
values,
{
preserveScroll: true, preserveScroll: true,
onSuccess: () => { onSuccess: () => {
processingUpdate.value = false; processingUpdate.value = false;
@@ -86,9 +85,8 @@ const updatePerson = async () => {
onFinish: () => { onFinish: () => {
processingUpdate.value = false; processingUpdate.value = false;
}, },
} });
); };
}
const onSubmit = form.handleSubmit(() => { const onSubmit = form.handleSubmit(() => {
updatePerson(); updatePerson();
@@ -96,7 +94,7 @@ const onSubmit = form.handleSubmit(() => {
const onConfirm = () => { const onConfirm = () => {
onSubmit(); onSubmit();
} };
</script> </script>
<template> <template>
<UpdateDialog <UpdateDialog
@@ -109,9 +107,7 @@ const onConfirm = () => {
> >
<form @submit.prevent="onSubmit"> <form @submit.prevent="onSubmit">
<SectionTitle class="border-b mb-4"> <SectionTitle class="border-b mb-4">
<template #title> <template #title> Oseba </template>
Oseba
</template>
</SectionTitle> </SectionTitle>
<div class="space-y-4"> <div class="space-y-4">
@@ -163,15 +159,42 @@ const onConfirm = () => {
</FormItem> </FormItem>
</FormField> </FormField>
<FormField v-slot="{ componentField }" name="employer">
<FormItem>
<FormLabel>Delodajalec</FormLabel>
<FormControl>
<Input
id="cemployer"
type="text"
placeholder="Delodajalec"
autocomplete="employer"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="birthday">
<FormItem>
<FormLabel>Datum rojstva</FormLabel>
<FormControl>
<DatePicker
id="cbirthday"
:model-value="value"
@update:model-value="handleChange"
format="dd.MM.yyyy"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="description"> <FormField v-slot="{ componentField }" name="description">
<FormItem> <FormItem>
<FormLabel>Opis</FormLabel> <FormLabel>Opis</FormLabel>
<FormControl> <FormControl>
<Textarea <Textarea id="cdescription" placeholder="Opis" v-bind="componentField" />
id="cdescription"
placeholder="Opis"
v-bind="componentField"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -6,12 +6,7 @@ import * as z from "zod";
import { router } from "@inertiajs/vue3"; import { router } from "@inertiajs/vue3";
import CreateDialog from "../Dialogs/CreateDialog.vue"; import CreateDialog from "../Dialogs/CreateDialog.vue";
import SectionTitle from "../SectionTitle.vue"; import SectionTitle from "../SectionTitle.vue";
import { import { FormControl, FormItem, FormLabel, FormMessage } from "@/Components/ui/form";
FormControl,
FormItem,
FormLabel,
FormMessage,
} from "@/Components/ui/form";
import { Input } from "@/Components/ui/input"; import { Input } from "@/Components/ui/input";
import { import {
Select, Select,
@@ -101,10 +96,7 @@ const create = async () => {
processing.value = true; processing.value = true;
const { values } = form; const { values } = form;
router.post( router.post(route("person.phone.create", props.person), values, {
route("person.phone.create", props.person),
values,
{
preserveScroll: true, preserveScroll: true,
onSuccess: () => { onSuccess: () => {
close(); close();
@@ -122,8 +114,7 @@ const create = async () => {
onFinish: () => { onFinish: () => {
processing.value = false; processing.value = false;
}, },
} });
);
}; };
const onSubmit = form.handleSubmit(() => { const onSubmit = form.handleSubmit(() => {
@@ -150,7 +141,12 @@ const onSubmit = form.handleSubmit(() => {
<FormItem> <FormItem>
<FormLabel>Številka</FormLabel> <FormLabel>Številka</FormLabel>
<FormControl> <FormControl>
<Input type="text" placeholder="Številka telefona" autocomplete="tel" v-bind="componentField" /> <Input
type="text"
placeholder="Številka telefona"
autocomplete="tel"
v-bind="componentField"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -166,7 +162,11 @@ const onSubmit = form.handleSubmit(() => {
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
<SelectItem v-for="option in countryOptions" :key="option.value" :value="option.value"> <SelectItem
v-for="option in countryOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }} {{ option.label }}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
@@ -204,7 +204,11 @@ const onSubmit = form.handleSubmit(() => {
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
<SelectItem v-for="option in phoneTypeOptions" :key="option.value" :value="option.value"> <SelectItem
v-for="option in phoneTypeOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }} {{ option.label }}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
@@ -213,6 +217,16 @@ const onSubmit = form.handleSubmit(() => {
</FormItem> </FormItem>
</FormField> </FormField>
<FormField v-slot="{ componentField }" name="description">
<FormItem>
<FormLabel>Opis</FormLabel>
<FormControl>
<Input type="text" placeholder="Opis" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="validated"> <FormField v-slot="{ value, handleChange }" name="validated">
<FormItem class="flex flex-row items-start space-x-3 space-y-0"> <FormItem class="flex flex-row items-start space-x-3 space-y-0">
<FormControl> <FormControl>
@@ -6,12 +6,7 @@ import * as z from "zod";
import { router } from "@inertiajs/vue3"; import { router } from "@inertiajs/vue3";
import UpdateDialog from "../Dialogs/UpdateDialog.vue"; import UpdateDialog from "../Dialogs/UpdateDialog.vue";
import SectionTitle from "../SectionTitle.vue"; import SectionTitle from "../SectionTitle.vue";
import { import { FormControl, FormItem, FormLabel, FormMessage } from "@/Components/ui/form";
FormControl,
FormItem,
FormLabel,
FormMessage,
} from "@/Components/ui/form";
import { Input } from "@/Components/ui/input"; import { Input } from "@/Components/ui/input";
import { import {
Select, Select,
@@ -108,7 +103,7 @@ function hydrateFromProps() {
form.setValues({ form.setValues({
nu: p.nu || "", nu: p.nu || "",
country_code: p.country_code ?? 386, country_code: p.country_code ?? 386,
type_id: p.type_id ?? (props.types?.[0]?.id ?? null), type_id: p.type_id ?? props.types?.[0]?.id ?? null,
description: p.description || "", description: p.description || "",
validated: !!p.validated, validated: !!p.validated,
phone_type: p.phone_type ?? null, phone_type: p.phone_type ?? null,
@@ -119,8 +114,17 @@ function hydrateFromProps() {
resetForm(); resetForm();
} }
watch(() => props.id, () => hydrateFromProps(), { immediate: true }); watch(
watch(() => props.show, (val) => { if (val) hydrateFromProps(); }); () => props.id,
() => hydrateFromProps(),
{ immediate: true }
);
watch(
() => props.show,
(val) => {
if (val) hydrateFromProps();
}
);
const update = async () => { const update = async () => {
processing.value = true; processing.value = true;
@@ -175,7 +179,12 @@ const onSubmit = form.handleSubmit(() => {
<FormItem> <FormItem>
<FormLabel>Številka</FormLabel> <FormLabel>Številka</FormLabel>
<FormControl> <FormControl>
<Input type="text" placeholder="Številka telefona" autocomplete="tel" v-bind="componentField" /> <Input
type="text"
placeholder="Številka telefona"
autocomplete="tel"
v-bind="componentField"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -191,7 +200,11 @@ const onSubmit = form.handleSubmit(() => {
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
<SelectItem v-for="option in countryOptions" :key="option.value" :value="option.value"> <SelectItem
v-for="option in countryOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }} {{ option.label }}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
@@ -229,7 +242,11 @@ const onSubmit = form.handleSubmit(() => {
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
<SelectItem v-for="option in phoneTypeOptions" :key="option.value" :value="option.value"> <SelectItem
v-for="option in phoneTypeOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }} {{ option.label }}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
@@ -238,6 +255,16 @@ const onSubmit = form.handleSubmit(() => {
</FormItem> </FormItem>
</FormField> </FormField>
<FormField v-slot="{ componentField }" name="description">
<FormItem>
<FormLabel>Opis</FormLabel>
<FormControl>
<Input type="text" placeholder="Opis" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="validated"> <FormField v-slot="{ value, handleChange }" name="validated">
<FormItem class="flex flex-row items-start space-x-3 space-y-0"> <FormItem class="flex flex-row items-start space-x-3 space-y-0">
<FormControl> <FormControl>
@@ -84,8 +84,8 @@ const summaryText = computed(() => {
const found = props.items.find((i) => String(i.value) === String(v)); const found = props.items.find((i) => String(i.value) === String(v));
return found?.label || v; return found?.label || v;
}); });
if (labels.length <= 3) return labels.join(', '); if (labels.length <= 3) return labels.join(", ");
const firstThree = labels.slice(0, 3).join(', '); const firstThree = labels.slice(0, 3).join(", ");
const remaining = labels.length - 3; const remaining = labels.length - 3;
return `${firstThree}, … +${remaining}`; // show ellipsis and remaining count return `${firstThree}, … +${remaining}`; // show ellipsis and remaining count
}); });
@@ -154,7 +154,7 @@ const summaryText = computed(() => {
:variant="chipVariant" :variant="chipVariant"
class="flex items-center gap-1" class="flex items-center gap-1"
> >
<span class="truncate max-w-[140px]"> <span class="truncate max-w-35">
{{ items.find((i) => String(i.value) === String(val))?.label || val }} {{ items.find((i) => String(i.value) === String(val))?.label || val }}
</span> </span>
<button <button
@@ -0,0 +1,177 @@
<script setup>
import { CalendarIcon, XIcon } from "lucide-vue-next";
import { computed, ref } from "vue";
import { cn } from "@/lib/utils";
import { Button } from "@/Components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/Components/ui/popover";
import { RangeCalendar } from "@/Components/ui/range-calendar";
import {
DateFormatter,
getLocalTimeZone,
today,
parseDate,
CalendarDate,
} from "@internationalized/date";
const props = defineProps({
modelValue: {
type: Object,
default: () => ({ start: null, end: null }),
},
placeholder: {
type: String,
default: "Izberi datumski obseg",
},
disabled: {
type: Boolean,
default: false,
},
buttonClass: {
type: String,
default: "w-[280px]",
},
locale: {
type: String,
default: "sl-SI",
},
numberOfMonths: {
type: Number,
default: 2,
},
minValue: {
type: Object,
default: undefined,
},
maxValue: {
type: Object,
default: undefined,
},
clearable: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(["update:modelValue"]);
const open = ref(false);
const df = new DateFormatter(props.locale, {
dateStyle: "medium",
});
// Check if there's a selected value
const hasValue = computed(() => {
const val = props.modelValue;
return val?.start || val?.end;
});
// Convert string dates to CalendarDate objects for the calendar
const calendarValue = computed({
get() {
const val = props.modelValue;
if (!val) return undefined;
let start = null;
let end = null;
if (val.start) {
if (typeof val.start === "string") {
start = parseDate(val.start);
} else if (val.start instanceof CalendarDate) {
start = val.start;
}
}
if (val.end) {
if (typeof val.end === "string") {
end = parseDate(val.end);
} else if (val.end instanceof CalendarDate) {
end = val.end;
}
}
if (!start && !end) return undefined;
return { start, end };
},
set(newValue) {
if (!newValue) {
emit("update:modelValue", { start: null, end: null });
return;
}
// Convert CalendarDate to ISO string (YYYY-MM-DD) for easier handling
const result = {
start: newValue.start ? newValue.start.toString() : null,
end: newValue.end ? newValue.end.toString() : null,
};
emit("update:modelValue", result);
// Close popover when both dates are selected
if (result.start && result.end) {
open.value = false;
}
},
});
const displayText = computed(() => {
const val = calendarValue.value;
if (!val?.start) return props.placeholder;
const startFormatted = df.format(val.start.toDate(getLocalTimeZone()));
if (!val.end) return startFormatted;
const endFormatted = df.format(val.end.toDate(getLocalTimeZone()));
return `${startFormatted} - ${endFormatted}`;
});
function clearValue(event) {
event.stopPropagation();
emit("update:modelValue", { start: null, end: null });
}
</script>
<template>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button
variant="outline"
:disabled="disabled"
:class="
cn(
'justify-start text-left font-normal',
!calendarValue?.start && 'text-muted-foreground',
buttonClass
)
"
>
<CalendarIcon class="mr-2 h-4 w-4 shrink-0" />
<span class="truncate flex-1">{{ displayText }}</span>
<span
v-if="clearable && hasValue && !disabled"
class="ml-2 shrink-0 opacity-50 hover:opacity-100 cursor-pointer"
@click.stop.prevent="clearValue"
>
<XIcon class="h-4 w-4" />
</span>
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-0" align="start">
<RangeCalendar
v-model="calendarValue"
:locale="locale"
:number-of-months="numberOfMonths"
:min-value="minValue"
:max-value="maxValue"
initial-focus
@update:start-value="
(startDate) => {
if (calendarValue?.start?.toString() !== startDate?.toString()) {
calendarValue = { start: startDate, end: undefined };
}
}
"
/>
</PopoverContent>
</Popover>
</template>
@@ -0,0 +1,20 @@
<script setup>
import { cn } from "@/lib/utils";
import { fieldVariants } from ".";
const props = defineProps({
class: { type: null, required: false },
orientation: { type: null, required: false },
});
</script>
<template>
<div
role="group"
data-slot="field"
:data-orientation="orientation"
:class="cn(fieldVariants({ orientation }), props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,21 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="field-content"
:class="
cn(
'group/field-content flex flex-1 flex-col gap-1.5 leading-snug',
props.class,
)
"
>
<slot />
</div>
</template>
@@ -0,0 +1,23 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<p
data-slot="field-description"
:class="
cn(
'text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance',
'last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5',
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
props.class,
)
"
>
<slot />
</p>
</template>
@@ -0,0 +1,43 @@
<script setup>
import { computed } from "vue";
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
errors: { type: Array, required: false },
});
const content = computed(() => {
if (!props.errors || props.errors.length === 0) return null;
if (props.errors.length === 1 && props.errors[0]?.message) {
return props.errors[0].message;
}
return props.errors.some((e) => e?.message) ? props.errors : null;
});
</script>
<template>
<div
v-if="$slots.default || content"
role="alert"
data-slot="field-error"
:class="cn('text-destructive text-sm font-normal', props.class)"
>
<slot v-if="$slots.default" />
<template v-else-if="typeof content === 'string'">
{{ content }}
</template>
<ul
v-else-if="Array.isArray(content)"
class="ml-4 flex list-disc flex-col gap-1"
>
<li v-for="(error, index) in content" :key="index">
{{ error?.message }}
</li>
</ul>
</div>
</template>
@@ -0,0 +1,21 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="field-group"
:class="
cn(
'group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4',
props.class,
)
"
>
<slot />
</div>
</template>
@@ -0,0 +1,24 @@
<script setup>
import { cn } from "@/lib/utils";
import { Label } from '@/Components/ui/label';
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<Label
data-slot="field-label"
:class="
cn(
'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50',
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&_>[data-slot=field]]:p-3',
'has-[[data-state=checked]]:bg-primary/5 has-[[data-state=checked]]:border-primary dark:has-[[data-state=checked]]:bg-primary/10',
props.class,
)
"
>
<slot />
</Label>
</template>
@@ -0,0 +1,25 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
variant: { type: String, required: false },
});
</script>
<template>
<legend
data-slot="field-legend"
:data-variant="variant"
:class="
cn(
'mb-3 font-medium',
'data-[variant=legend]:text-base',
'data-[variant=label]:text-sm',
props.class,
)
"
>
<slot />
</legend>
</template>
@@ -0,0 +1,30 @@
<script setup>
import { cn } from "@/lib/utils";
import { Separator } from '@/Components/ui/separator';
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="field-separator"
:data-content="!!$slots.default"
:class="
cn(
'relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2',
props.class,
)
"
>
<Separator class="absolute inset-0 top-1/2" />
<span
v-if="$slots.default"
class="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
data-slot="field-separator-content"
>
<slot />
</span>
</div>
</template>
@@ -0,0 +1,22 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<fieldset
data-slot="field-set"
:class="
cn(
'flex flex-col gap-6',
'has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3',
props.class,
)
"
>
<slot />
</fieldset>
</template>
@@ -0,0 +1,21 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="field-label"
:class="
cn(
'flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50',
props.class,
)
"
>
<slot />
</div>
</template>
+36
View File
@@ -0,0 +1,36 @@
import { cva } from "class-variance-authority";
export const fieldVariants = cva(
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
horizontal: [
"flex-row items-center",
"[&>[data-slot=field-label]]:flex-auto",
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
responsive: [
"flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto",
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
},
},
defaultVariants: {
orientation: "vertical",
},
},
);
export { default as Field } from "./Field.vue";
export { default as FieldContent } from "./FieldContent.vue";
export { default as FieldDescription } from "./FieldDescription.vue";
export { default as FieldError } from "./FieldError.vue";
export { default as FieldGroup } from "./FieldGroup.vue";
export { default as FieldLabel } from "./FieldLabel.vue";
export { default as FieldLegend } from "./FieldLegend.vue";
export { default as FieldSeparator } from "./FieldSeparator.vue";
export { default as FieldSet } from "./FieldSet.vue";
export { default as FieldTitle } from "./FieldTitle.vue";
@@ -36,6 +36,7 @@ const props = defineProps({
reference: { type: null, required: false }, reference: { type: null, required: false },
asChild: { type: Boolean, required: false }, asChild: { type: Boolean, required: false },
as: { type: null, required: false }, as: { type: null, required: false },
disableOutsidePointerEvents: { type: Boolean, required: false },
class: { type: null, required: false }, class: { type: null, required: false },
}); });
const emits = defineEmits([ const emits = defineEmits([
+4 -6
View File
@@ -251,19 +251,17 @@ function isActive(patterns) {
: 'sticky top-0 h-screen overflow-y-auto', : 'sticky top-0 h-screen overflow-y-auto',
]" ]"
> >
<div <div class="h-16 px-4 flex items-center border-b border-sidebar-border bg-sidebar">
class="h-16 px-4 flex items-center justify-between border-b border-gray-200 bg-white"
>
<Link <Link
:href="route('dashboard')" :href="route('dashboard')"
class="flex items-center gap-2 hover:opacity-80 transition-opacity" class="flex items-center gap-1 hover:opacity-80 transition-opacity"
> >
<ApplicationMark /> <ApplicationMark />
<span <span
v-if="!sidebarCollapsed" v-if="!sidebarCollapsed"
class="text-sm font-semibold text-gray-900 transition-opacity" class="text-lg font-semibold text-sidebar-foreground transition-opacity"
> >
Admin Administrator
</span> </span>
</Link> </Link>
</div> </div>
+4 -17
View File
@@ -10,19 +10,6 @@ import GlobalSearch from "./Partials/GlobalSearch.vue";
import NotificationsBell from "./Partials/NotificationsBell.vue"; import NotificationsBell from "./Partials/NotificationsBell.vue";
import ToastContainer from "@/Components/Toast/ToastContainer.vue"; import ToastContainer from "@/Components/Toast/ToastContainer.vue";
import { Button } from "@/Components/ui/button"; import { Button } from "@/Components/ui/button";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import {
faMobileScreenButton,
faGaugeHigh,
faLayerGroup,
faUserGroup,
faFolderOpen,
faFileImport,
faTableList,
faFileCirclePlus,
faMap,
faGear,
} from "@fortawesome/free-solid-svg-icons";
import { MenuIcon } from "lucide-vue-next"; import { MenuIcon } from "lucide-vue-next";
import { SearchIcon } from "lucide-vue-next"; import { SearchIcon } from "lucide-vue-next";
import { ChevronDownIcon } from "lucide-vue-next"; import { ChevronDownIcon } from "lucide-vue-next";
@@ -310,18 +297,18 @@ function isActive(patterns) {
]" ]"
> >
<div <div
class="h-16 px-4 flex items-center justify-between border-b border-sidebar-border bg-sidebar" class="h-16 px-4 flex items-center border-b border-sidebar-border bg-sidebar"
> >
<Link <Link
:href="route('dashboard')" :href="route('dashboard')"
class="flex items-center gap-2 hover:opacity-80 transition-opacity" class="flex items-center gap-1 hover:opacity-80 transition-opacity"
> >
<ApplicationMark /> <ApplicationMark />
<span <span
v-if="!sidebarCollapsed" v-if="!sidebarCollapsed"
class="text-sm font-semibold text-sidebar-foreground transition-opacity" class="text-lg font-semibold text-sidebar-foreground transition-opacity"
> >
Teren Aplikacija
</span> </span>
</Link> </Link>
</div> </div>
+3 -3
View File
@@ -149,14 +149,14 @@ const closeSearch = () => (searchOpen.value = false);
> >
<Link <Link
:href="route('phone.index')" :href="route('phone.index')"
class="flex items-center gap-2 hover:opacity-80 transition-opacity" class="flex items-center gap-1 hover:opacity-80 transition-opacity"
> >
<ApplicationMark /> <ApplicationMark />
<span <span
v-if="showLabels" v-if="showLabels"
class="text-sm font-semibold text-sidebar-foreground transition-opacity" class="text-lg font-semibold text-sidebar-foreground transition-opacity"
> >
Teren Mobitel
</span> </span>
</Link> </Link>
</div> </div>
+80 -104
View File
@@ -1,8 +1,18 @@
<script setup> <script setup>
import { Input } from "@/Components/ui/input"; import { Input } from "@/Components/ui/input";
import { Badge } from "@/Components/ui/badge";
import { Card, CardContent } from "@/Components/ui/card";
import { Separator } from "@/Components/ui/separator";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/Components/ui/dialog";
import axios from "axios"; import axios from "axios";
import { debounce } from "lodash"; import { debounce } from "lodash";
import { SearchIcon } from "@/Utilities/Icons"; import { SearchIcon, XIcon } from "lucide-vue-next";
import { onMounted, onUnmounted, ref, watch } from "vue"; import { onMounted, onUnmounted, ref, watch } from "vue";
import { Link } from "@inertiajs/vue3"; import { Link } from "@inertiajs/vue3";
@@ -55,139 +65,114 @@ onMounted(() => window.addEventListener("keydown", onKeydown));
onUnmounted(() => window.removeEventListener("keydown", onKeydown)); onUnmounted(() => window.removeEventListener("keydown", onKeydown));
</script> </script>
<template> <template>
<teleport to="body"> <Dialog :open="isOpen" @update:open="(v) => (isOpen = v)">
<transition name="fade"> <DialogContent class="max-w-3xl p-0 gap-0 [&>button]:hidden">
<div v-if="isOpen" class="fixed inset-0 z-50"> <div class="p-4 border-b" ref="inputWrap">
<div
class="absolute inset-0 bg-gradient-to-br from-slate-900/60 to-slate-800/60 backdrop-blur-sm"
@click="isOpen = false"
></div>
<div
class="absolute inset-0 flex items-start justify-center p-4 pt-20 sm:pt-28"
@click.self="isOpen = false"
>
<div
class="w-full max-w-3xl rounded-2xl border border-white/10 bg-white/80 backdrop-blur-xl shadow-2xl ring-1 ring-black/5 overflow-hidden"
role="dialog"
aria-modal="true"
>
<div
class="p-4 border-b border-slate-200/60"
ref="inputWrap"
>
<div class="relative"> <div class="relative">
<div class="relative"> <SearchIcon
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500"> class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground"
<SearchIcon /> />
</div>
<Input <Input
v-model="query" v-model="query"
placeholder="Išči po naročnikih ali primerih (Ctrl+K za zapiranje)" placeholder="Išči po naročnikih ali primerih (ESC za zapiranje)"
class="w-full pl-10 pr-16 rounded-xl" class="w-full pl-10 pr-16"
/> />
<button <button
v-if="query" v-if="query"
@click="query = ''" @click="query = ''"
class="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-slate-500 hover:text-slate-700" class="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-accent"
> >
ESC <XIcon class="h-4 w-4 text-muted-foreground" />
</button> </button>
</div> </div>
</div> </div>
</div> <div class="max-h-[65vh] overflow-y-auto">
<div
class="max-h-[65vh] overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-300"
>
<div <div
v-if="!query" v-if="!query"
class="p-8 text-sm text-slate-500 text-center space-y-2" class="p-8 text-sm text-muted-foreground text-center space-y-2"
> >
<p>Začni tipkati za iskanje.</p> <p>Začni tipkati za iskanje.</p>
<p class="text-xs"> <p class="text-xs">
Namig: uporabi Namig: uporabi <Badge variant="secondary" class="font-mono">Ctrl</Badge> +
<kbd <Badge variant="secondary" class="font-mono">K</Badge>
class="px-1.5 py-0.5 bg-slate-100 rounded font-mono text-[10px]"
>Ctrl</kbd
>
+
<kbd
class="px-1.5 py-0.5 bg-slate-100 rounded font-mono text-[10px]"
>K</kbd
>
</p> </p>
</div> </div>
<div v-else class="divide-y divide-slate-200/70"> <div v-else class="space-y-4 p-4">
<div v-if="result.clients.length" class="py-3"> <!-- Clients Results -->
<div v-if="result.clients.length">
<div <div
class="flex items-center justify-between px-5 pb-1 text-[11px] font-semibold tracking-wide uppercase text-slate-500" class="flex items-center justify-between pb-2 text-xs font-semibold tracking-wide uppercase text-muted-foreground"
> >
<span>Naročniki</span> <span>Naročniki</span>
<span <Badge variant="secondary">{{ result.clients.length }}</Badge>
class="rounded bg-slate-100 text-slate-600 px-2 py-0.5 text-[10px]"
>{{ result.clients.length }}</span
>
</div> </div>
<ul role="list" class="px-2 space-y-1"> <div class="space-y-1">
<li v-for="client in result.clients" :key="client.client_uuid">
<Link <Link
v-for="client in result.clients"
:key="client.client_uuid"
:href="route('client.show', { uuid: client.client_uuid })" :href="route('client.show', { uuid: client.client_uuid })"
class="group flex items-center gap-3 w-full rounded-lg px-3 py-2 text-sm hover:bg-indigo-50/70 transition" class="group flex items-center gap-3 w-full rounded-lg px-3 py-2 text-sm hover:bg-accent transition"
@click="isOpen = false" @click="isOpen = false"
> >
<span <Badge
class="shrink-0 w-6 h-6 rounded bg-indigo-100 text-indigo-600 flex items-center justify-center text-[11px] font-semibold group-hover:bg-indigo-200" variant="outline"
>C</span class="shrink-0 w-6 h-6 flex items-center justify-center"
> >C</Badge
<span
class="text-slate-700 group-hover:text-slate-900"
>{{ client.full_name }}</span
> >
<span class="font-medium">{{ client.full_name }}</span>
</Link> </Link>
</li>
</ul>
</div> </div>
<div v-if="result.client_cases.length" class="py-3"> </div>
<Separator v-if="result.clients.length && result.client_cases.length" />
<!-- Client Cases Results -->
<div v-if="result.client_cases.length">
<div <div
class="flex items-center justify-between px-5 pb-1 text-[11px] font-semibold tracking-wide uppercase text-slate-500" class="flex items-center justify-between pb-2 text-xs font-semibold tracking-wide uppercase text-muted-foreground"
> >
<span>Primeri</span> <span>Primeri</span>
<span <Badge variant="secondary">{{ result.client_cases.length }}</Badge>
class="rounded bg-slate-100 text-slate-600 px-2 py-0.5 text-[10px]"
>{{ result.client_cases.length }}</span
>
</div> </div>
<ul role="list" class="px-2 space-y-1"> <div class="space-y-2">
<li <Card
v-for="clientcase in result.client_cases" v-for="clientcase in result.client_cases"
:key="clientcase.case_uuid" :key="clientcase.case_uuid"
class="rounded-xl border border-slate-200/70 bg-white/70 px-4 py-3 shadow-sm hover:shadow-md transition flex flex-col gap-1" class="hover:shadow-md transition p-0"
> >
<div class="flex items-center gap-2"> <CardContent class="p-3 space-y-2">
<div class="space-y-1">
<Link <Link
:href=" :href="
route('clientCase.show', { route('clientCase.show', {
client_case: clientcase.case_uuid, client_case: clientcase.case_uuid,
}) })
" "
class="text-left font-medium hover:underline leading-tight text-slate-800" class="text-sm font-medium hover:underline block"
@click="isOpen = false" @click="isOpen = false"
> >
{{ clientcase.full_name }} {{ clientcase.full_name }}
</Link> </Link>
<template v-if="clientcase.contract_reference"> <div
<span v-if="clientcase.client_full_name"
class="font-mono text-[11px] tracking-tight text-indigo-600 bg-indigo-50 border border-indigo-200 rounded px-1.5 py-0.5 whitespace-nowrap shadow-sm" class="text-xs text-muted-foreground"
> >
Naročnik: {{ clientcase.client_full_name }}
</div>
</div>
<div
v-if="clientcase.contract_reference"
class="flex items-center gap-1"
>
<Badge variant="outline" class="font-mono text-xs">
{{ clientcase.contract_reference }} {{ clientcase.contract_reference }}
</span> </Badge>
</template>
</div> </div>
<div <div
v-if=" v-if="
clientcase.contract_segments && clientcase.contract_segments && clientcase.contract_segments.length
clientcase.contract_segments.length
" "
class="flex flex-wrap gap-1 mt-1" class="flex flex-wrap gap-1"
> >
<Link <Link
v-for="seg in clientcase.contract_segments" v-for="seg in clientcase.contract_segments"
@@ -199,17 +184,18 @@ onUnmounted(() => window.removeEventListener("keydown", onKeydown));
'?segment=' + '?segment=' +
(seg.id || seg) (seg.id || seg)
" "
class="group/seg text-[10px] uppercase tracking-wide bg-gradient-to-br from-purple-50 to-purple-100 text-purple-700 border border-purple-200 px-1.5 py-0.5 rounded hover:from-purple-100 hover:to-purple-200 hover:border-purple-300 transition"
@click="isOpen = false" @click="isOpen = false"
> >
<Badge variant="secondary" class="text-xs uppercase">
{{ seg.name || seg }} {{ seg.name || seg }}
</Badge>
</Link> </Link>
</div> </div>
<div <div
v-else-if=" v-else-if="
clientcase.case_segments && clientcase.case_segments.length clientcase.case_segments && clientcase.case_segments.length
" "
class="flex flex-wrap gap-1 mt-1" class="flex flex-wrap gap-1"
> >
<Link <Link
v-for="seg in clientcase.case_segments" v-for="seg in clientcase.case_segments"
@@ -221,37 +207,27 @@ onUnmounted(() => window.removeEventListener("keydown", onKeydown));
'?segment=' + '?segment=' +
(seg.id || seg) (seg.id || seg)
" "
class="text-[10px] uppercase tracking-wide bg-slate-100 text-slate-600 border border-slate-200 px-1.5 py-0.5 rounded hover:bg-slate-200 hover:text-slate-700 transition"
@click="isOpen = false" @click="isOpen = false"
> >
<Badge variant="outline" class="text-xs uppercase">
{{ seg.name }} {{ seg.name }}
</Badge>
</Link> </Link>
</div> </div>
</li> </CardContent>
</ul> </Card>
</div> </div>
</div>
<!-- No Results -->
<div <div
v-if="!result.clients.length && !result.client_cases.length" v-if="!result.clients.length && !result.client_cases.length"
class="p-8 text-center text-sm text-slate-500" class="p-8 text-center text-sm text-muted-foreground"
> >
Ni rezultatov. Ni rezultatov.
</div> </div>
</div> </div>
</div> </div>
</div> </DialogContent>
</div> </Dialog>
</div>
</transition>
</teleport>
</template> </template>
<style>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
@@ -1,12 +1,12 @@
<script setup> <script setup>
import { computed, onMounted, ref, watch } from "vue"; import { computed, onMounted, ref, watch } from "vue";
import { usePage, Link, router } from "@inertiajs/vue3"; import { usePage, Link, router } from "@inertiajs/vue3";
import Dropdown from "@/Components/Dropdown.vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { faBell } from "@fortawesome/free-solid-svg-icons";
import { BellIcon } from "lucide-vue-next"; import { BellIcon } from "lucide-vue-next";
import { Badge } from "@/Components/ui/badge"; import { Badge } from "@/Components/ui/badge";
import { Button } from "@/Components/ui/button"; import { Button } from "@/Components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/Components/ui/popover";
import { ScrollArea } from "@/Components/ui/scroll-area";
import { Separator } from "@/Components/ui/separator";
const page = usePage(); const page = usePage();
const due = computed( const due = computed(
@@ -83,12 +83,8 @@ function markRead(item) {
</script> </script>
<template> <template>
<Dropdown <Popover>
align="right" <PopoverTrigger as-child>
width="72"
:content-classes="['p-0', 'bg-white', 'max-h-96', 'overflow-hidden']"
>
<template #trigger>
<Button variant="ghost" size="default" class="relative"> <Button variant="ghost" size="default" class="relative">
<BellIcon /> <BellIcon />
@@ -100,32 +96,30 @@ function markRead(item) {
{{ count }} {{ count }}
</Badge> </Badge>
</Button> </Button>
</template> </PopoverTrigger>
<template #content> <PopoverContent align="end" class="w-96 p-0">
<div <div class="px-4 py-3 flex items-center justify-between border-b">
class="px-3 py-2 text-xs text-gray-400 border-b sticky top-0 bg-white z-10 flex items-center justify-between" <span class="text-sm font-medium">Zapadejo danes</span>
>
<span>Zapadejo danes</span>
<Link <Link
:href="route('notifications.unread')" :href="route('notifications.unread')"
class="text-indigo-600 hover:text-indigo-700" class="text-sm text-primary hover:underline"
>Vsa obvestila</Link >Vsa obvestila</Link
> >
</div> </div>
<!-- Scrollable content area with max height -->
<div class="max-h-80 overflow-auto"> <ScrollArea class="h-72">
<div v-if="!count" class="px-3 py-3 text-sm text-gray-500"> <div v-if="!count" class="px-4 py-8 text-center">
Ni zapadlih aktivnosti danes. <p class="text-sm text-muted-foreground">Ni zapadlih aktivnosti danes.</p>
</div> </div>
<ul v-else class="divide-y"> <div v-else class="divide-y">
<li <div
v-for="item in items" v-for="item in items"
:key="item.id" :key="item.id"
class="px-3 py-2 text-sm flex items-start gap-2" class="px-4 py-3 flex items-start gap-3 hover:bg-accent/50 transition-colors"
> >
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0 space-y-1">
<div class="font-medium text-gray-800 truncate"> <div class="font-medium truncate">
<template v-if="item.contract?.uuid"> <template v-if="item.contract?.uuid">
Pogodba: Pogodba:
<Link <Link
@@ -135,7 +129,7 @@ function markRead(item) {
client_case: item.contract.client_case.uuid, client_case: item.contract.client_case.uuid,
}) })
" "
class="text-indigo-600 hover:text-indigo-700 hover:underline" class="text-primary hover:underline"
> >
{{ item.contract?.reference || "—" }} {{ item.contract?.reference || "—" }}
</Link> </Link>
@@ -148,7 +142,7 @@ function markRead(item) {
:href=" :href="
route('clientCase.show', { client_case: item.client_case.uuid }) route('clientCase.show', { client_case: item.client_case.uuid })
" "
class="text-indigo-600 hover:text-indigo-700 hover:underline" class="text-primary hover:underline"
> >
{{ item.client_case?.person?.full_name || "—" }} {{ item.client_case?.person?.full_name || "—" }}
</Link> </Link>
@@ -157,37 +151,38 @@ function markRead(item) {
</div> </div>
<!-- Partner / Client full name (use contract.client when available; fallback to case.client) --> <!-- Partner / Client full name (use contract.client when available; fallback to case.client) -->
<div <div
class="text-xs text-gray-500 truncate" class="text-xs text-muted-foreground truncate"
v-if="item.contract?.client?.person?.full_name" v-if="item.contract?.client?.person?.full_name"
> >
Partner: {{ item.contract.client.person.full_name }} Partner: {{ item.contract.client.person.full_name }}
</div> </div>
<div <div
class="text-xs text-gray-500 truncate" class="text-xs text-muted-foreground truncate"
v-else-if="item.client_case?.client?.person?.full_name" v-else-if="item.client_case?.client?.person?.full_name"
> >
Partner: {{ item.client_case.client.person.full_name }} Partner: {{ item.client_case.client.person.full_name }}
</div> </div>
<div class="text-gray-600 truncate" v-if="item.contract"> <div class="text-sm truncate" v-if="item.contract">
{{ fmtEUR(item.contract?.account?.balance_amount) }} {{ fmtEUR(item.contract?.account?.balance_amount) }}
</div> </div>
</div> </div>
<div class="flex flex-col items-end gap-1"> <div class="flex flex-col items-end gap-1.5 shrink-0">
<div class="text-xs text-gray-500 whitespace-nowrap"> <div class="text-xs text-muted-foreground whitespace-nowrap">
{{ fmtDate(item.due_date) }} {{ fmtDate(item.due_date) }}
</div> </div>
<button <Button
type="button" variant="ghost"
class="text-[11px] text-gray-400 hover:text-gray-600" size="sm"
class="h-6 px-2 text-xs"
@click.stop="markRead(item)" @click.stop="markRead(item)"
title="Skrij obvestilo" title="Skrij obvestilo"
> >
Skrij Skrij
</button> </Button>
</div> </div>
</li>
</ul>
</div> </div>
</template> </div>
</Dropdown> </ScrollArea>
</PopoverContent>
</Popover>
</template> </template>
+70 -60
View File
@@ -2,6 +2,7 @@
import AdminLayout from "@/Layouts/AdminLayout.vue"; import AdminLayout from "@/Layouts/AdminLayout.vue";
import { Link, router, useForm } from "@inertiajs/vue3"; import { Link, router, useForm } from "@inertiajs/vue3";
import { ref, computed, nextTick } from "vue"; import { ref, computed, nextTick } from "vue";
import axios from "axios";
import { import {
Card, Card,
CardContent, CardContent,
@@ -39,6 +40,9 @@ import {
BadgeCheckIcon, BadgeCheckIcon,
} from "lucide-vue-next"; } from "lucide-vue-next";
import { fmtDateDMY } from "@/Utilities/functions"; import { fmtDateDMY } from "@/Utilities/functions";
import { upperFirst } from "lodash";
import AppCombobox from "@/Components/app/ui/AppCombobox.vue";
import AppRangeDatePicker from "@/Components/app/ui/AppRangeDatePicker.vue";
const props = defineProps({ const props = defineProps({
profiles: { type: Array, default: () => [] }, profiles: { type: Array, default: () => [] },
@@ -123,13 +127,19 @@ const contracts = ref({
const segmentId = ref(null); const segmentId = ref(null);
const search = ref(""); const search = ref("");
const clientId = ref(null); const clientId = ref(null);
const startDateFrom = ref(""); const startDateRange = ref({ start: null, end: null });
const startDateTo = ref(""); const promiseDateRange = ref({ start: null, end: null });
const promiseDateFrom = ref("");
const promiseDateTo = ref("");
const onlyMobile = ref(false); const onlyMobile = ref(false);
const onlyValidated = ref(false); const onlyValidated = ref(false);
const loadingContracts = ref(false); const loadingContracts = ref(false);
// Transform clients for AppCombobox
const clientItems = computed(() =>
props.clients.map((c) => ({
value: c.id,
label: c.name,
}))
);
const selectedContractIds = ref(new Set()); const selectedContractIds = ref(new Set());
const perPage = ref(25); const perPage = ref(25);
@@ -153,6 +163,11 @@ const contractColumns = [
accessorFn: (row) => row.selected_phone?.number || "—", accessorFn: (row) => row.selected_phone?.number || "—",
header: "Izbrana številka", header: "Izbrana številka",
}, },
{
id: "segment",
accessorFn: (row) => upperFirst(row.segment?.name) || "—",
header: "Segment",
},
{ accessorKey: "no_phone_reason", header: "Opomba" }, { accessorKey: "no_phone_reason", header: "Opomba" },
]; ];
@@ -175,19 +190,22 @@ async function loadContracts(url = null) {
if (segmentId.value) params.append("segment_id", segmentId.value); if (segmentId.value) params.append("segment_id", segmentId.value);
if (search.value) params.append("q", search.value); if (search.value) params.append("q", search.value);
if (clientId.value) params.append("client_id", clientId.value); if (clientId.value) params.append("client_id", clientId.value);
if (startDateFrom.value) params.append("start_date_from", startDateFrom.value); if (startDateRange.value?.start)
if (startDateTo.value) params.append("start_date_to", startDateTo.value); params.append("start_date_from", startDateRange.value.start);
if (promiseDateFrom.value) params.append("promise_date_from", promiseDateFrom.value); if (startDateRange.value?.end)
if (promiseDateTo.value) params.append("promise_date_to", promiseDateTo.value); params.append("start_date_to", startDateRange.value.end);
if (promiseDateRange.value?.start)
params.append("promise_date_from", promiseDateRange.value.start);
if (promiseDateRange.value?.end)
params.append("promise_date_to", promiseDateRange.value.end);
if (onlyMobile.value) params.append("only_mobile", "1"); if (onlyMobile.value) params.append("only_mobile", "1");
if (onlyValidated.value) params.append("only_validated", "1"); if (onlyValidated.value) params.append("only_validated", "1");
params.append("per_page", perPage.value); params.append("per_page", perPage.value);
const target = url || `${route("admin.packages.contracts")}?${params.toString()}`; const target = url || `${route("admin.packages.contracts")}?${params.toString()}`;
const res = await fetch(target, { const { data: json } = await axios.get(target, {
headers: { "X-Requested-With": "XMLHttpRequest" }, headers: { "X-Requested-With": "XMLHttpRequest" },
}); });
const json = await res.json();
// Wait for next tick before updating to avoid Vue reconciliation issues // Wait for next tick before updating to avoid Vue reconciliation issues
await nextTick(); await nextTick();
@@ -238,10 +256,13 @@ function goToPage(page) {
if (segmentId.value) params.append("segment_id", segmentId.value); if (segmentId.value) params.append("segment_id", segmentId.value);
if (search.value) params.append("q", search.value); if (search.value) params.append("q", search.value);
if (clientId.value) params.append("client_id", clientId.value); if (clientId.value) params.append("client_id", clientId.value);
if (startDateFrom.value) params.append("start_date_from", startDateFrom.value); if (startDateRange.value?.start)
if (startDateTo.value) params.append("start_date_to", startDateTo.value); params.append("start_date_from", startDateRange.value.start);
if (promiseDateFrom.value) params.append("promise_date_from", promiseDateFrom.value); if (startDateRange.value?.end) params.append("start_date_to", startDateRange.value.end);
if (promiseDateTo.value) params.append("promise_date_to", promiseDateTo.value); if (promiseDateRange.value?.start)
params.append("promise_date_from", promiseDateRange.value.start);
if (promiseDateRange.value?.end)
params.append("promise_date_to", promiseDateRange.value.end);
if (onlyMobile.value) params.append("only_mobile", "1"); if (onlyMobile.value) params.append("only_mobile", "1");
if (onlyValidated.value) params.append("only_validated", "1"); if (onlyValidated.value) params.append("only_validated", "1");
params.append("per_page", perPage.value); params.append("per_page", perPage.value);
@@ -255,10 +276,8 @@ function resetFilters() {
segmentId.value = null; segmentId.value = null;
clientId.value = null; clientId.value = null;
search.value = ""; search.value = "";
startDateFrom.value = ""; startDateRange.value = { start: null, end: null };
startDateTo.value = ""; promiseDateRange.value = { start: null, end: null };
promiseDateFrom.value = "";
promiseDateTo.value = "";
onlyMobile.value = false; onlyMobile.value = false;
onlyValidated.value = false; onlyValidated.value = false;
contracts.value = { contracts.value = {
@@ -448,9 +467,10 @@ const numbersCount = computed(() => {
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Checkbox <Checkbox
:checked="form.delivery_report" :model-value="form.delivery_report"
@update:checked="(val) => (form.delivery_report = val)" @update:model-value="(val) => (form.delivery_report = val)"
id="delivery-report" id="delivery-report"
:disabled="true"
/> />
<Label for="delivery-report" class="cursor-pointer text-sm"> <Label for="delivery-report" class="cursor-pointer text-sm">
Zahtevaj delivery report Zahtevaj delivery report
@@ -553,17 +573,15 @@ const numbersCount = computed(() => {
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label>Stranka</Label> <Label>Stranka</Label>
<Select v-model="clientId" @update:model-value="loadContracts()"> <AppCombobox
<SelectTrigger> v-model="clientId"
<SelectValue placeholder="Vse stranke" /> :items="clientItems"
</SelectTrigger> placeholder="Vse stranke"
<SelectContent> search-placeholder="Išči stranko..."
<SelectItem :value="null">Vse stranke</SelectItem> empty-text="Stranka ni najdena."
<SelectItem v-for="c in clients" :key="c.id" :value="c.id"> button-class="w-full"
{{ c.name }} @update:model-value="loadContracts()"
</SelectItem> />
</SelectContent>
</Select>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label>Iskanje po referenci</Label> <Label>Iskanje po referenci</Label>
@@ -586,29 +604,21 @@ const numbersCount = computed(() => {
<div class="grid gap-4 md:grid-cols-2"> <div class="grid gap-4 md:grid-cols-2">
<div class="space-y-3"> <div class="space-y-3">
<p class="text-sm text-muted-foreground">Datum začetka pogodbe</p> <p class="text-sm text-muted-foreground">Datum začetka pogodbe</p>
<div class="grid grid-cols-2 gap-2"> <AppRangeDatePicker
<div class="space-y-2"> v-model="startDateRange"
<Label class="text-xs">Od</Label> placeholder="Izberi obdobje"
<Input v-model="startDateFrom" type="date" /> button-class="w-full"
</div> :number-of-months="1"
<div class="space-y-2"> />
<Label class="text-xs">Do</Label>
<Input v-model="startDateTo" type="date" />
</div>
</div>
</div> </div>
<div class="space-y-3"> <div class="space-y-3">
<p class="text-sm text-muted-foreground">Datum obljube plačila</p> <p class="text-sm text-muted-foreground">Datum obljube plačila</p>
<div class="grid grid-cols-2 gap-2"> <AppRangeDatePicker
<div class="space-y-2"> v-model="promiseDateRange"
<Label class="text-xs">Od</Label> placeholder="Izberi obdobje"
<Input v-model="promiseDateFrom" type="date" /> button-class="w-full"
</div> :number-of-months="1"
<div class="space-y-2"> />
<Label class="text-xs">Do</Label>
<Input v-model="promiseDateTo" type="date" />
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -621,8 +631,8 @@ const numbersCount = computed(() => {
<div class="flex flex-wrap gap-4"> <div class="flex flex-wrap gap-4">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Checkbox <Checkbox
:checked="onlyMobile" :model-value="onlyMobile"
@update:checked=" @update:model-value="
(val) => { (val) => {
onlyMobile = val; onlyMobile = val;
} }
@@ -635,8 +645,8 @@ const numbersCount = computed(() => {
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Checkbox <Checkbox
:checked="onlyValidated" :model-value="onlyValidated"
@update:checked=" @update:model-value="
(val) => { (val) => {
onlyValidated = val; onlyValidated = val;
} }
@@ -653,11 +663,11 @@ const numbersCount = computed(() => {
<!-- Action buttons --> <!-- Action buttons -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Button @click="loadContracts()"> <Button @click="loadContracts()">
<SearchIcon class="h-4 w-4 mr-2" /> <SearchIcon class="h-4 w-4" />
Išči pogodbe Išči pogodbe
</Button> </Button>
<Button @click="resetFilters" variant="outline"> <Button @click="resetFilters" variant="outline">
<XCircleIcon class="h-4 w-4 mr-2" /> <XCircleIcon class="h-4 w-4" />
Počisti filtre Počisti filtre
</Button> </Button>
</div> </div>
@@ -669,7 +679,7 @@ const numbersCount = computed(() => {
<CardHeader> <CardHeader>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<CardTitle>Rezultati iskanja</CardTitle> <CardTitle>Rezultati iskanja (do 500 zapisov)</CardTitle>
<CardDescription v-if="contracts.meta.total > 0"> <CardDescription v-if="contracts.meta.total > 0">
Najdeno {{ contracts.meta.total }} Najdeno {{ contracts.meta.total }}
{{ {{
@@ -689,7 +699,7 @@ const numbersCount = computed(() => {
variant="secondary" variant="secondary"
class="text-sm" class="text-sm"
> >
<CheckCircle2Icon class="h-3 w-3 mr-1" /> <CheckCircle2Icon class="h-3 w-3" />
Izbrano: {{ selectedContractIds.size }} Izbrano: {{ selectedContractIds.size }}
</Badge> </Badge>
<Button <Button
@@ -702,7 +712,7 @@ const numbersCount = computed(() => {
@click="submitCreateFromContracts" @click="submitCreateFromContracts"
:disabled="selectedContractIds.size === 0 || creatingFromContracts" :disabled="selectedContractIds.size === 0 || creatingFromContracts"
> >
<SaveIcon class="h-4 w-4 mr-2" /> <SaveIcon class="h-4 w-4" />
Ustvari paket ({{ selectedContractIds.size }} Ustvari paket ({{ selectedContractIds.size }}
{{ {{
selectedContractIds.size === 1 selectedContractIds.size === 1
+9 -9
View File
@@ -18,6 +18,7 @@ import {
import DataTableNew2 from "@/Components/DataTable/DataTableNew2.vue"; import DataTableNew2 from "@/Components/DataTable/DataTableNew2.vue";
import { PackageIcon, PlusIcon, Trash2Icon, EyeIcon } from "lucide-vue-next"; import { PackageIcon, PlusIcon, Trash2Icon, EyeIcon } from "lucide-vue-next";
import AppCard from "@/Components/app/ui/card/AppCard.vue"; import AppCard from "@/Components/app/ui/card/AppCard.vue";
import { fmtDateTime } from "@/Utilities/functions";
const props = defineProps({ const props = defineProps({
packages: { type: Object, required: true }, packages: { type: Object, required: true },
@@ -29,7 +30,6 @@ const showDeleteDialog = ref(false);
const columns = [ const columns = [
{ accessorKey: "id", header: "ID" }, { accessorKey: "id", header: "ID" },
{ accessorKey: "uuid", header: "UUID" },
{ accessorKey: "name", header: "Ime" }, { accessorKey: "name", header: "Ime" },
{ accessorKey: "type", header: "Tip" }, { accessorKey: "type", header: "Tip" },
{ accessorKey: "status", header: "Status" }, { accessorKey: "status", header: "Status" },
@@ -84,7 +84,7 @@ function confirmDelete() {
</div> </div>
<Link :href="route('admin.packages.create')"> <Link :href="route('admin.packages.create')">
<Button> <Button>
<PlusIcon class="h-4 w-4 mr-2" /> <PlusIcon class="h-4 w-4" />
Nov paket Nov paket
</Button> </Button>
</Link> </Link>
@@ -111,10 +111,6 @@ function confirmDelete() {
:meta="packages" :meta="packages"
route-name="admin.packages.index" route-name="admin.packages.index"
> >
<template #cell-uuid="{ row }">
<span class="font-mono text-xs text-muted-foreground">{{ row.uuid }}</span>
</template>
<template #cell-name="{ row }"> <template #cell-name="{ row }">
<span class="text-sm">{{ row.name ?? "—" }}</span> <span class="text-sm">{{ row.name ?? "—" }}</span>
</template> </template>
@@ -128,7 +124,9 @@ function confirmDelete() {
</template> </template>
<template #cell-finished_at="{ row }"> <template #cell-finished_at="{ row }">
<span class="text-xs text-muted-foreground">{{ row.finished_at ?? "—" }}</span> <span class="text-xs text-muted-foreground">{{
fmtDateTime(row.finished_at) ?? "—"
}}</span>
</template> </template>
<template #cell-actions="{ row }"> <template #cell-actions="{ row }">
@@ -157,8 +155,10 @@ function confirmDelete() {
<AlertDialogTitle>Izbriši paket?</AlertDialogTitle> <AlertDialogTitle>Izbriši paket?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Ali ste prepričani, da želite izbrisati paket Ali ste prepričani, da želite izbrisati paket
<strong v-if="packageToDelete">#{{ packageToDelete.id }} - {{ packageToDelete.name || 'Brez imena' }}</strong>? <strong v-if="packageToDelete"
Tega dejanja ni mogoče razveljaviti. >#{{ packageToDelete.id }} -
{{ packageToDelete.name || "Brez imena" }}</strong
>? Tega dejanja ni mogoče razveljaviti.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
@@ -120,7 +120,8 @@ const store = async () => {
return `${y}-${m}-${day}`; return `${y}-${m}-${day}`;
}; };
const contractUuids = Array.isArray(form.contract_uuids) && form.contract_uuids.length > 0 const contractUuids =
Array.isArray(form.contract_uuids) && form.contract_uuids.length > 0
? form.contract_uuids ? form.contract_uuids
: null; : null;
@@ -175,9 +176,9 @@ const autoMailRequiresContract = computed(() => {
}); });
const contractItems = computed(() => { const contractItems = computed(() => {
return pageContracts.value.map(c => ({ return pageContracts.value.map((c) => ({
value: c.uuid, value: c.uuid,
label: `${c.reference}${c.name ? ` - ${c.name}` : ''}` label: `${c.reference}${c.name ? ` - ${c.name}` : ""}`,
})); }));
}); });
@@ -188,7 +189,10 @@ const autoMailDisabled = computed(() => {
if (form.contract_uuids && form.contract_uuids.length > 1) return true; if (form.contract_uuids && form.contract_uuids.length > 1) return true;
// Disable if template requires contract but none selected // Disable if template requires contract but none selected
if (autoMailRequiresContract.value && (!form.contract_uuids || form.contract_uuids.length === 0)) { if (
autoMailRequiresContract.value &&
(!form.contract_uuids || form.contract_uuids.length === 0)
) {
return true; return true;
} }
@@ -202,7 +206,10 @@ const autoMailDisabledHint = computed(() => {
return "Avtomatska e-pošta ni na voljo pri več pogodbah."; return "Avtomatska e-pošta ni na voljo pri več pogodbah.";
} }
if (autoMailRequiresContract.value && (!form.contract_uuids || form.contract_uuids.length === 0)) { if (
autoMailRequiresContract.value &&
(!form.contract_uuids || form.contract_uuids.length === 0)
) {
return "Ta e-poštna predloga zahteva pogodbo. Najprej izberite pogodbo."; return "Ta e-poštna predloga zahteva pogodbo. Najprej izberite pogodbo.";
} }
@@ -373,8 +380,12 @@ watch(
:clearable="true" :clearable="true"
:show-selected-chips="true" :show-selected-chips="true"
/> />
<p v-if="form.contract_uuids && form.contract_uuids.length > 1" class="text-xs text-muted-foreground"> <p
Bo ustvarjenih {{ form.contract_uuids.length }} aktivnosti (ena za vsako pogodbo). v-if="form.contract_uuids && form.contract_uuids.length > 1"
class="text-xs text-muted-foreground"
>
Bo ustvarjenih {{ form.contract_uuids.length }} aktivnosti (ena za vsako
pogodbo).
</p> </p>
</div> </div>
@@ -383,7 +394,7 @@ watch(
<Textarea <Textarea
id="activityNote" id="activityNote"
v-model="form.note" v-model="form.note"
class="block w-full" class="block w-full max-h-72"
placeholder="Opomba" placeholder="Opomba"
/> />
</div> </div>
@@ -412,10 +423,7 @@ watch(
<div v-if="showSendAutoMail()" class="space-y-2"> <div v-if="showSendAutoMail()" class="space-y-2">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<Switch <Switch v-model="form.send_auto_mail" :disabled="autoMailDisabled" />
v-model="form.send_auto_mail"
:disabled="autoMailDisabled"
/>
<Label class="cursor-pointer">Send auto email</Label> <Label class="cursor-pointer">Send auto email</Label>
</div> </div>
</div> </div>
@@ -423,7 +431,14 @@ watch(
{{ autoMailDisabledHint }} {{ autoMailDisabledHint }}
</p> </p>
<div v-if="templateAllowsAttachments && form.contract_uuids && form.contract_uuids.length === 1" class="mt-3"> <div
v-if="
templateAllowsAttachments &&
form.contract_uuids &&
form.contract_uuids.length === 1
"
class="mt-3"
>
<label class="inline-flex items-center gap-2"> <label class="inline-flex items-center gap-2">
<Switch v-model="form.attach_documents" /> <Switch v-model="form.attach_documents" />
<span class="text-sm">Dodaj priponke iz izbrane pogodbe</span> <span class="text-sm">Dodaj priponke iz izbrane pogodbe</span>
@@ -445,21 +460,28 @@ watch(
<div <div
v-for="doc in availableContractDocs" v-for="doc in availableContractDocs"
:key="doc.uuid || doc.id" :key="doc.uuid || doc.id"
class="flex items-center gap-2 text-sm" class="flex items-center max-w-sm gap-2 text-sm"
> >
<Switch <Switch
:model-value="form.attachment_document_ids.includes(doc.id)" :model-value="form.attachment_document_ids.includes(doc.id)"
@update:model-value="(checked) => { @update:model-value="
(checked) => {
if (checked) { if (checked) {
if (!form.attachment_document_ids.includes(doc.id)) { if (!form.attachment_document_ids.includes(doc.id)) {
form.attachment_document_ids.push(doc.id); form.attachment_document_ids.push(doc.id);
} }
} else { } else {
form.attachment_document_ids = form.attachment_document_ids.filter(id => id !== doc.id); form.attachment_document_ids = form.attachment_document_ids.filter(
(id) => id !== doc.id
);
} }
}" }
"
/> />
<span>{{ doc.original_name || doc.name }}</span> <div class="wrap-anywhere">
<p>
{{ doc.original_name || doc.name }}
</p>
<span class="text-xs text-gray-400" <span class="text-xs text-gray-400"
>({{ doc.extension?.toUpperCase() || "" }}, >({{ doc.extension?.toUpperCase() || "" }},
{{ (doc.size / 1024 / 1024).toFixed(2) }} MB)</span {{ (doc.size / 1024 / 1024).toFixed(2) }} MB)</span
@@ -467,6 +489,7 @@ watch(
</div> </div>
</div> </div>
</div> </div>
</div>
</template> </template>
<div <div
v-if="availableContractDocs.length === 0" v-if="availableContractDocs.length === 0"
@@ -0,0 +1,229 @@
<script setup>
import { ref, watch } from "vue";
import { router } from "@inertiajs/vue3";
import DialogModal from "@/Components/DialogModal.vue";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label";
import { ScrollArea } from "@/Components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
import { Plus, Trash2 } from "lucide-vue-next";
const props = defineProps({
show: { type: Boolean, default: false },
client_case: { type: Object, required: true },
contract: { type: Object, default: null },
});
const emit = defineEmits(["close"]);
const processing = ref(false);
const metaEntries = ref([]);
// Extract meta entries from contract
function extractMetaEntries(contract) {
if (!contract?.meta) return [];
const results = [];
const visit = (node, keyName) => {
if (node === null || node === undefined) return;
if (Array.isArray(node)) {
node.forEach((el) => visit(el));
return;
}
if (typeof node === "object") {
const hasValue = Object.prototype.hasOwnProperty.call(node, "value");
const hasTitle = Object.prototype.hasOwnProperty.call(node, "title");
if (hasValue || hasTitle) {
const title = (node.title || keyName || "").toString().trim() || keyName || "";
results.push({
title,
value: node.value ?? "",
type: node.type || "string",
});
return;
}
for (const [k, v] of Object.entries(node)) {
visit(v, k);
}
return;
}
if (keyName) {
results.push({ title: keyName, value: node ?? "", type: "string" });
}
};
visit(contract.meta, undefined);
return results;
}
// Initialize meta entries when dialog opens
watch(
() => props.show,
(newVal) => {
if (newVal && props.contract) {
const entries = extractMetaEntries(props.contract);
metaEntries.value =
entries.length > 0 ? entries : [{ title: "", value: "", type: "string" }];
}
}
);
function addEntry() {
metaEntries.value.push({ title: "", value: "", type: "string" });
}
function removeEntry(index) {
metaEntries.value.splice(index, 1);
if (metaEntries.value.length === 0) {
metaEntries.value.push({ title: "", value: "", type: "string" });
}
}
function close() {
emit("close");
}
function submit() {
if (!props.contract?.uuid || processing.value) return;
// Filter out empty entries and build meta object
const validEntries = metaEntries.value.filter((e) => e.title && e.title.trim() !== "");
const meta = {};
validEntries.forEach((entry) => {
meta[entry.title] = {
title: entry.title,
value: entry.value,
type: entry.type,
};
});
processing.value = true;
router.patch(
route("clientCase.contract.patchMeta", {
client_case: props.client_case.uuid,
uuid: props.contract.uuid,
}),
{ meta },
{
preserveScroll: true,
only: ["contracts"],
onSuccess: () => {
close();
processing.value = false;
},
onError: () => {
processing.value = false;
},
onFinish: () => {
processing.value = false;
},
}
);
}
</script>
<template>
<DialogModal :show="show" max-width="3xl" @close="close">
<template #title>
<h3 class="text-lg font-semibold leading-6 text-foreground">Uredi Meta podatke</h3>
</template>
<template #description>
Posodobi meta podatke za pogodbo {{ contract?.reference }}
</template>
<template #content>
<form id="meta-edit-form" @submit.prevent="submit" class="space-y-4">
<ScrollArea class="h-[60vh]">
<div class="space-y-3 pr-4">
<div
v-for="(entry, index) in metaEntries"
:key="index"
class="flex items-start gap-2 p-3 border rounded-lg bg-muted/20"
>
<div class="flex-1 space-y-3">
<div>
<Label :for="`meta-title-${index}`">Naziv</Label>
<Input
:id="`meta-title-${index}`"
v-model="entry.title"
placeholder="Vnesi naziv..."
class="mt-1"
/>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<Label :for="`meta-type-${index}`">Tip</Label>
<Select v-model="entry.type">
<SelectTrigger :id="`meta-type-${index}`" class="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="string">Tekst</SelectItem>
<SelectItem value="number">Številka</SelectItem>
<SelectItem value="date">Datum</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label :for="`meta-value-${index}`">Vrednost</Label>
<Input
:id="`meta-value-${index}`"
v-model="entry.value"
:type="
entry.type === 'date'
? 'date'
: entry.type === 'number'
? 'number'
: 'text'
"
:step="entry.type === 'number' ? '0.01' : undefined"
placeholder="Vnesi vrednost..."
class="mt-1"
/>
</div>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
@click="removeEntry(index)"
:disabled="metaEntries.length === 1"
class="mt-6"
>
<Trash2 class="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
</ScrollArea>
<Button type="button" variant="outline" @click="addEntry" class="w-full">
<Plus class="h-4 w-4 mr-2" />
Dodaj vnos
</Button>
</form>
</template>
<template #footer>
<div class="flex flex-row gap-2">
<Button type="button" variant="ghost" @click="close" :disabled="processing">
Prekliči
</Button>
<Button type="submit" form="meta-edit-form" :disabled="processing">
{{ processing ? "Shranjujem..." : "Shrani" }}
</Button>
</div>
</template>
</DialogModal>
</template>
@@ -15,6 +15,7 @@ import CaseObjectCreateDialog from "./CaseObjectCreateDialog.vue";
import CaseObjectsDialog from "./CaseObjectsDialog.vue"; import CaseObjectsDialog from "./CaseObjectsDialog.vue";
import PaymentDialog from "./PaymentDialog.vue"; import PaymentDialog from "./PaymentDialog.vue";
import ViewPaymentsDialog from "./ViewPaymentsDialog.vue"; import ViewPaymentsDialog from "./ViewPaymentsDialog.vue";
import ContractMetaEditDialog from "./ContractMetaEditDialog.vue";
import CreateDialog from "@/Components/Dialogs/CreateDialog.vue"; import CreateDialog from "@/Components/Dialogs/CreateDialog.vue";
import ConfirmationDialog from "@/Components/Dialogs/ConfirmationDialog.vue"; import ConfirmationDialog from "@/Components/Dialogs/ConfirmationDialog.vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
@@ -33,6 +34,16 @@ import {
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import EmptyState from "@/Components/EmptyState.vue"; import EmptyState from "@/Components/EmptyState.vue";
import { Button } from "@/Components/ui/button"; import { Button } from "@/Components/ui/button";
import { Label } from "@/Components/ui/label";
import { Input } from "@/Components/ui/input";
import { Textarea } from "@/Components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
const props = defineProps({ const props = defineProps({
client: { type: Object, default: null }, client: { type: Object, default: null },
@@ -433,6 +444,19 @@ const closePaymentsDialog = () => {
selectedContract.value = null; selectedContract.value = null;
}; };
// Meta edit dialog
const showMetaEditDialog = ref(false);
const openMetaEditDialog = (c) => {
selectedContract.value = c;
showMetaEditDialog.value = true;
};
const closeMetaEditDialog = () => {
showMetaEditDialog.value = false;
selectedContract.value = null;
};
// Columns configuration // Columns configuration
const columns = computed(() => [ const columns = computed(() => [
{ key: "reference", label: "Ref.", sortable: false, align: "center" }, { key: "reference", label: "Ref.", sortable: false, align: "center" },
@@ -638,6 +662,19 @@ const availableSegmentsCount = computed(() => {
<div class="text-gray-500">Ni meta podatkov.</div> <div class="text-gray-500">Ni meta podatkov.</div>
</template> </template>
</div> </div>
<div v-if="edit && row.active" class="border-t border-gray-200 mt-2 pt-2">
<button
type="button"
@click="openMetaEditDialog(row)"
class="w-full flex items-center gap-2 px-3 py-2 text-left text-sm hover:bg-gray-100 rounded transition-colors"
>
<FontAwesomeIcon
:icon="faPenToSquare"
class="h-3.5 w-3.5 text-gray-600"
/>
<span>Uredi meta podatke</span>
</button>
</div>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -901,6 +938,13 @@ const availableSegmentsCount = computed(() => {
:edit="edit" :edit="edit"
/> />
<ContractMetaEditDialog
:show="showMetaEditDialog"
:client_case="client_case"
:contract="selectedContract"
@close="closeMetaEditDialog"
/>
<!-- Generate Document Dialog --> <!-- Generate Document Dialog -->
<CreateDialog <CreateDialog
:show="showGenerateDialog" :show="showGenerateDialog"
@@ -913,18 +957,18 @@ const availableSegmentsCount = computed(() => {
@confirm="submitGenerate" @confirm="submitGenerate"
> >
<div class="space-y-4"> <div class="space-y-4">
<div> <div class="space-y-2">
<label class="block text-sm font-medium text-gray-700">Predloga</label> <Label>Predloga</Label>
<select <Select v-model="selectedTemplateSlug" @update:model-value="onTemplateChange">
v-model="selectedTemplateSlug" <SelectTrigger>
@change="onTemplateChange" <SelectValue placeholder="Izberi predlogo..." />
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500" </SelectTrigger>
> <SelectContent>
<option :value="null">Izberi predlogo...</option> <SelectItem v-for="t in templates" :key="t.slug" :value="t.slug">
<option v-for="t in templates" :key="t.slug" :value="t.slug">
{{ t.name }} (v{{ t.version }}) {{ t.name }} (v{{ t.version }})
</option> </SelectItem>
</select> </SelectContent>
</Select>
</div> </div>
<!-- Custom inputs --> <!-- Custom inputs -->
@@ -932,14 +976,30 @@ const availableSegmentsCount = computed(() => {
<div class="border-t border-gray-200 pt-4"> <div class="border-t border-gray-200 pt-4">
<h3 class="text-sm font-medium text-gray-700 mb-3">Prilagojene vrednosti</h3> <h3 class="text-sm font-medium text-gray-700 mb-3">Prilagojene vrednosti</h3>
<div class="space-y-3"> <div class="space-y-3">
<div v-for="token in customTokenList" :key="token"> <div v-for="token in customTokenList" :key="token" class="space-y-2">
<label class="block text-sm font-medium text-gray-700"> <Label>
{{ token.replace(/^custom\./, "") }} {{ token.replace(/^custom\./, "") }}
</label> </Label>
<input <Textarea
v-if="templateCustomTypes[token.replace(/^custom\./, '')] === 'text'"
v-model="customInputs[token.replace(/^custom\./, '')]" v-model="customInputs[token.replace(/^custom\./, '')]"
type="text" rows="3"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500" />
<Input
v-else
v-model="customInputs[token.replace(/^custom\./, '')]"
:type="
templateCustomTypes[token.replace(/^custom\./, '')] === 'date'
? 'date'
: templateCustomTypes[token.replace(/^custom\./, '')] === 'number'
? 'number'
: 'text'
"
:step="
templateCustomTypes[token.replace(/^custom\./, '')] === 'number'
? '0.01'
: undefined
"
/> />
</div> </div>
</div> </div>
@@ -948,26 +1008,30 @@ const availableSegmentsCount = computed(() => {
<!-- Address overrides --> <!-- Address overrides -->
<div class="border-t border-gray-200 pt-4 space-y-3"> <div class="border-t border-gray-200 pt-4 space-y-3">
<h3 class="text-sm font-medium text-gray-700">Naslovi</h3> <h3 class="text-sm font-medium text-gray-700 mb-2">Naslovi</h3>
<div> <div class="space-y-2">
<label class="block text-sm font-medium text-gray-700">Naslov stranke</label> <Label>Naslov stranke</Label>
<select <Select v-model="clientAddressSource">
v-model="clientAddressSource" <SelectTrigger>
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500" <SelectValue />
> </SelectTrigger>
<option value="client">Stranka</option> <SelectContent>
<option value="case_person">Oseba primera</option> <SelectItem value="client">Stranka</SelectItem>
</select> <SelectItem value="case_person">Oseba primera</SelectItem>
</SelectContent>
</Select>
</div> </div>
<div> <div class="space-y-2">
<label class="block text-sm font-medium text-gray-700">Naslov osebe</label> <Label>Naslov osebe</Label>
<select <Select v-model="personAddressSource">
v-model="personAddressSource" <SelectTrigger>
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500" <SelectValue />
> </SelectTrigger>
<option value="case_person">Oseba primera</option> <SelectContent>
<option value="client">Stranka</option> <SelectItem value="case_person">Oseba primera</SelectItem>
</select> <SelectItem value="client">Stranka</SelectItem>
</SelectContent>
</Select>
</div> </div>
</div> </div>
+12 -10
View File
@@ -107,7 +107,7 @@ const onDocSaved = () => {
router.reload({ only: ["documents"] }); router.reload({ only: ["documents"] });
}; };
const viewer = ref({ open: false, src: "", title: "" }); const viewer = ref({ open: false, src: "", title: "", mimeType: "", filename: "" });
const openViewer = (doc) => { const openViewer = (doc) => {
const kind = classifyDocument(doc); const kind = classifyDocument(doc);
const isContractDoc = (doc?.documentable_type || "").toLowerCase().includes("contract"); const isContractDoc = (doc?.documentable_type || "").toLowerCase().includes("contract");
@@ -122,7 +122,13 @@ const openViewer = (doc) => {
client_case: props.client_case.uuid, client_case: props.client_case.uuid,
document: doc.uuid, document: doc.uuid,
}); });
viewer.value = { open: true, src: url, title: doc.original_name || doc.name }; viewer.value = {
open: true,
src: url,
title: doc.name || doc.original_name,
mimeType: doc.mime_type || "",
filename: doc.original_name || doc.name || "",
};
} else { } else {
const url = const url =
isContractDoc && doc.contract_uuid isContractDoc && doc.contract_uuid
@@ -140,6 +146,8 @@ const openViewer = (doc) => {
const closeViewer = () => { const closeViewer = () => {
viewer.value.open = false; viewer.value.open = false;
viewer.value.src = ""; viewer.value.src = "";
viewer.value.mimeType = "";
viewer.value.filename = "";
}; };
const clientDetails = ref(false); const clientDetails = ref(false);
@@ -210,14 +218,6 @@ const closeDrawer = () => {
drawerAddActivity.value = false; drawerAddActivity.value = false;
}; };
const showClientDetails = () => {
clientDetails.value = false;
};
const hideClietnDetails = () => {
clientDetails.value = true;
};
// Attach segment to case // Attach segment to case
const showAttachSegment = ref(false); const showAttachSegment = ref(false);
const openAttachSegment = () => { const openAttachSegment = () => {
@@ -490,6 +490,8 @@ const submitAttachSegment = () => {
:show="viewer.open" :show="viewer.open"
:src="viewer.src" :src="viewer.src"
:title="viewer.title" :title="viewer.title"
:mime-type="viewer.mimeType"
:filename="viewer.filename"
@close="closeViewer" @close="closeViewer"
/> />
</AppLayout> </AppLayout>
+91 -5
View File
@@ -24,18 +24,31 @@ import DateRangePicker from "@/Components/DateRangePicker.vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { ButtonGroup } from "@/Components/ui/button-group"; import { ButtonGroup } from "@/Components/ui/button-group";
import AppPopover from "@/Components/app/ui/AppPopover.vue"; import AppPopover from "@/Components/app/ui/AppPopover.vue";
import { Filter, LinkIcon, FileDown } from "lucide-vue-next"; import { Filter, LinkIcon, FileDown, LayoutIcon } from "lucide-vue-next";
import { Card } from "@/Components/ui/card"; import { Card } from "@/Components/ui/card";
import { Badge } from "@/Components/ui/badge"; import { Badge } from "@/Components/ui/badge";
import { hasPermission } from "@/Services/permissions"; import { hasPermission } from "@/Services/permissions";
import InputLabel from "@/Components/InputLabel.vue"; import InputLabel from "@/Components/InputLabel.vue";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/Components/ui/dropdown-menu";
import { toNumber } from "lodash";
import { FormControl, FormField, FormFieldArray, FormLabel } from "@/Components/ui/form";
import { Field, FieldLabel } from "@/Components/ui/field";
import { toTypedSchema } from "@vee-validate/zod";
import { z } from "zod";
import FormChangeSegment from "./Partials/FormChangeSegment.vue";
const props = defineProps({ const props = defineProps({
client: Object, client: Object,
contracts: Object, contracts: Object,
filters: Object, filters: Object,
segments: Object, segments: Array,
types: Object, types: Object,
}); });
@@ -59,10 +72,20 @@ const selectedSegments = ref(
: [] : []
); );
const filterPopoverOpen = ref(false); const filterPopoverOpen = ref(false);
const selectedContracts = ref([]);
const changeSegmentDialogOpen = ref(false);
const contractTable = ref(null);
const exportDialogOpen = ref(false); const exportDialogOpen = ref(false);
const exportScope = ref("current"); const exportScope = ref("current");
const exportColumns = ref(["reference", "customer", "address", "start", "segment", "balance"]); const exportColumns = ref([
"reference",
"customer",
"address",
"start",
"segment",
"balance",
]);
const exportError = ref(""); const exportError = ref("");
const isExporting = ref(false); const isExporting = ref(false);
@@ -85,6 +108,12 @@ const allColumnsSelected = computed(
const exportDisabled = computed( const exportDisabled = computed(
() => exportColumns.value.length === 0 || isExporting.value () => exportColumns.value.length === 0 || isExporting.value
); );
const segmentSelectItems = computed(() =>
props.segments.map((val, i) => ({
label: val.name,
value: val.id,
}))
);
function applyDateFilter() { function applyDateFilter() {
filterPopoverOpen.value = false; filterPopoverOpen.value = false;
@@ -288,6 +317,24 @@ function extractFilenameFromHeaders(headers) {
const asciiMatch = disposition.match(/filename="?([^";]+)"?/i); const asciiMatch = disposition.match(/filename="?([^";]+)"?/i);
return asciiMatch?.[1] || null; return asciiMatch?.[1] || null;
} }
function handleSelectionChange(selectedKeys) {
selectedContracts.value = selectedKeys.map((val, i) => {
const num = toNumber(val);
return props.contracts.data[num].uuid;
});
}
function openDialogChangeSegment() {
changeSegmentDialogOpen.value = true;
}
function clearContractTableSelected() {
if (contractTable.value) {
contractTable.value.clearSelection();
}
}
</script> </script>
<template> <template>
@@ -357,6 +404,7 @@ function extractFilenameFromHeaders(headers) {
</Link> </Link>
</div> </div>
<DataTable <DataTable
ref="contractTable"
:columns="[ :columns="[
{ key: 'reference', label: 'Referenca', sortable: false }, { key: 'reference', label: 'Referenca', sortable: false },
{ key: 'customer', label: 'Stranka', sortable: false }, { key: 'customer', label: 'Stranka', sortable: false },
@@ -380,11 +428,13 @@ function extractFilenameFromHeaders(headers) {
row-key="uuid" row-key="uuid"
:only-props="['contracts']" :only-props="['contracts']"
:page-size-options="[10, 15, 25, 50, 100]" :page-size-options="[10, 15, 25, 50, 100]"
:enable-row-selection="true"
@selection:change="handleSelectionChange"
page-param-name="contracts_page" page-param-name="contracts_page"
per-page-param-name="contracts_per_page" per-page-param-name="contracts_per_page"
:show-toolbar="true" :show-toolbar="true"
> >
<template #toolbar-filters> <template #toolbar-filters="{ table }">
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<AppPopover <AppPopover
v-model:open="filterPopoverOpen" v-model:open="filterPopoverOpen"
@@ -481,6 +531,32 @@ function extractFilenameFromHeaders(headers) {
<FileDown class="h-4 w-4" /> <FileDown class="h-4 w-4" />
Izvozi v Excel Izvozi v Excel
</Button> </Button>
<DropdownMenu v-if="table.getSelectedRowModel().rows.length > 0">
<DropdownMenuTrigger as-child>
<Button class="gap-2 px-3" variant="outline">
<Badge
class="h-5 min-w-5 rounded-full font-mono tabular-nums text-accent"
variant="destructive"
>
{{ table.getSelectedRowModel().rows.length }}
</Badge>
Akcija
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem @click="openDialogChangeSegment">
<LayoutIcon />
Spremeni segment
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
@click="clearContractTableSelected"
v-if="table.getSelectedRowModel().rows.length > 0"
>
Odznači izbrane
</Button>
</div> </div>
</template> </template>
<template #cell-reference="{ row }"> <template #cell-reference="{ row }">
@@ -519,7 +595,7 @@ function extractFilenameFromHeaders(headers) {
</div> </div>
</div> </div>
</div> </div>
<!-- Excel export dialog -->
<DialogModal :show="exportDialogOpen" max-width="3xl" @close="closeExportDialog"> <DialogModal :show="exportDialogOpen" max-width="3xl" @close="closeExportDialog">
<template #title> <template #title>
<div class="space-y-1"> <div class="space-y-1">
@@ -626,5 +702,15 @@ function extractFilenameFromHeaders(headers) {
</div> </div>
</template> </template>
</DialogModal> </DialogModal>
<!-- Change segment selected contracts dialog -->
<FormChangeSegment
:show="changeSegmentDialogOpen"
@close="changeSegmentDialogOpen = false"
:segments="segmentSelectItems"
:contracts="selectedContracts"
:clear-selected-rows="clearContractTableSelected"
/>
</AppLayout> </AppLayout>
</template> </template>
+4 -6
View File
@@ -6,10 +6,8 @@ import CreateDialog from "@/Components/Dialogs/CreateDialog.vue";
import DataTable from "@/Components/DataTable/DataTableNew2.vue"; import DataTable from "@/Components/DataTable/DataTableNew2.vue";
import { hasPermission } from "@/Services/permissions"; import { hasPermission } from "@/Services/permissions";
import { Button } from "@/Components/ui/button"; import { Button } from "@/Components/ui/button";
import { Card, CardHeader, CardTitle, CardContent } from "@/Components/ui/card"; import { CardTitle } from "@/Components/ui/card";
import { Input } from "@/Components/ui/input"; import { Input } from "@/Components/ui/input";
import ActionMenuItem from "@/Components/DataTable/ActionMenuItem.vue";
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -27,8 +25,7 @@ import {
import { useForm } from "vee-validate"; import { useForm } from "vee-validate";
import { toTypedSchema } from "@vee-validate/zod"; import { toTypedSchema } from "@vee-validate/zod";
import * as z from "zod"; import * as z from "zod";
import ActionMessage from "@/Components/ActionMessage.vue"; import { Plus, UsersRoundIcon } from "lucide-vue-next";
import { Mail, Plug2Icon, Plus, UsersRoundIcon } from "lucide-vue-next";
import { Separator } from "@/Components/ui/separator"; import { Separator } from "@/Components/ui/separator";
import AppCard from "@/Components/app/ui/card/AppCard.vue"; import AppCard from "@/Components/app/ui/card/AppCard.vue";
@@ -162,7 +159,7 @@ const fmtCurrency = (v) => {
</script> </script>
<template> <template>
<AppLayout> <AppLayout title="Clients">
<template #header> </template> <template #header> </template>
<div class="py-6"> <div class="py-6">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8"> <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
@@ -201,6 +198,7 @@ const fmtCurrency = (v) => {
:show-pagination="false" :show-pagination="false"
:show-toolbar="true" :show-toolbar="true"
:hoverable="true" :hoverable="true"
:page-size="100"
row-key="uuid" row-key="uuid"
:striped="true" :striped="true"
empty-text="Ni najdenih naročnikov." empty-text="Ni najdenih naročnikov."
@@ -0,0 +1,155 @@
<script setup>
import DialogModal from "@/Components/DialogModal.vue";
import { Button } from "@/Components/ui/button";
import {
Field,
FieldContent,
FieldDescription,
FieldError,
FieldLabel,
} from "@/Components/ui/field";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
import { toTypedSchema } from "@vee-validate/zod";
import { useForm, Field as VeeField } from "vee-validate";
import { router } from "@inertiajs/vue3";
import { onMounted, ref } from "vue";
import z from "zod";
const props = defineProps({
show: {
type: Boolean,
default: false,
},
segments: { type: Array, default: [] },
contracts: { type: Array, default: [] },
clearSelectedRows: { type: Function, default: () => console.log("test") },
});
const emit = defineEmits(["close"]);
const close = () => {
emit("close");
};
const processing = ref(false);
// vee-validate Form setup
const formSchema = toTypedSchema(
z.object({
segment_id: z
.number()
.refine((val) => props.segments.find((item) => item.value == val) !== undefined, {
message: "Izbran segment ne obstaja v zbirki segmentov",
}),
})
);
const { handleSubmit, resetForm, errors } = useForm({
validationSchema: formSchema,
});
const onSubmit = handleSubmit((data) => {
processing.value = true;
router.patch(
route("contracts.segment"),
{
...data,
contracts: props.contracts,
},
{
onSuccess: () => {
router.reload({ only: ["contracts"] });
close();
resetForm();
props.clearSelectedRows();
processing.value = false;
},
onError: (e) => {
errors = e;
processing.value = false;
},
onFinish: () => {
processing.value = false;
},
}
);
});
onMounted(() => {
console.log(props.segments);
});
</script>
<template>
<DialogModal :show="show" @close="close">
<template #title>
<h3 class="text-lg font-semibold leading-6 text-foreground">
Spremeni segment pogodbam
</h3>
</template>
<template #content>
<form id="segment-change-form" @submit.prevent="onSubmit">
<VeeField v-slot="{ field, errors }" name="segment_id">
<Field orientation="responsive" :data-invalid="!!errors.length">
<FieldContent>
<FieldLabel for="segment">Segment</FieldLabel>
<FieldDescription>Izberi segment za preusmeritev</FieldDescription>
<FieldError v-if="errors.length" :errors="errors" />
</FieldContent>
<Select
:model-value="field.value"
@update:model-value="field.onChange"
@blur="field.onBlur"
>
<SelectTrigger id="segment_id" :aria-invalid="!!errors.length">
<SelectValue placeholder="Izberi segment..."></SelectValue>
</SelectTrigger>
<SelectContent position="item-aligned">
<SelectItem value="auto"> Auto </SelectItem>
<SelectItem
v-for="segment in segments"
:key="segment.label"
:value="segment.value"
>
{{ segment.label }}
</SelectItem>
</SelectContent>
</Select>
</Field>
</VeeField>
</form>
</template>
<template #footer>
<div class="flex flex-row gap-2">
<Button
type="button"
:disabled="processing"
variant="ghost"
@click="
() => {
close();
resetForm();
}
"
>
Prekliči
</Button>
<Button type="submit" form="segment-change-form" :disabled="processing">
Potrdi
</Button>
</div>
</template>
</DialogModal>
</template>
<style></style>
+30 -13
View File
@@ -30,14 +30,15 @@ import AppPopover from "@/Components/app/ui/AppPopover.vue";
import InputLabel from "@/Components/InputLabel.vue"; import InputLabel from "@/Components/InputLabel.vue";
import AppMultiSelect from "@/Components/app/ui/AppMultiSelect.vue"; import AppMultiSelect from "@/Components/app/ui/AppMultiSelect.vue";
import AppCard from "@/Components/app/ui/card/AppCard.vue"; import AppCard from "@/Components/app/ui/card/AppCard.vue";
import { toNumber } from "lodash";
const props = defineProps({ const props = defineProps({
setting: Object, setting: Object,
unassignedContracts: Object, unassignedContracts: Object,
assignedContracts: Object, assignedContracts: Object,
users: Array, users: Array,
unassignedClients: Array, unassignedClients: [Array, Object],
assignedClients: Array, assignedClients: [Array, Object],
filters: Object, filters: Object,
}); });
@@ -54,6 +55,8 @@ const filterAssignedSelectedClient = ref(
: [] : []
); );
const unassignedContractTable = ref(null);
const form = useForm({ const form = useForm({
contract_uuid: null, contract_uuid: null,
assigned_user_id: null, assigned_user_id: null,
@@ -107,6 +110,14 @@ function toggleContractSelection(uuid, checked) {
console.log(selectedContractUuids.value); console.log(selectedContractUuids.value);
} }
function handleContractSelection(selected) {
selectedContractUuids.value = selected.map((val, i) => {
const num = toNumber(val);
return props.unassignedContracts.data[num].uuid;
});
}
// Format helpers (Slovenian formatting) // Format helpers (Slovenian formatting)
// Initialize search and filter from URL params // Initialize search and filter from URL params
@@ -296,6 +307,7 @@ function assignSelected() {
bulkForm.contract_uuids = selectedContractUuids.value; bulkForm.contract_uuids = selectedContractUuids.value;
bulkForm.post(route("fieldjobs.assign-bulk"), { bulkForm.post(route("fieldjobs.assign-bulk"), {
onSuccess: () => { onSuccess: () => {
unassignedContractTable.value.clearSelection();
selectedContractUuids.value = []; selectedContractUuids.value = [];
bulkForm.contract_uuids = []; bulkForm.contract_uuids = [];
}, },
@@ -304,7 +316,11 @@ function assignSelected() {
function cancelAssignment(contract) { function cancelAssignment(contract) {
const payload = { contract_uuid: contract.uuid }; const payload = { contract_uuid: contract.uuid };
form.transform(() => payload).post(route("fieldjobs.cancel")); form
.transform(() => payload)
.post(route("fieldjobs.cancel"), {
preserveScroll: true,
});
} }
// Column definitions for DataTableNew2 // Column definitions for DataTableNew2
@@ -437,6 +453,7 @@ const assignedRows = computed(() =>
</div> </div>
</div> </div>
<DataTable <DataTable
ref="unassignedContractTable"
:columns="unassignedColumns" :columns="unassignedColumns"
:data="unassignedRows" :data="unassignedRows"
:meta="{ :meta="{
@@ -449,6 +466,8 @@ const assignedRows = computed(() =>
links: unassignedContracts.links, links: unassignedContracts.links,
}" }"
row-key="uuid" row-key="uuid"
:enable-row-selection="true"
@selection:change="handleContractSelection"
:page-size="props.unassignedContracts?.per_page || 10" :page-size="props.unassignedContracts?.per_page || 10"
:page-size-options="[10, 15, 25, 50, 100]" :page-size-options="[10, 15, 25, 50, 100]"
:show-toolbar="true" :show-toolbar="true"
@@ -482,7 +501,10 @@ const assignedRows = computed(() =>
<AppMultiSelect <AppMultiSelect
v-model="filterUnassignedSelectedClient" v-model="filterUnassignedSelectedClient"
:items=" :items="
(props.unassignedClients || []).map((client) => ({ (Array.isArray(props.unassignedClients)
? props.unassignedClients
: props.unassignedClients?.data || []
).map((client) => ({
value: client.uuid, value: client.uuid,
label: client.person.full_name, label: client.person.full_name,
})) }))
@@ -497,14 +519,6 @@ const assignedRows = computed(() =>
</AppPopover> </AppPopover>
</div> </div>
</template> </template>
<template #cell-_select="{ row }">
<Checkbox
@update:model-value="
(checked) => toggleContractSelection(row.uuid, checked)
"
/>
</template>
<template #cell-case_person="{ row }"> <template #cell-case_person="{ row }">
<Link <Link
v-if="row.client_case?.uuid" v-if="row.client_case?.uuid"
@@ -605,7 +619,10 @@ const assignedRows = computed(() =>
<AppMultiSelect <AppMultiSelect
v-model="filterAssignedSelectedClient" v-model="filterAssignedSelectedClient"
:items=" :items="
(props.assignedClients || []).map((client) => ({ (Array.isArray(props.assignedClients)
? props.assignedClients
: props.assignedClients?.data || []
).map((client) => ({
value: client.uuid, value: client.uuid,
label: client.person.full_name, label: client.person.full_name,
})) }))
+1 -1
View File
@@ -245,7 +245,7 @@ async function startImport() {
<!-- Has Header Checkbox --> <!-- Has Header Checkbox -->
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<Checkbox id="has-header" v-model:checked="form.has_header" /> <Checkbox id="has-header" :model-value="form.has_header" />
<Label <Label
for="has-header" for="has-header"
class="cursor-pointer text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" class="cursor-pointer text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
+13 -1
View File
@@ -1094,6 +1094,16 @@ async function fetchEvents() {
} }
} }
async function downloadImport() {
if (!importId.value) return;
try {
const url = route("imports.download", { import: importId.value });
window.location.href = url;
} catch (e) {
console.error("Download failed", e);
}
}
// Simulation (generic or payments) state // Simulation (generic or payments) state
const showPaymentSim = ref(false); const showPaymentSim = ref(false);
const paymentSimLoading = ref(false); const paymentSimLoading = ref(false);
@@ -1307,7 +1317,8 @@ async function fetchSimulation() {
<Checkbox <Checkbox
:id="'show-missing-checkbox'" :id="'show-missing-checkbox'"
:checked="showMissingEnabled" :checked="showMissingEnabled"
@update:checked=" :model-value="showMissingEnabled"
@update:model-value="
(val) => { (val) => {
showMissingEnabled = val; showMissingEnabled = val;
saveImportOptions(); saveImportOptions();
@@ -1339,6 +1350,7 @@ async function fetchSimulation() {
:can-process="canProcess" :can-process="canProcess"
:selected-mappings-count="selectedMappingsCount" :selected-mappings-count="selectedMappingsCount"
@preview="openPreview" @preview="openPreview"
@download="downloadImport"
@save-mappings="saveMappings" @save-mappings="saveMappings"
@process-import="processImport" @process-import="processImport"
@simulate="openSimulation" @simulate="openSimulation"
@@ -4,9 +4,10 @@ import {
ArrowPathIcon, ArrowPathIcon,
BeakerIcon, BeakerIcon,
ArrowDownOnSquareIcon, ArrowDownOnSquareIcon,
ArrowDownTrayIcon,
} from "@heroicons/vue/24/outline"; } from "@heroicons/vue/24/outline";
import { Button } from '@/Components/ui/button'; import { Button } from "@/Components/ui/button";
import { Badge } from '@/Components/ui/badge'; import { Badge } from "@/Components/ui/badge";
const props = defineProps({ const props = defineProps({
importId: [Number, String], importId: [Number, String],
@@ -16,15 +17,30 @@ const props = defineProps({
canProcess: Boolean, canProcess: Boolean,
selectedMappingsCount: Number, selectedMappingsCount: Number,
}); });
const emits = defineEmits(["preview", "save-mappings", "process-import", "simulate"]); const emits = defineEmits([
"preview",
"save-mappings",
"process-import",
"simulate",
"download",
]);
</script> </script>
<template> <template>
<div class="flex flex-wrap gap-2 items-center" v-if="!isCompleted"> <div class="flex flex-wrap gap-2 items-center">
<!-- Download button - always visible -->
<Button <Button
variant="secondary" variant="secondary"
@click.prevent="$emit('preview')" @click.prevent="$emit('download')"
:disabled="!importId" :disabled="!importId"
title="Preznesi originalno uvozno datoteko"
> >
<ArrowDownTrayIcon class="h-4 w-4" />
Prenos datoteko
</Button>
<!-- Other action buttons - only when not completed -->
<div class="flex flex-wrap gap-2 items-center" v-if="!isCompleted">
<Button variant="secondary" @click.prevent="$emit('preview')" :disabled="!importId">
<EyeIcon class="h-4 w-4 mr-2" /> <EyeIcon class="h-4 w-4 mr-2" />
Predogled vrstic Predogled vrstic
</Button> </Button>
@@ -41,11 +57,9 @@ const emits = defineEmits(["preview", "save-mappings", "process-import", "simula
></span> ></span>
<ArrowPathIcon v-else class="h-4 w-4 mr-2" /> <ArrowPathIcon v-else class="h-4 w-4 mr-2" />
<span>Shrani preslikave</span> <span>Shrani preslikave</span>
<Badge <Badge v-if="selectedMappingsCount" variant="secondary" class="ml-2 text-xs">{{
v-if="selectedMappingsCount" selectedMappingsCount
variant="secondary" }}</Badge>
class="ml-2 text-xs"
>{{ selectedMappingsCount }}</Badge>
</Button> </Button>
<Button <Button
variant="default" variant="default"
@@ -66,4 +80,5 @@ const emits = defineEmits(["preview", "save-mappings", "process-import", "simula
Simulacija vnosa Simulacija vnosa
</Button> </Button>
</div> </div>
</div>
</template> </template>
@@ -2,9 +2,12 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/Components/ui/dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/Components/ui/dialog";
import { Button } from "@/Components/ui/button"; import { Button } from "@/Components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/Components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/Components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table";
import { Badge } from "@/Components/ui/badge"; import { Badge } from "@/Components/ui/badge";
import { Label } from "@/Components/ui/label"; import { Label } from "@/Components/ui/label";
import { Checkbox } from "@/Components/ui/checkbox";
import { ChevronRightIcon } from "@heroicons/vue/24/outline";
import { computed, ref } from "vue";
const props = defineProps({ const props = defineProps({
show: Boolean, show: Boolean,
limit: Number, limit: Number,
@@ -14,20 +17,63 @@ const props = defineProps({
truncated: Boolean, truncated: Boolean,
hasHeader: Boolean, hasHeader: Boolean,
}) })
const emits = defineEmits(['close','change-limit','refresh']) const emits = defineEmits(['close','change-limit','refresh'])
function onLimit(e){ emits('change-limit', Number(e.target.value)); emits('refresh') }
// State
const selectedRow = ref(null);
const hideEmptyRows = ref(true);
// Filter out columns with empty headers
const visibleColumns = computed(() => {
if (!props.columns) return [];
return props.columns.filter(col => col && String(col).trim() !== '');
});
// Check if row is empty (first 2 columns are empty)
function isRowEmpty(row) {
if (!visibleColumns.value || visibleColumns.value.length === 0) return false;
const firstCols = visibleColumns.value.slice(0, 2);
return firstCols.every(col => !row[col] || String(row[col]).trim() === '');
}
// Filtered rows
const visibleRows = computed(() => {
if (!props.rows) return [];
let filtered = props.rows;
if (hideEmptyRows.value) {
filtered = filtered.filter(r => !isRowEmpty(r));
}
return filtered.map((r, idx) => ({ ...r, index: idx + 1 }));
});
// Select row
function selectRow(row) {
selectedRow.value = row;
}
function onLimit(val) {
emits('change-limit', Number(val));
emits('refresh');
}
</script> </script>
<template> <template>
<Dialog :open="show" @update:open="(val) => !val && $emit('close')"> <Dialog :open="show" @update:open="(val) => !val && $emit('close')">
<DialogContent class="max-w-6xl max-h-[90vh] overflow-hidden flex flex-col"> <DialogContent class="max-w-7xl max-h-[90vh] overflow-hidden flex flex-col p-0">
<DialogHeader> <!-- Header -->
<DialogTitle>CSV Preview ({{ rows.length }} / {{ limit }})</DialogTitle> <div class="px-6 py-4 border-b bg-linear-to-r from-gray-50 to-white">
</DialogHeader> <div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-3 pb-3 border-b"> <h2 class="text-xl font-semibold text-gray-900">CSV Preview</h2>
<p class="text-sm text-gray-500 mt-1">
Showing {{ visibleRows.length }} of {{ rows.length }} rows
</p>
</div>
<div class="flex items-center gap-3">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Label for="limit-select" class="text-sm text-gray-600">Limit:</Label> <Label for="limit-select" class="text-sm text-gray-600">Limit:</Label>
<Select :model-value="String(limit)" @update:model-value="(val) => { emits('change-limit', Number(val)); emits('refresh'); }"> <Select :model-value="String(limit)" @update:model-value="onLimit">
<SelectTrigger id="limit-select" class="w-24 h-8"> <SelectTrigger id="limit-select" class="w-24 h-8">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
@@ -43,43 +89,115 @@ function onLimit(e){ emits('change-limit', Number(e.target.value)); emits('refre
<Button @click="$emit('refresh')" variant="outline" size="sm" :disabled="loading"> <Button @click="$emit('refresh')" variant="outline" size="sm" :disabled="loading">
{{ loading ? 'Loading…' : 'Refresh' }} {{ loading ? 'Loading…' : 'Refresh' }}
</Button> </Button>
<div class="flex items-center gap-2">
<Checkbox
id="hide-empty-rows"
:checked="hideEmptyRows"
@update:checked="(val) => hideEmptyRows = val"
/>
<Label for="hide-empty-rows" class="text-xs cursor-pointer">
Hide empty rows
</Label>
</div>
<Badge v-if="truncated" variant="outline" class="bg-amber-50 text-amber-700 border-amber-200"> <Badge v-if="truncated" variant="outline" class="bg-amber-50 text-amber-700 border-amber-200">
Truncated at limit Truncated at limit
</Badge> </Badge>
</div> </div>
</div>
<div class="flex-1 overflow-auto border rounded-lg">
<Table>
<TableHeader class="sticky top-0 bg-white z-10">
<TableRow>
<TableHead class="w-16">#</TableHead>
<TableHead v-for="col in columns" :key="col">{{ col }}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-if="loading">
<TableCell :colspan="columns.length + 1" class="text-center text-gray-500">
Loading…
</TableCell>
</TableRow>
<TableRow v-for="(r, idx) in rows" :key="idx">
<TableCell class="text-gray-500 font-medium">{{ idx + 1 }}</TableCell>
<TableCell v-for="col in columns" :key="col" class="whitespace-pre-wrap">
{{ r[col] }}
</TableCell>
</TableRow>
<TableRow v-if="!loading && !rows.length">
<TableCell :colspan="columns.length + 1" class="text-center text-gray-500">
No rows
</TableCell>
</TableRow>
</TableBody>
</Table>
</div> </div>
<div class="text-xs text-gray-500 pt-3 border-t"> <!-- Split View -->
Showing up to {{ limit }} rows from source file. <div class="flex-1 flex overflow-hidden">
<!-- Left Panel - Row List -->
<div class="w-96 border-r bg-gray-50 overflow-y-auto">
<div v-if="loading" class="p-8 text-center text-gray-500">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div>
Loading...
</div>
<div v-else-if="!visibleRows.length" class="p-8 text-center text-gray-500">
No rows to display
</div>
<div v-else class="divide-y">
<button
v-for="row in visibleRows"
:key="row.index"
@click="selectRow(row)"
class="w-full px-4 py-3 text-left hover:bg-white transition-colors"
:class="{
'bg-white shadow-sm': selectedRow?.index === row.index,
}"
>
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3 flex-1 min-w-0">
<!-- Row Number -->
<div class="flex-shrink-0">
<div class="w-8 h-8 rounded-full bg-blue-100 text-blue-700 flex items-center justify-center text-xs font-semibold">
{{ row.index }}
</div>
</div>
<!-- Row Preview -->
<div class="flex-1 min-w-0">
<div class="text-xs font-semibold text-gray-900 mb-1">
Row #{{ row.index }}
</div>
<div class="text-xs text-gray-600 truncate">
{{
visibleColumns.slice(0, 2).map(col => row[col]).filter(Boolean).join(' • ') || 'Empty row'
}}
</div>
</div>
</div>
<!-- Arrow -->
<ChevronRightIcon class="h-4 w-4 text-gray-400 flex-shrink-0" />
</div>
</button>
</div>
</div>
<!-- Right Panel - Row Details -->
<div v-if="selectedRow" class="flex-1 overflow-y-auto p-6">
<!-- Row Header -->
<div class="mb-6">
<h3 class="text-lg font-semibold text-gray-900">
Row #{{ selectedRow.index }}
</h3>
<p class="text-sm text-gray-500">Full row details</p>
</div>
<!-- Row Data -->
<div class="bg-gray-50 rounded-lg p-4">
<dl class="grid grid-cols-1 gap-3">
<div
v-for="col in visibleColumns"
:key="col"
class="flex items-start gap-3 py-2 border-b border-gray-200 last:border-0"
>
<dt class="text-sm font-medium text-gray-600 w-48 flex-shrink-0">
{{ col }}
</dt>
<dd class="text-sm text-gray-900 flex-1 font-medium whitespace-pre-wrap break-words">
{{ selectedRow[col] || '—' }}
</dd>
</div>
</dl>
</div>
</div>
<!-- Empty State for Right Panel -->
<div v-else class="flex-1 flex items-center justify-center text-gray-400">
<div class="text-center">
<div class="text-5xl mb-3">📄</div>
<p class="text-sm">Select a row to view details</p>
</div>
</div>
</div>
<!-- Footer -->
<div class="px-6 py-3 border-t bg-gray-50 text-xs text-gray-500">
Header detection: <span class="font-medium">{{ hasHeader ? 'header present' : 'no header' }}</span> Header detection: <span class="font-medium">{{ hasHeader ? 'header present' : 'no header' }}</span>
Click a row to view full details
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -1,10 +1,24 @@
<script setup> <script setup>
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/Components/ui/table'; import {
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/Components/ui/select'; Table,
import { Checkbox } from '@/Components/ui/checkbox'; TableBody,
import { Input } from '@/Components/ui/input'; TableCell,
import { Badge } from '@/Components/ui/badge'; TableHead,
import { ScrollArea } from '@/Components/ui/scroll-area'; TableHeader,
TableRow,
} from "@/Components/ui/table";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
import { Checkbox } from "@/Components/ui/checkbox";
import { Input } from "@/Components/ui/input";
import { Badge } from "@/Components/ui/badge";
import { ScrollArea } from "@/Components/ui/scroll-area";
const props = defineProps({ const props = defineProps({
rows: Array, rows: Array,
@@ -19,12 +33,12 @@ const props = defineProps({
mappingError: String, mappingError: String,
show: { type: Boolean, default: true }, show: { type: Boolean, default: true },
fieldsForEntity: Function, fieldsForEntity: Function,
}) });
const emits = defineEmits(['update:rows','save']) const emits = defineEmits(["update:rows", "save"]);
function duplicateTarget(row){ function duplicateTarget(row) {
if(!row || !row.entity || !row.field) return false if (!row || !row.entity || !row.field) return false;
return props.duplicateTargets?.has?.(row.entity + '.' + row.field) || false return props.duplicateTargets?.has?.(row.entity + "." + row.field) || false;
} }
</script> </script>
<template> <template>
@@ -32,39 +46,63 @@ function duplicateTarget(row){
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<h3 class="font-semibold"> <h3 class="font-semibold">
Detected Columns Detected Columns
<Badge variant="outline" class="ml-2 text-[10px]">{{ detected?.has_header ? 'header' : 'positional' }}</Badge> <Badge variant="outline" class="ml-2 text-[10px]">{{
detected?.has_header ? "header" : "positional"
}}</Badge>
</h3> </h3>
<div class="text-xs text-muted-foreground"> <div class="text-xs text-muted-foreground">
detected: {{ detected?.columns?.length || 0 }}, rows: {{ rows.length }}, delimiter: {{ detected?.delimiter || 'auto' }} detected: {{ detected?.columns?.length || 0 }}, rows: {{ rows.length }},
delimiter: {{ detected?.delimiter || "auto" }}
</div> </div>
</div> </div>
<p v-if="detectedNote" class="text-xs text-muted-foreground mb-2">{{ detectedNote }}</p> <p v-if="detectedNote" class="text-xs text-muted-foreground mb-2">
{{ detectedNote }}
</p>
<div class="relative border rounded-lg"> <div class="relative border rounded-lg">
<ScrollArea class="h-[420px]"> <ScrollArea class="h-[420px]">
<Table> <Table>
<TableHeader class="sticky top-0 z-10 bg-background"> <TableHeader class="sticky top-0 z-10 bg-background">
<TableRow class="hover:bg-transparent"> <TableRow class="hover:bg-transparent">
<TableHead class="w-[180px] bg-muted/95 backdrop-blur">Source column</TableHead> <TableHead class="w-[180px] bg-muted/95 backdrop-blur"
>Source column</TableHead
>
<TableHead class="w-[150px] bg-muted/95 backdrop-blur">Entity</TableHead> <TableHead class="w-[150px] bg-muted/95 backdrop-blur">Entity</TableHead>
<TableHead class="w-[150px] bg-muted/95 backdrop-blur">Field</TableHead> <TableHead class="w-[150px] bg-muted/95 backdrop-blur">Field</TableHead>
<TableHead class="w-[140px] bg-muted/95 backdrop-blur">Meta key</TableHead> <TableHead class="w-[140px] bg-muted/95 backdrop-blur">Meta key</TableHead>
<TableHead class="w-[120px] bg-muted/95 backdrop-blur">Meta type</TableHead> <TableHead class="w-[120px] bg-muted/95 backdrop-blur">Meta type</TableHead>
<TableHead class="w-[120px] bg-muted/95 backdrop-blur">Transform</TableHead> <TableHead class="w-[120px] bg-muted/95 backdrop-blur">Transform</TableHead>
<TableHead class="w-[130px] bg-muted/95 backdrop-blur">Apply mode</TableHead> <TableHead class="w-[130px] bg-muted/95 backdrop-blur"
<TableHead class="w-[60px] text-center bg-muted/95 backdrop-blur">Skip</TableHead> >Apply mode</TableHead
>
<TableHead class="w-[60px] text-center bg-muted/95 backdrop-blur"
>Skip</TableHead
>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
<TableRow v-for="(row, idx) in rows" :key="idx" :class="duplicateTarget(row) ? 'bg-destructive/10' : ''"> <TableRow
v-for="(row, idx) in rows"
:key="idx"
:class="duplicateTarget(row) ? 'bg-destructive/10' : ''"
>
<TableCell class="font-medium">{{ row.source_column }}</TableCell> <TableCell class="font-medium">{{ row.source_column }}</TableCell>
<TableCell> <TableCell>
<Select :model-value="row.entity || ''" @update:model-value="(val) => row.entity = val || ''" :disabled="isCompleted"> <Select
:model-value="row.entity || ''"
@update:model-value="(val) => (row.entity = val || '')"
:disabled="isCompleted"
>
<SelectTrigger class="h-8 text-xs"> <SelectTrigger class="h-8 text-xs">
<SelectValue placeholder="Select entity..." /> <SelectValue placeholder="Select entity..." />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
<SelectItem v-for="opt in entityOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</SelectItem> <SelectItem
v-for="opt in entityOptions"
:key="opt.value"
:value="opt.value"
>{{ opt.label }}</SelectItem
>
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -72,16 +110,26 @@ function duplicateTarget(row){
<TableCell> <TableCell>
<Select <Select
:model-value="row.field || ''" :model-value="row.field || ''"
@update:model-value="(val) => row.field = val || ''" @update:model-value="(val) => (row.field = val || '')"
:disabled="isCompleted" :disabled="isCompleted"
:class="duplicateTarget(row) ? 'border-destructive' : ''" :class="duplicateTarget(row) ? 'border-destructive' : ''"
> >
<SelectTrigger class="h-8 text-xs" :class="duplicateTarget(row) ? 'border-destructive bg-destructive/10' : ''"> <SelectTrigger
class="h-8 text-xs"
:class="
duplicateTarget(row) ? 'border-destructive bg-destructive/10' : ''
"
>
<SelectValue placeholder="Select field..." /> <SelectValue placeholder="Select field..." />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
<SelectItem v-for="f in fieldsForEntity(row.entity)" :key="f" :value="f">{{ f }}</SelectItem> <SelectItem
v-for="f in fieldsForEntity(row.entity)"
:key="f"
:value="f"
>{{ f }}</SelectItem
>
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -101,7 +149,7 @@ function duplicateTarget(row){
<Select <Select
v-if="row.field === 'meta'" v-if="row.field === 'meta'"
:model-value="(row.options ||= {}).type || 'string'" :model-value="(row.options ||= {}).type || 'string'"
@update:model-value="(val) => (row.options ||= {}).type = val" @update:model-value="(val) => ((row.options ||= {}).type = val)"
:disabled="isCompleted" :disabled="isCompleted"
> >
<SelectTrigger class="h-8 text-xs"> <SelectTrigger class="h-8 text-xs">
@@ -119,7 +167,13 @@ function duplicateTarget(row){
<span v-else class="text-muted-foreground text-xs">—</span> <span v-else class="text-muted-foreground text-xs">—</span>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Select :model-value="row.transform || 'none'" @update:model-value="(val) => row.transform = val === 'none' ? '' : val" :disabled="isCompleted"> <Select
:model-value="row.transform || 'none'"
@update:model-value="
(val) => (row.transform = val === 'none' ? '' : val)
"
:disabled="isCompleted"
>
<SelectTrigger class="h-8 text-xs"> <SelectTrigger class="h-8 text-xs">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
@@ -134,7 +188,11 @@ function duplicateTarget(row){
</Select> </Select>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Select :model-value="row.apply_mode || 'both'" @update:model-value="(val) => row.apply_mode = val" :disabled="isCompleted"> <Select
:model-value="row.apply_mode || 'both'"
@update:model-value="(val) => (row.apply_mode = val)"
:disabled="isCompleted"
>
<SelectTrigger class="h-8 text-xs"> <SelectTrigger class="h-8 text-xs">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
@@ -149,20 +207,31 @@ function duplicateTarget(row){
</Select> </Select>
</TableCell> </TableCell>
<TableCell class="text-center"> <TableCell class="text-center">
<Checkbox :checked="row.skip" @update:checked="(val) => row.skip = val" :disabled="isCompleted" /> <Checkbox
:model-value="row.skip"
@update:model-value="(val) => (row.skip = val)"
:disabled="isCompleted"
/>
</TableCell> </TableCell>
</TableRow> </TableRow>
</TableBody> </TableBody>
</Table> </Table>
</ScrollArea> </ScrollArea>
</div> </div>
<div v-if="mappingSaved" class="text-sm text-emerald-700 mt-2 flex items-center gap-2"> <div
v-if="mappingSaved"
class="text-sm text-emerald-700 mt-2 flex items-center gap-2"
>
<Badge variant="default" class="bg-emerald-600">Saved</Badge> <Badge variant="default" class="bg-emerald-600">Saved</Badge>
<span>{{ mappingSavedCount }} mappings saved</span> <span>{{ mappingSavedCount }} mappings saved</span>
</div> </div>
<div v-else-if="mappingError" class="text-sm text-destructive mt-2">{{ mappingError }}</div> <div v-else-if="mappingError" class="text-sm text-destructive mt-2">
{{ mappingError }}
</div>
<div v-if="missingCritical?.length" class="mt-2"> <div v-if="missingCritical?.length" class="mt-2">
<Badge variant="destructive" class="text-xs">Missing critical: {{ missingCritical.join(', ') }}</Badge> <Badge variant="destructive" class="text-xs"
>Missing critical: {{ missingCritical.join(", ") }}</Badge
>
</div> </div>
</div> </div>
</template> </template>
+7 -1
View File
@@ -67,7 +67,7 @@ const props = defineProps({
completed_mode: { type: Boolean, default: false }, completed_mode: { type: Boolean, default: false },
}); });
const viewer = reactive({ open: false, src: "", title: "" }); const viewer = reactive({ open: false, src: "", title: "", mimeType: "", filename: "" });
function openViewer(doc) { function openViewer(doc) {
const kind = classifyDocument(doc); const kind = classifyDocument(doc);
const isContractDoc = (doc?.documentable_type || "").toLowerCase().includes("contract"); const isContractDoc = (doc?.documentable_type || "").toLowerCase().includes("contract");
@@ -85,6 +85,8 @@ function openViewer(doc) {
viewer.open = true; viewer.open = true;
viewer.src = url; viewer.src = url;
viewer.title = doc.original_name || doc.name; viewer.title = doc.original_name || doc.name;
viewer.mimeType = doc.mime_type || "";
viewer.filename = doc.original_name || doc.name || "";
} else { } else {
const url = const url =
isContractDoc && doc.contract_uuid isContractDoc && doc.contract_uuid
@@ -102,6 +104,8 @@ function openViewer(doc) {
function closeViewer() { function closeViewer() {
viewer.open = false; viewer.open = false;
viewer.src = ""; viewer.src = "";
viewer.mimeType = "";
viewer.filename = "";
} }
function formatAmount(val) { function formatAmount(val) {
@@ -610,6 +614,8 @@ const clientSummary = computed(() => {
:show="viewer.open" :show="viewer.open"
:src="viewer.src" :src="viewer.src"
:title="viewer.title" :title="viewer.title"
:mime-type="viewer.mimeType"
:filename="viewer.filename"
@close="closeViewer" @close="closeViewer"
/> />
<ActivityDrawer <ActivityDrawer
@@ -20,6 +20,7 @@ import {
} from "@/Components/ui/dialog"; } from "@/Components/ui/dialog";
import InputError from "@/Components/InputError.vue"; import InputError from "@/Components/InputError.vue";
import { Monitor, Smartphone, LogOut, CheckCircle } from "lucide-vue-next"; import { Monitor, Smartphone, LogOut, CheckCircle } from "lucide-vue-next";
import AppCard from "@/Components/app/ui/card/AppCard.vue";
defineProps({ defineProps({
sessions: Array, sessions: Array,
@@ -55,33 +56,30 @@ const closeModal = () => {
</script> </script>
<template> <template>
<Card> <AppCard
<CardHeader> title=""
padding="none"
class="p-0! gap-0"
header-class="py-3! px-4 gap-0 text-muted-foreground"
body-class=""
>
<template #header>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<LogOut class="h-5 w-5 text-muted-foreground" /> <LogOut size="18" />
<CardTitle>Browser Sessions</CardTitle> <CardTitle>Aktivne prijave</CardTitle>
</div> </div>
<CardDescription> <CardDescription>
Manage and log out your active sessions on other browsers and devices. Upravljanje in izpis aktivnih prijav no drugih brskalnikih in napravah.
</CardDescription> </CardDescription>
</CardHeader> </template>
<CardContent class="space-y-6">
<p class="text-sm text-muted-foreground">
If necessary, you may log out of all of your other browser sessions across all of
your devices. Some of your recent sessions are listed below; however, this list
may not be exhaustive. If you feel your account has been compromised, you should
also update your password.
</p>
<!-- Other Browser Sessions --> <!-- Other Browser Sessions -->
<div v-if="sessions.length > 0" class="space-y-4"> <div v-if="sessions && sessions.length > 0" class="space-y-4">
<div <div
v-for="(session, i) in sessions" v-for="(session, i) in sessions"
:key="i" :key="i"
class="flex items-center gap-3 rounded-lg border p-3" class="flex items-center gap-3 rounded-lg border p-3"
> >
<div class="flex-shrink-0"> <div class="shrink-0">
<Monitor <Monitor
v-if="session.agent.is_desktop" v-if="session.agent.is_desktop"
class="h-8 w-8 text-muted-foreground" class="h-8 w-8 text-muted-foreground"
@@ -100,15 +98,22 @@ const closeModal = () => {
v-if="session.is_current_device" v-if="session.is_current_device"
class="inline-flex items-center ml-2 text-green-600 dark:text-green-400 font-semibold" class="inline-flex items-center ml-2 text-green-600 dark:text-green-400 font-semibold"
> >
This device Ta naprava
</span> </span>
<span v-else class="ml-1"> · Last active {{ session.last_active }} </span> <span v-else class="ml-1"> · Aktiven {{ session.last_active }} </span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="flex items-center gap-3"> <!-- Empty State -->
<div v-else class="rounded-lg border border-dashed p-8 text-center">
<Monitor class="h-12 w-12 mx-auto text-muted-foreground mb-3" />
<p class="text-sm text-muted-foreground">Najdena nobena odprta prijava.</p>
</div>
<template #footer>
<div class="flex flex-row gap-1 items-center justify-end w-full">
<Button @click="confirmLogout"> <Button @click="confirmLogout">
<LogOut class="h-4 w-4 mr-2" /> <LogOut class="h-4 w-4 mr-2" />
Log Out Other Browser Sessions Log Out Other Browser Sessions
@@ -122,7 +127,8 @@ const closeModal = () => {
<span>Done.</span> <span>Done.</span>
</div> </div>
</div> </div>
</CardContent> </template>
</AppCard>
<!-- Log Out Other Devices Confirmation Dialog --> <!-- Log Out Other Devices Confirmation Dialog -->
<Dialog :open="confirmingLogout" @update:open="closeModal"> <Dialog :open="confirmingLogout" @update:open="closeModal">
@@ -155,5 +161,4 @@ const closeModal = () => {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</Card>
</template> </template>
@@ -1,14 +1,21 @@
<script setup> <script setup>
import { ref, computed, watch } from 'vue'; import { ref, computed, watch } from "vue";
import { router, useForm, usePage } from '@inertiajs/vue3'; import { router, useForm, usePage } from "@inertiajs/vue3";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/Components/ui/card'; import {
import { Button } from '@/Components/ui/button'; Card,
import { Input } from '@/Components/ui/input'; CardContent,
import { Label } from '@/Components/ui/label'; CardDescription,
import { Badge } from '@/Components/ui/badge'; CardHeader,
import ConfirmsPassword from '@/Components/ConfirmsPassword.vue'; CardTitle,
import InputError from '@/Components/InputError.vue'; } from "@/Components/ui/card";
import { Shield, Key, Copy, RefreshCw, CheckCircle, AlertCircle } from 'lucide-vue-next'; import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label";
import { Badge } from "@/Components/ui/badge";
import ConfirmsPassword from "@/Components/ConfirmsPassword.vue";
import InputError from "@/Components/InputError.vue";
import { Shield, Key, Copy, RefreshCw, CheckCircle, AlertCircle } from "lucide-vue-next";
import AppCard from "@/Components/app/ui/card/AppCard.vue";
const props = defineProps({ const props = defineProps({
requiresConfirmation: Boolean, requiresConfirmation: Boolean,
@@ -23,15 +30,15 @@ const setupKey = ref(null);
const recoveryCodes = ref([]); const recoveryCodes = ref([]);
const confirmationForm = useForm({ const confirmationForm = useForm({
code: '', code: "",
}); });
const twoFactorEnabled = computed( const twoFactorEnabled = computed(
() => ! enabling.value && page.props.auth.user?.two_factor_enabled, () => !enabling.value && page.props.auth.user?.two_factor_enabled
); );
watch(twoFactorEnabled, () => { watch(twoFactorEnabled, () => {
if (! twoFactorEnabled.value) { if (!twoFactorEnabled.value) {
confirmationForm.reset(); confirmationForm.reset();
confirmationForm.clearErrors(); confirmationForm.clearErrors();
} }
@@ -40,40 +47,40 @@ watch(twoFactorEnabled, () => {
const enableTwoFactorAuthentication = () => { const enableTwoFactorAuthentication = () => {
enabling.value = true; enabling.value = true;
router.post(route('two-factor.enable'), {}, { router.post(
route("two-factor.enable"),
{},
{
preserveScroll: true, preserveScroll: true,
onSuccess: () => Promise.all([ onSuccess: () => Promise.all([showQrCode(), showSetupKey(), showRecoveryCodes()]),
showQrCode(),
showSetupKey(),
showRecoveryCodes(),
]),
onFinish: () => { onFinish: () => {
enabling.value = false; enabling.value = false;
confirming.value = props.requiresConfirmation; confirming.value = props.requiresConfirmation;
}, },
}); }
);
}; };
const showQrCode = () => { const showQrCode = () => {
return axios.get(route('two-factor.qr-code')).then(response => { return axios.get(route("two-factor.qr-code")).then((response) => {
qrCode.value = response.data.svg; qrCode.value = response.data.svg;
}); });
}; };
const showSetupKey = () => { const showSetupKey = () => {
return axios.get(route('two-factor.secret-key')).then(response => { return axios.get(route("two-factor.secret-key")).then((response) => {
setupKey.value = response.data.secretKey; setupKey.value = response.data.secretKey;
}); });
} };
const showRecoveryCodes = () => { const showRecoveryCodes = () => {
return axios.get(route('two-factor.recovery-codes')).then(response => { return axios.get(route("two-factor.recovery-codes")).then((response) => {
recoveryCodes.value = response.data; recoveryCodes.value = response.data;
}); });
}; };
const confirmTwoFactorAuthentication = () => { const confirmTwoFactorAuthentication = () => {
confirmationForm.post(route('two-factor.confirm'), { confirmationForm.post(route("two-factor.confirm"), {
errorBag: "confirmTwoFactorAuthentication", errorBag: "confirmTwoFactorAuthentication",
preserveScroll: true, preserveScroll: true,
preserveState: true, preserveState: true,
@@ -86,15 +93,13 @@ const confirmTwoFactorAuthentication = () => {
}; };
const regenerateRecoveryCodes = () => { const regenerateRecoveryCodes = () => {
axios axios.post(route("two-factor.recovery-codes")).then(() => showRecoveryCodes());
.post(route('two-factor.recovery-codes'))
.then(() => showRecoveryCodes());
}; };
const disableTwoFactorAuthentication = () => { const disableTwoFactorAuthentication = () => {
disabling.value = true; disabling.value = true;
router.delete(route('two-factor.disable'), { router.delete(route("two-factor.disable"), {
preserveScroll: true, preserveScroll: true,
onSuccess: () => { onSuccess: () => {
disabling.value = false; disabling.value = false;
@@ -107,42 +112,50 @@ const copyToClipboard = async (text) => {
try { try {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
} catch (err) { } catch (err) {
console.error('Failed to copy:', err); console.error("Failed to copy:", err);
} }
}; };
</script> </script>
<template> <template>
<Card> <AppCard
<CardHeader> title=""
padding="none"
class="p-0! gap-0"
header-class="py-3! px-4 gap-0 text-muted-foreground"
body-class="p-4 border-t"
>
<template #header>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Shield class="h-5 w-5 text-muted-foreground" /> <Shield size="18" />
<CardTitle>Two Factor Authentication</CardTitle> <CardTitle>Dvonivojska overitev</CardTitle>
</div> </div>
<CardDescription> <CardDescription>
Add additional security to your account using two factor authentication. Dodatna varnost za vaš račun z dvonivojsko overitvijo.
</CardDescription> </CardDescription>
</CardHeader> </template>
<CardContent class="space-y-6">
<!-- Status Header --> <!-- Status Header -->
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<div class="flex-1"> <div class="flex-1">
<h3 v-if="twoFactorEnabled && ! confirming" class="text-lg font-semibold flex items-center gap-2"> <h3
v-if="twoFactorEnabled && !confirming"
class="text-lg font-semibold flex items-center gap-2"
>
<CheckCircle class="h-5 w-5 text-green-600" /> <CheckCircle class="h-5 w-5 text-green-600" />
Two factor authentication is enabled Dvonivojska overitev omogočena
</h3> </h3>
<h3 v-else-if="twoFactorEnabled && confirming" class="text-lg font-semibold flex items-center gap-2"> <h3
v-else-if="twoFactorEnabled && confirming"
class="text-lg font-semibold flex items-center gap-2"
>
<AlertCircle class="h-5 w-5 text-amber-600" /> <AlertCircle class="h-5 w-5 text-amber-600" />
Finish enabling two factor authentication
Dokončaj namestitev dvonivojske overitve
</h3> </h3>
<h3 v-else class="text-lg font-semibold flex items-center gap-2"> <h3 v-else class="text-lg font-semibold flex items-center gap-2">
<Shield class="h-5 w-5 text-muted-foreground" /> Dvonivojska overitev onemogočena
Two factor authentication is disabled
</h3> </h3>
<p class="mt-2 text-sm text-muted-foreground">
When two factor authentication is enabled, you will be prompted for a secure, random token during authentication. You may retrieve this token from your phone's Google Authenticator application.
</p>
</div> </div>
</div> </div>
@@ -151,10 +164,13 @@ const copyToClipboard = async (text) => {
<div v-if="qrCode" class="space-y-4"> <div v-if="qrCode" class="space-y-4">
<div class="rounded-lg border bg-muted/50 p-4"> <div class="rounded-lg border bg-muted/50 p-4">
<p v-if="confirming" class="text-sm font-medium mb-4"> <p v-if="confirming" class="text-sm font-medium mb-4">
To finish enabling two factor authentication, scan the following QR code using your phone's authenticator application or enter the setup key and provide the generated OTP code. Za dokončanje omogočanja dvostopenjske overitve skenirajte naslednjo QR-kodo z
aplikacijo za preverjanje pristnosti na vašem telefonu ali vnesite
namestitveno kodo in vpišite ustvarjeno OTP-kodo.
</p> </p>
<p v-else class="text-sm text-muted-foreground mb-4"> <p v-else class="text-sm text-muted-foreground mb-4">
Two factor authentication is now enabled. Scan the following QR code using your phone's authenticator application or enter the setup key. Dvonivojska overitev je zdaj omogočena. Skenirajte QR kodo z aplikacijo za
preverjanje pristnosti na vašem telefonu ali vnesite namestitveni ključ.
</p> </p>
<!-- QR Code --> <!-- QR Code -->
@@ -164,7 +180,7 @@ const copyToClipboard = async (text) => {
<div v-if="setupKey" class="mt-4 p-3 bg-background rounded-lg border"> <div v-if="setupKey" class="mt-4 p-3 bg-background rounded-lg border">
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<div class="flex-1"> <div class="flex-1">
<Label class="text-xs text-muted-foreground">Setup Key</Label> <Label class="text-xs text-muted-foreground">Namestitveni Ključ</Label>
<p class="font-mono text-sm font-semibold mt-1" v-html="setupKey"></p> <p class="font-mono text-sm font-semibold mt-1" v-html="setupKey"></p>
</div> </div>
<Button <Button
@@ -181,7 +197,7 @@ const copyToClipboard = async (text) => {
<!-- Confirmation Code Input --> <!-- Confirmation Code Input -->
<div v-if="confirming" class="space-y-2"> <div v-if="confirming" class="space-y-2">
<Label for="code">Confirmation Code</Label> <Label for="code">Potrdite kodo</Label>
<Input <Input
id="code" id="code"
v-model="confirmationForm.code" v-model="confirmationForm.code"
@@ -199,19 +215,28 @@ const copyToClipboard = async (text) => {
</div> </div>
<!-- Recovery Codes --> <!-- Recovery Codes -->
<div v-if="recoveryCodes.length > 0 && ! confirming" class="space-y-4"> <div v-if="recoveryCodes.length > 0 && !confirming" class="space-y-4">
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-950"> <div
class="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-950"
>
<div class="flex items-start gap-2"> <div class="flex items-start gap-2">
<Key class="h-5 w-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" /> <Key
class="h-5 w-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5"
/>
<p class="text-sm font-medium text-amber-900 dark:text-amber-100"> <p class="text-sm font-medium text-amber-900 dark:text-amber-100">
Store these recovery codes in a secure password manager. They can be used to recover access to your account if your two factor authentication device is lost. Shranite to obnovitveno kodo v upravitelja gesel. Lahko se uporabi za obnovo
vstopa v vaš račun, če se izgubi naprava z dvostopenjskim overjanjem.
</p> </p>
</div> </div>
</div> </div>
<div class="rounded-lg border bg-muted p-4"> <div class="rounded-lg border bg-muted p-4">
<div class="grid grid-cols-2 gap-2 font-mono text-sm"> <div class="grid grid-cols-2 gap-2 font-mono text-sm">
<div v-for="code in recoveryCodes" :key="code" class="flex items-center justify-between p-2 bg-background rounded border"> <div
v-for="code in recoveryCodes"
:key="code"
class="flex items-center justify-between p-2 bg-background rounded border"
>
<span>{{ code }}</span> <span>{{ code }}</span>
<Button <Button
type="button" type="button"
@@ -227,11 +252,11 @@ const copyToClipboard = async (text) => {
</div> </div>
</div> </div>
</div> </div>
<template #footer>
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="flex flex-wrap gap-2"> <div class="flex flex-row gap-2 items-center justify-end w-full">
<!-- Enable --> <!-- Enable -->
<div v-if="! twoFactorEnabled"> <div v-if="!twoFactorEnabled">
<ConfirmsPassword @confirmed="enableTwoFactorAuthentication"> <ConfirmsPassword @confirmed="enableTwoFactorAuthentication">
<Button type="button" :disabled="enabling"> <Button type="button" :disabled="enabling">
<Shield class="h-4 w-4 mr-2" /> <Shield class="h-4 w-4 mr-2" />
@@ -243,11 +268,7 @@ const copyToClipboard = async (text) => {
<!-- Confirm --> <!-- Confirm -->
<template v-else> <template v-else>
<ConfirmsPassword @confirmed="confirmTwoFactorAuthentication"> <ConfirmsPassword @confirmed="confirmTwoFactorAuthentication">
<Button <Button v-if="confirming" type="button" :disabled="enabling">
v-if="confirming"
type="button"
:disabled="enabling"
>
<CheckCircle class="h-4 w-4 mr-2" /> <CheckCircle class="h-4 w-4 mr-2" />
Confirm Confirm
</Button> </Button>
@@ -256,7 +277,7 @@ const copyToClipboard = async (text) => {
<!-- Regenerate Recovery Codes --> <!-- Regenerate Recovery Codes -->
<ConfirmsPassword @confirmed="regenerateRecoveryCodes"> <ConfirmsPassword @confirmed="regenerateRecoveryCodes">
<Button <Button
v-if="recoveryCodes.length > 0 && ! confirming" v-if="recoveryCodes.length > 0 && !confirming"
type="button" type="button"
variant="outline" variant="outline"
> >
@@ -268,7 +289,7 @@ const copyToClipboard = async (text) => {
<!-- Show Recovery Codes --> <!-- Show Recovery Codes -->
<ConfirmsPassword @confirmed="showRecoveryCodes"> <ConfirmsPassword @confirmed="showRecoveryCodes">
<Button <Button
v-if="recoveryCodes.length === 0 && ! confirming" v-if="recoveryCodes.length === 0 && !confirming"
type="button" type="button"
variant="outline" variant="outline"
> >
@@ -291,7 +312,7 @@ const copyToClipboard = async (text) => {
<ConfirmsPassword @confirmed="disableTwoFactorAuthentication"> <ConfirmsPassword @confirmed="disableTwoFactorAuthentication">
<Button <Button
v-if="! confirming" v-if="!confirming"
type="button" type="button"
variant="destructive" variant="destructive"
:disabled="disabling" :disabled="disabling"
@@ -301,6 +322,6 @@ const copyToClipboard = async (text) => {
</ConfirmsPassword> </ConfirmsPassword>
</template> </template>
</div> </div>
</CardContent> </template>
</Card> </AppCard>
</template> </template>
@@ -1,35 +1,36 @@
<script setup> <script setup>
import { ref } from 'vue'; import { ref } from "vue";
import { useForm } from '@inertiajs/vue3'; import { useForm } from "@inertiajs/vue3";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/Components/ui/card'; import { Button } from "@/Components/ui/button";
import { Button } from '@/Components/ui/button'; import { Input } from "@/Components/ui/input";
import { Input } from '@/Components/ui/input'; import { Label } from "@/Components/ui/label";
import { Label } from '@/Components/ui/label'; import InputError from "@/Components/InputError.vue";
import InputError from '@/Components/InputError.vue'; import { CheckCircle, Lock } from "lucide-vue-next";
import { CheckCircle, Lock } from 'lucide-vue-next'; import AppCard from "@/Components/app/ui/card/AppCard.vue";
import { CardTitle } from "@/Components/ui/card";
const passwordInput = ref(null); const passwordInput = ref(null);
const currentPasswordInput = ref(null); const currentPasswordInput = ref(null);
const form = useForm({ const form = useForm({
current_password: '', current_password: "",
password: '', password: "",
password_confirmation: '', password_confirmation: "",
}); });
const updatePassword = () => { const updatePassword = () => {
form.put(route('user-password.update'), { form.put(route("user-password.update"), {
errorBag: 'updatePassword', errorBag: "updatePassword",
preserveScroll: true, preserveScroll: true,
onSuccess: () => form.reset(), onSuccess: () => form.reset(),
onError: () => { onError: () => {
if (form.errors.password) { if (form.errors.password) {
form.reset('password', 'password_confirmation'); form.reset("password", "password_confirmation");
passwordInput.value.focus(); passwordInput.value.focus();
} }
if (form.errors.current_password) { if (form.errors.current_password) {
form.reset('current_password'); form.reset("current_password");
currentPasswordInput.value.focus(); currentPasswordInput.value.focus();
} }
}, },
@@ -38,21 +39,26 @@ const updatePassword = () => {
</script> </script>
<template> <template>
<Card> <AppCard
<form @submit.prevent="updatePassword"> title=""
<CardHeader> padding="none"
class="p-0! gap-0"
header-class="py-3! px-4 gap-0 text-muted-foreground"
body-class="p-4 border-t"
>
<template #header>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Lock class="h-5 w-5 text-muted-foreground" /> <Lock size="18" />
<CardTitle>Update Password</CardTitle> <CardTitle>Posodobi geslo</CardTitle>
</div> </div>
<CardDescription> <p class="text-sm">
Ensure your account is using a long, random password to stay secure. Poskrbite, da vaš račun uporablja dolgo, naključno geslo za varnost.
</CardDescription> </p>
</CardHeader> </template>
<CardContent class="space-y-6"> <form @submit.prevent="updatePassword" class="space-y-6">
<div class="space-y-2"> <div class="space-y-2">
<Label for="current_password">Current Password</Label> <Label for="current_password">Trenutno geslo</Label>
<Input <Input
id="current_password" id="current_password"
ref="currentPasswordInput" ref="currentPasswordInput"
@@ -64,7 +70,7 @@ const updatePassword = () => {
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label for="password">New Password</Label> <Label for="password">Novo geslo</Label>
<Input <Input
id="password" id="password"
ref="passwordInput" ref="passwordInput"
@@ -76,7 +82,7 @@ const updatePassword = () => {
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label for="password_confirmation">Confirm Password</Label> <Label for="password_confirmation">Potrdi geslo</Label>
<Input <Input
id="password_confirmation" id="password_confirmation"
v-model="form.password_confirmation" v-model="form.password_confirmation"
@@ -85,17 +91,16 @@ const updatePassword = () => {
/> />
<InputError :message="form.errors.password_confirmation" class="mt-2" /> <InputError :message="form.errors.password_confirmation" class="mt-2" />
</div> </div>
</CardContent> </form>
<CardFooter class="flex items-center justify-between"> <template #footer>
<div class="flex items-center justify-between w-full">
<div class="flex items-center gap-2 text-sm text-muted-foreground"> <div class="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle v-if="form.recentlySuccessful" class="h-4 w-4 text-green-600" /> <CheckCircle v-if="form.recentlySuccessful" class="h-4 w-4 text-green-600" />
<span v-if="form.recentlySuccessful">Saved.</span> <span v-if="form.recentlySuccessful">Shranjeno.</span>
</div> </div>
<Button type="submit" :disabled="form.processing"> <Button type="submit" :disabled="form.processing"> Shrani </Button>
Save </div>
</Button> </template>
</CardFooter> </AppCard>
</form>
</Card>
</template> </template>
@@ -1,20 +1,21 @@
<script setup> <script setup>
import { ref } from 'vue'; import { ref } from "vue";
import { Link, router, useForm } from '@inertiajs/vue3'; import { Link, router, useForm } from "@inertiajs/vue3";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/Components/ui/card'; import { Button } from "@/Components/ui/button";
import { Button } from '@/Components/ui/button'; import { Input } from "@/Components/ui/input";
import { Input } from '@/Components/ui/input'; import { Label } from "@/Components/ui/label";
import { Label } from '@/Components/ui/label'; import { Avatar, AvatarImage, AvatarFallback } from "@/Components/ui/avatar";
import { Avatar, AvatarImage, AvatarFallback } from '@/Components/ui/avatar'; import InputError from "@/Components/InputError.vue";
import InputError from '@/Components/InputError.vue'; import { User, Mail, Camera, Trash2, CheckCircle, AlertCircle } from "lucide-vue-next";
import { User, Mail, Camera, Trash2, CheckCircle, AlertCircle } from 'lucide-vue-next'; import AppCard from "@/Components/app/ui/card/AppCard.vue";
import { CardTitle } from "@/Components/ui/card";
const props = defineProps({ const props = defineProps({
user: Object, user: Object,
}); });
const form = useForm({ const form = useForm({
_method: 'PUT', _method: "PUT",
name: props.user.name, name: props.user.name,
email: props.user.email, email: props.user.email,
photo: null, photo: null,
@@ -29,8 +30,8 @@ const updateProfileInformation = () => {
form.photo = photoInput.value.files[0]; form.photo = photoInput.value.files[0];
} }
form.post(route('user-profile-information.update'), { form.post(route("user-profile-information.update"), {
errorBag: 'updateProfileInformation', errorBag: "updateProfileInformation",
preserveScroll: true, preserveScroll: true,
onSuccess: () => clearPhotoFileInput(), onSuccess: () => clearPhotoFileInput(),
}); });
@@ -47,7 +48,7 @@ const selectNewPhoto = () => {
const updatePhotoPreview = () => { const updatePhotoPreview = () => {
const photo = photoInput.value.files[0]; const photo = photoInput.value.files[0];
if (! photo) return; if (!photo) return;
const reader = new FileReader(); const reader = new FileReader();
@@ -59,7 +60,7 @@ const updatePhotoPreview = () => {
}; };
const deletePhoto = () => { const deletePhoto = () => {
router.delete(route('current-user-photo.destroy'), { router.delete(route("current-user-photo.destroy"), {
preserveScroll: true, preserveScroll: true,
onSuccess: () => { onSuccess: () => {
photoPreview.value = null; photoPreview.value = null;
@@ -76,19 +77,22 @@ const clearPhotoFileInput = () => {
</script> </script>
<template> <template>
<Card> <AppCard
<form @submit.prevent="updateProfileInformation"> title=""
<CardHeader> padding="none"
class="p-0! gap-0"
header-class="py-3! px-4 gap-0 text-muted-foreground"
body-class="p-4 border-t"
>
<template #header>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<User class="h-5 w-5 text-muted-foreground" /> <User size="18" />
<CardTitle>Profile Information</CardTitle> <CardTitle>Informacije profila</CardTitle>
</div> </div>
<CardDescription> <p class="text-sm">Posodobite informacije vašega profila in e-poštni naslov.</p>
Update your account's profile information and email address. </template>
</CardDescription>
</CardHeader>
<CardContent class="space-y-6"> <form @submit.prevent="updateProfileInformation" class="space-y-6">
<!-- Profile Photo --> <!-- Profile Photo -->
<div v-if="$page.props.jetstream.managesProfilePhotos" class="space-y-4"> <div v-if="$page.props.jetstream.managesProfilePhotos" class="space-y-4">
<input <input
@@ -98,23 +102,15 @@ const clearPhotoFileInput = () => {
class="hidden" class="hidden"
accept="image/*" accept="image/*"
@change="updatePhotoPreview" @change="updatePhotoPreview"
> />
<Label for="photo">Photo</Label> <Label for="photo">Fotografija</Label>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<!-- Current/Preview Photo --> <!-- Current/Preview Photo -->
<Avatar class="h-20 w-20"> <Avatar class="h-20 w-20">
<AvatarImage <AvatarImage v-if="photoPreview" :src="photoPreview" :alt="user.name" />
v-if="photoPreview" <AvatarImage v-else :src="user.profile_photo_url" :alt="user.name" />
:src="photoPreview"
:alt="user.name"
/>
<AvatarImage
v-else
:src="user.profile_photo_url"
:alt="user.name"
/>
<AvatarFallback> <AvatarFallback>
<User class="h-8 w-8" /> <User class="h-8 w-8" />
</AvatarFallback> </AvatarFallback>
@@ -128,7 +124,7 @@ const clearPhotoFileInput = () => {
@click.prevent="selectNewPhoto" @click.prevent="selectNewPhoto"
> >
<Camera class="h-4 w-4 mr-2" /> <Camera class="h-4 w-4 mr-2" />
Select Photo Izberi fotografijo
</Button> </Button>
<Button <Button
@@ -139,7 +135,7 @@ const clearPhotoFileInput = () => {
@click.prevent="deletePhoto" @click.prevent="deletePhoto"
> >
<Trash2 class="h-4 w-4 mr-2" /> <Trash2 class="h-4 w-4 mr-2" />
Remove Odstrani
</Button> </Button>
</div> </div>
</div> </div>
@@ -149,20 +145,14 @@ const clearPhotoFileInput = () => {
<!-- Name --> <!-- Name -->
<div class="space-y-2"> <div class="space-y-2">
<Label for="name">Name</Label> <Label for="name">Ime</Label>
<Input <Input id="name" v-model="form.name" type="text" required autocomplete="name" />
id="name"
v-model="form.name"
type="text"
required
autocomplete="name"
/>
<InputError :message="form.errors.name" class="mt-2" /> <InputError :message="form.errors.name" class="mt-2" />
</div> </div>
<!-- Email --> <!-- Email -->
<div class="space-y-2"> <div class="space-y-2">
<Label for="email">Email</Label> <Label for="email">E-pošta</Label>
<Input <Input
id="email" id="email"
v-model="form.email" v-model="form.email"
@@ -173,12 +163,17 @@ const clearPhotoFileInput = () => {
<InputError :message="form.errors.email" class="mt-2" /> <InputError :message="form.errors.email" class="mt-2" />
<!-- Email Verification --> <!-- Email Verification -->
<div v-if="$page.props.jetstream.hasEmailVerification && user.email_verified_at === null" class="rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-800 dark:bg-amber-950"> <div
v-if="
$page.props.jetstream.hasEmailVerification && user.email_verified_at === null
"
class="rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-800 dark:bg-amber-950"
>
<div class="flex items-start gap-2"> <div class="flex items-start gap-2">
<AlertCircle class="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5" /> <AlertCircle class="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5" />
<div class="flex-1 text-sm"> <div class="flex-1 text-sm">
<p class="text-amber-800 dark:text-amber-200"> <p class="text-amber-800 dark:text-amber-200">
Your email address is unverified. Vaš e-poštni naslov ni potrjen.
<Link <Link
:href="route('verification.send')" :href="route('verification.send')"
method="post" method="post"
@@ -186,28 +181,33 @@ const clearPhotoFileInput = () => {
class="underline text-amber-900 hover:text-amber-700 dark:text-amber-100 dark:hover:text-amber-300 font-medium" class="underline text-amber-900 hover:text-amber-700 dark:text-amber-100 dark:hover:text-amber-300 font-medium"
@click.prevent="sendEmailVerification" @click.prevent="sendEmailVerification"
> >
Click here to re-send the verification email. Kliknite tukaj za ponovno pošiljanje potrditvenega e-sporočila.
</Link> </Link>
</p> </p>
<div v-show="verificationLinkSent" class="mt-2 flex items-center gap-1.5 text-green-700 dark:text-green-400"> <div
v-show="verificationLinkSent"
class="mt-2 flex items-center gap-1.5 text-green-700 dark:text-green-400"
>
<CheckCircle class="h-4 w-4" /> <CheckCircle class="h-4 w-4" />
<span>A new verification link has been sent to your email address.</span> <span
>Nova povezava za potrditev je bila poslana na vaš e-poštni
naslov.</span
>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</CardContent> </form>
<CardFooter class="flex items-center justify-between"> <template #footer>
<div class="flex items-center justify-between w-full">
<div class="flex items-center gap-2 text-sm text-muted-foreground"> <div class="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle v-if="form.recentlySuccessful" class="h-4 w-4 text-green-600" /> <CheckCircle v-if="form.recentlySuccessful" class="h-4 w-4 text-green-600" />
<span v-if="form.recentlySuccessful">Saved.</span> <span v-if="form.recentlySuccessful">Shranjeno.</span>
</div> </div>
<Button type="submit" :disabled="form.processing"> <Button type="submit" :disabled="form.processing"> Shrani </Button>
Save </div>
</Button> </template>
</CardFooter> </AppCard>
</form>
</Card>
</template> </template>
+82 -1
View File
@@ -1,10 +1,11 @@
<script setup> <script setup>
import AppLayout from "@/Layouts/AppLayout.vue"; import AppLayout from "@/Layouts/AppLayout.vue";
import { Link, router } from "@inertiajs/vue3"; import { Link, router, useForm, usePage } from "@inertiajs/vue3";
import { ref, computed } from "vue"; import { ref, computed } from "vue";
import axios from "axios"; import axios from "axios";
import DataTable from "@/Components/DataTable/DataTableNew2.vue"; import DataTable from "@/Components/DataTable/DataTableNew2.vue";
import DialogModal from "@/Components/DialogModal.vue"; import DialogModal from "@/Components/DialogModal.vue";
import ConfirmDialog from "@/Components/ConfirmDialog.vue";
import { Button } from "@/Components/ui/button"; import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input"; import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label"; import { Label } from "@/Components/ui/label";
@@ -30,6 +31,7 @@ import {
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import AppCard from "@/Components/app/ui/card/AppCard.vue"; import AppCard from "@/Components/app/ui/card/AppCard.vue";
import { CardTitle } from "@/Components/ui/card"; import { CardTitle } from "@/Components/ui/card";
import { toNumber } from "lodash";
const props = defineProps({ const props = defineProps({
segment: Object, segment: Object,
@@ -63,6 +65,14 @@ const exportColumns = ref(columns.map((col) => col.key));
const exportError = ref(""); const exportError = ref("");
const isExporting = ref(false); const isExporting = ref(false);
const contractTable = ref(null);
const selectedRows = ref([]);
const showConfirmDialog = ref(false);
const archiveForm = useForm({
contracts: [],
reactivate: false,
});
const hasActiveFilters = computed(() => { const hasActiveFilters = computed(() => {
return Boolean(search.value?.trim()) || Boolean(selectedClient.value); return Boolean(search.value?.trim()) || Boolean(selectedClient.value);
}); });
@@ -78,6 +88,13 @@ const appliedFilterCount = computed(() => {
return count; return count;
}); });
function handleSelectionChange(selectedKeys) {
selectedRows.value = selectedKeys.map((val, i) => {
const nu = toNumber(val);
return props.contracts.data[nu].uuid;
});
}
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(
@@ -90,6 +107,11 @@ const exportDisabled = computed(
() => exportColumns.value.length === 0 || isExporting.value () => 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) : [];
} }
@@ -311,6 +333,36 @@ function extractFilenameFromHeaders(headers) {
const asciiMatch = disposition.match(/filename="?([^";]+)"?/i); const asciiMatch = disposition.match(/filename="?([^";]+)"?/i);
return asciiMatch?.[1] || null; return asciiMatch?.[1] || null;
} }
function openArchiveModal() {
console.log(selectedRows.value);
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 = [];
if (contractTable.value) {
contractTable.value.clearSelection();
}
router.reload({ only: ["contracts"] });
},
});
}
</script> </script>
<template> <template>
@@ -364,10 +416,13 @@ function extractFilenameFromHeaders(headers) {
</div> </div>
</template> </template>
<DataTable <DataTable
ref="contractTable"
:columns="columns" :columns="columns"
:data="contracts?.data || []" :data="contracts?.data || []"
:meta="contracts || {}" :meta="contracts || {}"
route-name="segments.show" route-name="segments.show"
:enable-row-selection="canManageSettings"
@selection:change="handleSelectionChange"
:route-params="{ segment: segment?.id ?? segment }" :route-params="{ segment: segment?.id ?? segment }"
:only-props="['contracts']" :only-props="['contracts']"
:page-size="contracts?.per_page ?? 15" :page-size="contracts?.per_page ?? 15"
@@ -500,6 +555,17 @@ function extractFilenameFromHeaders(headers) {
</Button> </Button>
</div> </div>
</template> </template>
<template #toolbar-actions="{ table }">
<Button
v-if="canManageSettings && table?.getSelectedRowModel()?.rows?.length > 0"
variant="destructive"
size="sm"
class="gap-2"
@click="openArchiveModal"
>
Arhiviraj ({{ table.getSelectedRowModel().rows.length }})
</Button>
</template>
<template #cell-client_case="{ row }"> <template #cell-client_case="{ row }">
<Link <Link
@@ -541,6 +607,21 @@ function extractFilenameFromHeaders(headers) {
</div> </div>
</div> </div>
<ConfirmDialog
:show="showConfirmDialog"
title="Arhiviraj pogodbe"
:message="`Ali ste prepričani, da želite arhivirati ${
selectedRows?.length || 0
} 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 class="space-y-1"> <div class="space-y-1">
@@ -305,7 +305,7 @@ const destroyAction = () => {
</div> </div>
</div> </div>
<div> <div class="space-y-1.5">
<InputLabel for="segmentEdit">Segment</InputLabel> <InputLabel for="segmentEdit">Segment</InputLabel>
<AppCombobox <AppCombobox
id="segmentEdit" id="segmentEdit"
@@ -323,7 +323,7 @@ const destroyAction = () => {
v-model="form.decisions" v-model="form.decisions"
:items="selectOptions" :items="selectOptions"
placeholder="Dodaj odločitev" placeholder="Dodaj odločitev"
content-class="p-0 w-full" chip-variant="secondary"
/> />
</div> </div>
@@ -373,7 +373,7 @@ const destroyAction = () => {
v-model="createForm.decisions" v-model="createForm.decisions"
:items="selectOptions" :items="selectOptions"
placeholder="Dodaj odločitev" placeholder="Dodaj odločitev"
content-class="p-0 w-full" chip-variant="secondary"
/> />
</div> </div>
@@ -265,20 +265,22 @@ const update = () => {
// Transform actions from array of IDs to array of objects // Transform actions from array of IDs to array of objects
const actionsPayload = form.actions const actionsPayload = form.actions
.map(id => { .map((id) => {
const action = props.actions.find(a => a.id === Number(id) || a.id === id); const action = props.actions.find((a) => a.id === Number(id) || a.id === id);
if (!action) { if (!action) {
console.warn('Action not found for id:', id); console.warn("Action not found for id:", id);
return null; return null;
} }
return { id: action.id, name: action.name }; return { id: action.id, name: action.name };
}) })
.filter(Boolean); // Remove null entries .filter(Boolean); // Remove null entries
form.transform((data) => ({ form
.transform((data) => ({
...data, ...data,
actions: actionsPayload actions: actionsPayload,
})).put(route("settings.decisions.update", { id: form.id }), { }))
.put(route("settings.decisions.update", { id: form.id }), {
onSuccess: () => { onSuccess: () => {
closeEditDrawer(); closeEditDrawer();
}, },
@@ -299,20 +301,22 @@ const store = () => {
// Transform actions from array of IDs to array of objects // Transform actions from array of IDs to array of objects
const actionsPayload = createForm.actions const actionsPayload = createForm.actions
.map(id => { .map((id) => {
const action = props.actions.find(a => a.id === Number(id) || a.id === id); const action = props.actions.find((a) => a.id === Number(id) || a.id === id);
if (!action) { if (!action) {
console.warn('Action not found for id:', id); console.warn("Action not found for id:", id);
return null; return null;
} }
return { id: action.id, name: action.name }; return { id: action.id, name: action.name };
}) })
.filter(Boolean); // Remove null entries .filter(Boolean); // Remove null entries
createForm.transform((data) => ({ createForm
.transform((data) => ({
...data, ...data,
actions: actionsPayload actions: actionsPayload,
})).post(route("settings.decisions.store"), { }))
.post(route("settings.decisions.store"), {
onSuccess: () => { onSuccess: () => {
closeCreateDrawer(); closeCreateDrawer();
}, },
@@ -665,7 +669,7 @@ const destroyDecision = () => {
</div> </div>
<div class="flex items-center gap-2 self-end"> <div class="flex items-center gap-2 self-end">
<label class="flex items-center gap-2 text-sm"> <label class="flex items-center gap-2 text-sm">
<Checkbox v-model:checked="ev.active" /> <Checkbox v-model="ev.active" />
Aktivno Aktivno
</label> </label>
<Button <Button
@@ -703,7 +707,7 @@ const destroyDecision = () => {
</div> </div>
<div class="flex items-end"> <div class="flex items-end">
<label class="flex items-center gap-2 text-sm mt-6"> <label class="flex items-center gap-2 text-sm mt-6">
<Checkbox v-model:checked="ev.config.deactivate_previous" /> <Checkbox v-model="ev.config.deactivate_previous" />
Deaktiviraj prejšnje Deaktiviraj prejšnje
</label> </label>
</div> </div>
+19 -4
View File
@@ -1,13 +1,21 @@
<script setup> <script setup>
import { ref } from "vue"; import { ref, watch } from "vue";
import AppLayout from "@/Layouts/AppLayout.vue"; import AppLayout from "@/Layouts/AppLayout.vue";
import DataTableClient from "@/Components/DataTable/DataTableClient.vue"; import DataTableClient from "@/Components/DataTable/DataTableClient.vue";
import DataTableExample from "../Examples/DataTableExample.vue"; import DataTableExample from "../Examples/DataTableExample.vue";
import { useForm } from "@inertiajs/vue3";
import Checkbox from "@/Components/ui/checkbox/Checkbox.vue";
const props = defineProps({ const props = defineProps({
example: { type: String, default: "Demo" }, example: { type: String, default: "Demo" },
}); });
const checkboxValue = ref(false);
const testForm = useForm({
allowed: false,
});
// Dummy columns // Dummy columns
const columns = [ const columns = [
{ key: "id", label: "ID", sortable: true, class: "w-16" }, { key: "id", label: "ID", sortable: true, class: "w-16" },
@@ -53,10 +61,17 @@ function onRowClick(row) {
// no-op demo; could show toast or details // no-op demo; could show toast or details
console.debug("Row clicked:", row); console.debug("Row clicked:", row);
} }
watch(
() => testForm.allowed,
(newVal) => {
console.log(newVal);
}
);
</script> </script>
<template> <template>
<AppLayout>
<DataTableExample></DataTableExample> <Checkbox v-model:checked="testForm.allowed" />
</AppLayout>
</template> </template>
+14 -2
View File
@@ -203,7 +203,14 @@
->leftJoin('person_addresses', 'person.id', '=', 'person_addresses.person_id') ->leftJoin('person_addresses', 'person.id', '=', 'person_addresses.person_id')
->leftJoin('person_phones', 'person.id', '=', 'person_phones.person_id') ->leftJoin('person_phones', 'person.id', '=', 'person_phones.person_id')
->leftJoin('emails', 'person.id', '=', 'emails.person_id') ->leftJoin('emails', 'person.id', '=', 'emails.person_id')
->select('person.*', 'client_cases.uuid as case_uuid', 'client_cases.id as case_id') ->leftJoin('clients', 'clients.id', '=', 'client_cases.client_id')
->leftJoin('person as client_person', 'client_person.id', '=', 'clients.person_id')
->select(
'person.*',
'client_cases.uuid as case_uuid',
'client_cases.id as case_id',
'client_person.full_name as client_full_name'
)
->limit($request->input('limit')); ->limit($request->input('limit'));
}) })
->get(); ->get();
@@ -215,6 +222,8 @@
$contractCases = \App\Models\Contract::query() $contractCases = \App\Models\Contract::query()
->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id') ->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
->join('person', 'client_cases.person_id', '=', 'person.id') ->join('person', 'client_cases.person_id', '=', 'person.id')
->leftJoin('clients', 'clients.id', '=', 'client_cases.client_id')
->leftJoin('person as client_person', 'client_person.id', '=', 'clients.person_id')
->leftJoin('contract_segment', function ($j) { ->leftJoin('contract_segment', function ($j) {
$j->on('contract_segment.contract_id', '=', 'contracts.id') $j->on('contract_segment.contract_id', '=', 'contracts.id')
->where('contract_segment.active', true); ->where('contract_segment.active', true);
@@ -227,9 +236,10 @@
'client_cases.uuid as case_uuid', 'client_cases.uuid as case_uuid',
'client_cases.id as case_id', 'client_cases.id as case_id',
'contracts.reference as contract_reference', 'contracts.reference as contract_reference',
'client_person.full_name as client_full_name',
\DB::raw("COALESCE(json_agg(DISTINCT jsonb_build_object('id', segments.id, 'name', segments.name)) FILTER (WHERE segments.id IS NOT NULL), '[]') as contract_segments") \DB::raw("COALESCE(json_agg(DISTINCT jsonb_build_object('id', segments.id, 'name', segments.name)) FILTER (WHERE segments.id IS NOT NULL), '[]') as contract_segments")
) )
->groupBy('person.id', 'client_cases.uuid', 'client_cases.id', 'contracts.reference') ->groupBy('person.id', 'client_cases.uuid', 'client_cases.id', 'contracts.reference', 'client_person.full_name')
->limit($limit) ->limit($limit)
->get(); ->get();
@@ -324,6 +334,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
@@ -452,6 +463,7 @@
Route::get('imports/{import}/missing-keyref-rows', [ImportController::class, 'missingKeyrefRows'])->name('imports.missing-keyref-rows'); Route::get('imports/{import}/missing-keyref-rows', [ImportController::class, 'missingKeyrefRows'])->name('imports.missing-keyref-rows');
Route::get('imports/{import}/missing-keyref-csv', [ImportController::class, 'exportMissingKeyrefCsv'])->name('imports.missing-keyref-csv'); Route::get('imports/{import}/missing-keyref-csv', [ImportController::class, 'exportMissingKeyrefCsv'])->name('imports.missing-keyref-csv');
Route::get('imports/{import}/preview', [ImportController::class, 'preview'])->name('imports.preview'); Route::get('imports/{import}/preview', [ImportController::class, 'preview'])->name('imports.preview');
Route::get('imports/{import}/download', [ImportController::class, 'download'])->name('imports.download');
Route::get('imports/{import}/missing-contracts', [ImportController::class, 'missingContracts'])->name('imports.missing-contracts'); Route::get('imports/{import}/missing-contracts', [ImportController::class, 'missingContracts'])->name('imports.missing-contracts');
Route::post('imports/{import}/options', [ImportController::class, 'updateOptions'])->name('imports.options'); Route::post('imports/{import}/options', [ImportController::class, 'updateOptions'])->name('imports.options');
// Generic simulation endpoint (new) provides projected effects for first N rows regardless of payments template // Generic simulation endpoint (new) provides projected effects for first N rows regardless of payments template
+72
View File
@@ -0,0 +1,72 @@
<?php
use App\Models\Import;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
it('downloads the original import file', function () {
// Create a test file
$uuid = (string) Str::uuid();
$disk = 'local';
$path = "imports/{$uuid}.csv";
$csv = "email,reference\nalpha@example.com,REF-1\n";
Storage::disk($disk)->put($path, $csv);
// Authenticate a user
$user = User::factory()->create();
Auth::login($user);
// Create import record
$import = Import::create([
'uuid' => $uuid,
'user_id' => $user->id,
'import_template_id' => null,
'client_id' => null,
'source_type' => 'csv',
'file_name' => basename($path),
'original_name' => 'test-import.csv',
'disk' => $disk,
'path' => $path,
'size' => strlen($csv),
'status' => 'uploaded',
'meta' => ['has_header' => true],
]);
// Test download endpoint
$response = test()->get(route('imports.download', ['import' => $import->id]));
$response->assertSuccessful();
expect($response->headers->get('Content-Disposition'))->toContain('test-import.csv');
// Clean up
Storage::disk($disk)->delete($path);
});
it('returns 404 when file does not exist', function () {
// Authenticate a user
$user = User::factory()->create();
Auth::login($user);
// Create import record with non-existent file
$import = Import::create([
'uuid' => (string) Str::uuid(),
'user_id' => $user->id,
'import_template_id' => null,
'client_id' => null,
'source_type' => 'csv',
'file_name' => 'missing.csv',
'original_name' => 'missing.csv',
'disk' => 'local',
'path' => 'imports/nonexistent.csv',
'size' => 0,
'status' => 'uploaded',
'meta' => ['has_header' => true],
]);
// Test download endpoint
$response = test()->get(route('imports.download', ['import' => $import->id]));
$response->assertNotFound();
});