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\Services\Contact\PhoneSelector;
use App\Services\Sms\SmsService;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Bus;
@@ -23,9 +24,11 @@ class PackageController extends Controller
{
public function index(Request $request): Response
{
$perPage = $request->input('per_page') ?? 25;
$packages = Package::query()
->latest('id')
->paginate(25);
->paginate($perPage);
return Inertia::render('Admin/Packages/Index', [
'packages' => $packages,
@@ -48,6 +51,7 @@ public function create(Request $request): Response
->get(['id', 'name', 'content']);
$segments = \App\Models\Segment::query()
->where('active', true)
->where('exclude', false)
->orderBy('name')
->get(['id', 'name']);
// 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([
'segment_id' => ['nullable', 'integer', 'exists:segments,id'],
'q' => ['nullable', 'string'],
'client_id' => ['nullable', 'integer', 'exists:clients,id'],
'only_mobile' => ['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;
$query = Contract::query()
->with([
'clientCase.person.phones',
'clientCase.client.person',
'account',
'segments:id,name',
])
->select('contracts.*')
->latest('contracts.id');
@@ -348,6 +351,15 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
->where('contract_segment.segment_id', '=', $segmentId)
->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'))) {
@@ -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) {
$person = $contract->clientCase?->person;
$selected = $person ? $selector->selectForPerson($person) : ['phone' => null, 'reason' => 'no_person'];
$phone = $selected['phone'];
$clientPerson = $contract->clientCase?->client?->person;
$segment = collect($contract->segments)->last();
return [
'id' => $contract->id,
@@ -421,6 +434,7 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
'uuid' => $person?->uuid,
'full_name' => $person?->full_name,
],
'segment' => $segment,
// Stranka: the client person
'client' => $clientPerson ? [
'id' => $contract->clientCase?->client?->id,
@@ -438,7 +452,7 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
});
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.
*/
+2 -2
View File
@@ -27,7 +27,7 @@ public function index(Client $client, Request $request)
->where('person.full_name', 'ilike', '%'.$search.'%')
->groupBy('clients.id');
})
->where('clients.active', 1)
//->where('clients.active', 1)
// Use LEFT JOINs for aggregated data to avoid subqueries
->leftJoin('client_cases', 'client_cases.client_id', '=', 'clients.id')
->leftJoin('contracts', function ($join) {
@@ -51,7 +51,7 @@ public function index(Client $client, Request $request)
return Inertia::render('Client/Index', [
'clients' => $query
->paginate($request->integer('per_page', 15))
->paginate($request->integer('per_page', default: 100))
->withQueryString(),
'filters' => $request->only(['search']),
]);
+4 -2
View File
@@ -62,7 +62,8 @@ public function index(Request $request)
$unassignedClients = $unassignedContracts->get()
->pluck('clientCase.client')
->filter()
->unique('id');
->unique('id')
->values();
$assignedContracts = Contract::query()
@@ -98,7 +99,8 @@ public function index(Request $request)
$assignedClients = $assignedContracts->get()
->pluck('clientCase.client')
->filter()
->unique('id');
->unique('id')
->values();
$users = User::query()->orderBy('name')->get(['id', 'name']);
+17 -4
View File
@@ -9,7 +9,6 @@
use App\Models\ImportEvent;
use App\Models\ImportTemplate;
use App\Services\CsvImportService;
use App\Services\Import\ImportServiceV2;
use App\Services\Import\ImportSimulationServiceV2;
use App\Services\ImportProcessor;
use Illuminate\Http\Request;
@@ -184,12 +183,13 @@ public function store(Request $request)
}
// 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()]);
try {
$result = $processor->process($import, user: $request->user());
return response()->json($result);
} catch (\Throwable $e) {
\Log::error('Import processing failed', [
@@ -712,8 +712,6 @@ public function simulatePayments(Import $import, Request $request)
* templates. For payments templates, payment-specific summaries/entities will be included
* automatically by the simulation service when mappings contain the payment root.
*
* @param Import $import
* @param Request $request
* @return \Illuminate\Http\JsonResponse
*/
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');
}
// 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();
$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', ''));
$clientUuid = trim((string) $request->input('client', ''));
$clientId = null;
+2 -2
View File
@@ -118,10 +118,10 @@ public function handle(SmsService $sms): void
if ($template && $case) {
$note = '';
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') {
$note = sprintf(
'Št: %s | Telo: %s | Napaka: %s',
'Tel: %s | Telo: %s | Napaka: %s',
(string) $this->to,
(string) $this->content,
'SMS ni bil poslan!'
+2
View File
@@ -6,10 +6,12 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Account extends Model
{
/** @use HasFactory<\Database\Factories\Person/AccountFactory> */
use SoftDeletes;
use HasFactory;
protected $fillable = [
@@ -103,7 +103,7 @@ public function process(Import $import, array $mapped, array $raw, array $contex
$payload = $this->buildPayloadForAddress($address);
$payload['person_id'] = $personId;
$addressEntity = new \App\Models\Person\PersonAddress;
$addressEntity = new PersonAddress;
$addressEntity->fill($payload);
$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
{
return \App\Models\Person\PersonAddress::where('person_id', $personId)
return PersonAddress::where('person_id', $personId)
->where('address', $address)
->first();
}
+18 -6
View File
@@ -1633,7 +1633,7 @@ private function upsertAccount(Import $import, array $mapped, $mappings, bool $h
$existing = Account::query()
->where('contract_id', $contractId)
->where('reference', $reference)
//->where('reference', $reference)
->where('active', 1)
->first();
@@ -1656,6 +1656,10 @@ private function upsertAccount(Import $import, array $mapped, $mappings, bool $h
$value = $acc[$field] ?? null;
if (in_array($field, ['balance_amount', 'initial_amount'], true) && is_string($value)) {
$value = $this->normalizeDecimal($value);
// Ensure the normalized value is numeric, otherwise default to 0
if ($value === '' || $value === '-' || ! is_numeric($value)) {
$value = 0;
}
}
// Convert empty string to 0 for amount fields
if (in_array($field, ['balance_amount', 'initial_amount'], true) && ($value === '' || $value === null)) {
@@ -1689,8 +1693,12 @@ private function upsertAccount(Import $import, array $mapped, $mappings, bool $h
if ($existing) {
// Build non-null changes for account fields
$changes = array_filter($applyUpdate, fn ($v) => ! is_null($v));
// Track balance change
$oldBalance = (float) ($existing->balance_amount ?? 0);
// Track balance change - normalize in case DB has malformed data
$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
if (! empty($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 (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) {
try {
$contractId = $existing->contract_id;
@@ -3194,7 +3206,7 @@ private function upsertAddress(int $personId, array $addrData, $mappings): array
->first();*/
// Build search query combining address, post_code and city
$searchParts = [$addrData['post_code']];
$searchParts = [$addrData['address']];
if (!empty($addrData['post_code'])) {
$searchParts[] = $addrData['post_code'];
}
@@ -3204,7 +3216,7 @@ private function upsertAddress(int $personId, array $addrData, $mappings): array
$searchQuery = implode(' ', $searchParts);
// 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])
->first();
+1 -1
View File
@@ -60,7 +60,7 @@
'features' => [
// Features::termsAndPrivacyPolicy(),
// Features::profilePhotos(),
Features::api(),
// Features::api(),
// Features::teams(['invitations' => true]),
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>
import { ref, reactive, nextTick } from 'vue';
import DialogModal from './DialogModal.vue';
import InputError from './InputError.vue';
import PrimaryButton from './PrimaryButton.vue';
import SecondaryButton from './SecondaryButton.vue';
import TextInput from './TextInput.vue';
import { ref, reactive, nextTick } from "vue";
import DialogModal from "./DialogModal.vue";
import InputError from "./InputError.vue";
import PrimaryButton from "./PrimaryButton.vue";
import SecondaryButton from "./SecondaryButton.vue";
import { Input } from "@/Components/ui/input";
const emit = defineEmits(['confirmed']);
const emit = defineEmits(["confirmed"]);
defineProps({
title: {
type: String,
default: 'Confirm Password',
default: "Confirm Password",
},
content: {
type: String,
default: 'For your security, please confirm your password to continue.',
default: "For your security, please confirm your password to continue.",
},
button: {
type: String,
default: 'Confirm',
default: "Confirm",
},
});
const confirmingPassword = ref(false);
const form = reactive({
password: '',
error: '',
password: "",
error: "",
processing: false,
});
const passwordInput = ref(null);
const startConfirmingPassword = () => {
axios.get(route('password.confirmation')).then(response => {
axios.get(route("password.confirmation")).then((response) => {
if (response.data.confirmed) {
emit('confirmed');
emit("confirmed");
} else {
confirmingPassword.value = true;
@@ -48,15 +48,17 @@ const startConfirmingPassword = () => {
const confirmPassword = () => {
form.processing = true;
axios.post(route('password.confirm'), {
axios
.post(route("password.confirm"), {
password: form.password,
}).then(() => {
})
.then(() => {
form.processing = false;
closeModal();
nextTick().then(() => emit('confirmed'));
}).catch(error => {
nextTick().then(() => emit("confirmed"));
})
.catch((error) => {
form.processing = false;
form.error = error.response.data.errors.password[0];
passwordInput.value.focus();
@@ -65,8 +67,8 @@ const confirmPassword = () => {
const closeModal = () => {
confirmingPassword.value = false;
form.password = '';
form.error = '';
form.password = "";
form.error = "";
};
</script>
@@ -85,7 +87,7 @@ const closeModal = () => {
{{ content }}
<div class="mt-4">
<TextInput
<Input
ref="passwordInput"
v-model="form.password"
type="password"
@@ -100,9 +102,7 @@ const closeModal = () => {
</template>
<template #footer>
<SecondaryButton @click="closeModal">
Cancel
</SecondaryButton>
<SecondaryButton @click="closeModal"> Cancel </SecondaryButton>
<PrimaryButton
class="ms-3"
@@ -69,7 +69,7 @@ const maxWidthClass = computed(() => {
<template>
<Dialog v-model:open="open">
<DialogContent :class="maxWidthClass">
<DialogContent class="overflow-auto max-h-3/4" :class="maxWidthClass">
<DialogHeader>
<DialogTitle>
<div class="flex items-center gap-2">
@@ -6,34 +6,40 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/Components/ui/dialog';
import { Button } from '@/Components/ui/button';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import { faTrashCan, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons';
import { ref, watch } from 'vue';
} from "@/Components/ui/dialog";
import { Button } from "@/Components/ui/button";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { faTrashCan, faTriangleExclamation } from "@fortawesome/free-solid-svg-icons";
import { ref, watch } from "vue";
const props = defineProps({
show: { type: Boolean, default: false },
title: { type: String, default: 'Izbriši' },
message: { type: String, default: 'Ali ste prepričani, da želite izbrisati ta element?' },
confirmText: { type: String, default: 'Izbriši' },
cancelText: { type: String, default: 'Prekliči' },
title: { type: String, default: "Izbriši" },
message: {
type: String,
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 },
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);
watch(() => props.show, (newVal) => {
watch(
() => props.show,
(newVal) => {
open.value = newVal;
});
}
);
watch(open, (newVal) => {
emit('update:show', newVal);
emit("update:show", newVal);
if (!newVal) {
emit('close');
emit("close");
}
});
@@ -42,7 +48,7 @@ const onClose = () => {
};
const onConfirm = () => {
emit('confirm');
emit("confirm");
};
</script>
@@ -59,8 +65,13 @@ const onConfirm = () => {
<DialogDescription>
<div class="flex items-start gap-4 pt-4">
<div class="flex-shrink-0">
<div 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
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 class="flex-1 space-y-2">
@@ -70,9 +81,7 @@ const onConfirm = () => {
<p v-if="itemName" class="text-sm font-medium text-gray-900">
{{ itemName }}
</p>
<p class="text-sm text-gray-500">
Ta dejanje ni mogoče razveljaviti.
</p>
<p class="text-sm text-gray-500">Ta dejanje ni mogoče razveljaviti.</p>
</div>
</div>
</DialogDescription>
@@ -82,15 +91,10 @@ const onConfirm = () => {
<Button variant="outline" @click="onClose" :disabled="processing">
{{ cancelText }}
</Button>
<Button
variant="destructive"
@click="onConfirm"
:disabled="processing"
>
<Button variant="destructive" @click="onConfirm" :disabled="processing">
{{ confirmText }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
@@ -69,7 +69,7 @@ const maxWidthClass = computed(() => {
<template>
<Dialog v-model:open="open">
<DialogContent :class="maxWidthClass">
<DialogContent class="overflow-auto max-h-3/4" :class="maxWidthClass">
<DialogHeader>
<DialogTitle>
<div class="flex items-center gap-2">
@@ -1,15 +1,27 @@
<script setup>
import CreateDialog from '@/Components/Dialogs/CreateDialog.vue'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod'
import { ref, watch } from 'vue'
import { router } from '@inertiajs/vue3'
import { FormControl, FormField, FormItem, 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'
import CreateDialog from "@/Components/Dialogs/CreateDialog.vue";
import { useForm } from "vee-validate";
import { toTypedSchema } from "@vee-validate/zod";
import * as z from "zod";
import { ref, watch } from "vue";
import { router } from "@inertiajs/vue3";
import {
FormControl,
FormField,
FormItem,
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({
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
// Each item should have at least: { uuid, reference }
contracts: { type: Array, default: () => [] },
})
const emit = defineEmits(['close', 'uploaded'])
});
const emit = defineEmits(["close", "uploaded"]);
const MAX_SIZE = 25 * 1024 * 1024 // 25MB
const ALLOWED_EXTS = ['doc','docx','pdf','txt','csv','xls','xlsx','jpeg','jpg','png']
const MAX_SIZE = 25 * 1024 * 1024; // 25MB
const ALLOWED_EXTS = [
"doc",
"docx",
"pdf",
"txt",
"csv",
"xls",
"xlsx",
"jpeg",
"jpg",
"png",
];
const formSchema = toTypedSchema(z.object({
name: z.string().min(1, 'Ime je obvezno'),
const formSchema = toTypedSchema(
z.object({
name: z.string().min(1, "Ime je obvezno"),
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),
contract_uuid: z.string().nullable().optional(),
}))
})
);
const form = useForm({
validationSchema: formSchema,
initialValues: {
name: '',
description: '',
name: "",
description: "",
file: null,
is_public: true,
contract_uuid: null,
},
})
});
const localError = ref('')
const localError = ref("");
watch(() => props.show, (v) => {
if (!v) return
localError.value = ''
form.resetForm()
})
watch(
() => props.show,
(v) => {
if (!v) return;
localError.value = "";
form.resetForm();
}
);
const onFileChange = (e) => {
localError.value = ''
const f = e.target.files?.[0]
localError.value = "";
const f = e.target.files?.[0];
if (!f) {
form.setFieldValue('file', null)
return
form.setFieldValue("file", null);
return;
}
const ext = (f.name.split('.').pop() || '').toLowerCase()
const ext = (f.name.split(".").pop() || "").toLowerCase();
if (!ALLOWED_EXTS.includes(ext)) {
localError.value = 'Nepodprta vrsta datoteke. Dovoljeno: ' + ALLOWED_EXTS.join(', ')
e.target.value = ''
form.setFieldValue('file', null)
return
localError.value = "Nepodprta vrsta datoteke. Dovoljeno: " + ALLOWED_EXTS.join(", ");
e.target.value = "";
form.setFieldValue("file", null);
return;
}
if (f.size > MAX_SIZE) {
localError.value = 'Datoteka je prevelika. Največja velikost je 25MB.'
e.target.value = ''
form.setFieldValue('file', null)
return
localError.value = "Datoteka je prevelika. Največja velikost je 25MB.";
e.target.value = "";
form.setFieldValue("file", null);
return;
}
form.setFieldValue('file', f)
form.setFieldValue("file", f);
if (!form.values.name) {
form.setFieldValue('name', f.name.replace(/\.[^.]+$/, ''))
}
form.setFieldValue("name", f.name.replace(/\.[^.]+$/, ""));
}
};
const submit = form.handleSubmit(async (values) => {
localError.value = ''
localError.value = "";
if (!values.file) {
localError.value = 'Prosimo izberite datoteko.'
return
localError.value = "Prosimo izberite datoteko.";
return;
}
const ext = (values.file.name.split('.').pop() || '').toLowerCase()
const ext = (values.file.name.split(".").pop() || "").toLowerCase();
if (!ALLOWED_EXTS.includes(ext)) {
localError.value = 'Nepodprta vrsta datoteke. Dovoljeno: ' + ALLOWED_EXTS.join(', ')
return
localError.value = "Nepodprta vrsta datoteke. Dovoljeno: " + ALLOWED_EXTS.join(", ");
return;
}
if (values.file.size > MAX_SIZE) {
localError.value = 'Datoteka je prevelika. Največja velikost je 25MB.'
return
localError.value = "Datoteka je prevelika. Največja velikost je 25MB.";
return;
}
const formData = new FormData()
formData.append('name', values.name)
formData.append('description', values.description || '')
formData.append('file', values.file)
formData.append('is_public', values.is_public ? '1' : '0')
const formData = new FormData();
formData.append("name", values.name);
formData.append("description", values.description || "");
formData.append("file", values.file);
formData.append("is_public", values.is_public ? "1" : "0");
if (values.contract_uuid) {
formData.append('contract_uuid', values.contract_uuid)
formData.append("contract_uuid", values.contract_uuid);
}
router.post(props.postUrl, formData, {
forceFormData: true,
onSuccess: () => {
emit('uploaded')
emit('close')
form.resetForm()
emit("uploaded");
emit("close");
form.resetForm();
},
onError: (errors) => {
// Set form errors if any
if (errors.name) form.setFieldError('name', errors.name)
if (errors.description) form.setFieldError('description', errors.description)
if (errors.file) form.setFieldError('file', errors.file)
if (errors.contract_uuid) form.setFieldError('contract_uuid', errors.contract_uuid)
if (errors.name) form.setFieldError("name", errors.name);
if (errors.description) form.setFieldError("description", errors.description);
if (errors.file) form.setFieldError("file", errors.file);
if (errors.contract_uuid) form.setFieldError("contract_uuid", errors.contract_uuid);
},
})
})
});
});
const close = () => emit('close')
const close = () => emit("close");
const onConfirm = () => {
submit()
}
submit();
};
</script>
<template>
@@ -137,7 +165,11 @@ const onConfirm = () => {
@confirm="onConfirm"
>
<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>
<FormLabel>Pripiši k</FormLabel>
<Select :model-value="value" @update:model-value="handleChange">
@@ -148,11 +180,7 @@ const onConfirm = () => {
</FormControl>
<SelectContent>
<SelectItem :value="null">Primer</SelectItem>
<SelectItem
v-for="c in props.contracts"
:key="c.uuid"
:value="c.uuid"
>
<SelectItem v-for="c in props.contracts" :key="c.uuid" :value="c.uuid">
Pogodba: {{ c.reference }}
</SelectItem>
</SelectContent>
@@ -165,7 +193,11 @@ const onConfirm = () => {
<FormItem>
<FormLabel>Ime</FormLabel>
<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>
<FormMessage />
</FormItem>
@@ -184,29 +216,24 @@ const onConfirm = () => {
<FormField v-slot="{ value, handleChange }" name="file">
<FormItem>
<FormLabel>Datoteka (max 25MB)</FormLabel>
<FormControl>
<FormControl class="flex w-full">
<Input
id="doc_file"
type="file"
@change="onFileChange"
accept=".doc,.docx,.pdf,.txt,.csv,.xls,.xlsx,.jpeg,.jpg,.png"
class="min-w-0 w-full"
/>
</FormControl>
<FormMessage />
<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>
</FormField>
<FormField v-slot="{ value, handleChange }" name="is_public">
<FormItem class="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Switch
:model-value="value"
@update:model-value="handleChange"
/>
<Switch :model-value="value" @update:model-value="handleChange" />
</FormControl>
<div class="space-y-1 leading-none">
<FormLabel>Javno</FormLabel>
@@ -1,30 +1,219 @@
<script setup>
import { ref, computed, watch } from "vue";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/Components/ui/dialog'
import { Button } from '@/Components/ui/button'
} from "@/Components/ui/dialog";
import { Button } from "@/Components/ui/button";
import { Badge } from "../ui/badge";
import { Loader2 } from "lucide-vue-next";
import axios from "axios";
const props = defineProps({
show: { type: Boolean, default: false },
src: { type: String, default: '' },
title: { type: String, default: 'Dokument' }
})
const emit = defineEmits(['close'])
src: { type: String, default: "" },
title: { type: String, default: "Dokument" },
mimeType: { type: String, default: "" },
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>
<template>
<Dialog :open="show" @update:open="(open) => !open && $emit('close')">
<DialogContent class="max-w-4xl">
<DialogContent class="max-w-full xl:max-w-7xl">
<DialogHeader>
<DialogTitle>{{ props.title }}</DialogTitle>
<DialogTitle>
{{ title }}
</DialogTitle>
<DialogDescription>
<Badge>
{{ fileExtension }}
</Badge>
</DialogDescription>
</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>
<div class="flex justify-end mt-4">
<Button type="button" variant="outline" @click="$emit('close')">Zapri</Button>
</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 UpdateDialog from "../Dialogs/UpdateDialog.vue";
import SectionTitle from "../SectionTitle.vue";
import {
FormControl,
FormItem,
FormLabel,
FormMessage,
} from "@/Components/ui/form";
import { FormControl, FormItem, FormLabel, FormMessage } from "@/Components/ui/form";
import { Input } from "@/Components/ui/input";
import {
Select,
@@ -97,7 +92,7 @@ watch(
country: a.country || "",
post_code: a.post_code || a.postal_code || "",
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 || "",
});
return;
@@ -108,7 +103,9 @@ watch(
{ immediate: true }
);
watch(() => props.show, (val) => {
watch(
() => props.show,
(val) => {
if (val && props.edit && props.id) {
const a = props.person.addresses?.find((x) => x.id === props.id);
if (a) {
@@ -117,23 +114,21 @@ watch(() => props.show, (val) => {
country: a.country || "",
post_code: a.post_code || a.postal_code || "",
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 || "",
});
}
} else if (val && !props.edit) {
resetForm();
}
});
}
);
const create = async () => {
processing.value = true;
const { values } = form;
router.post(
route("person.address.create", props.person),
values,
{
router.post(route("person.address.create", props.person), values, {
preserveScroll: true,
onSuccess: () => {
processing.value = false;
@@ -152,8 +147,7 @@ const create = async () => {
onFinish: () => {
processing.value = false;
},
}
);
});
};
const update = async () => {
@@ -223,7 +217,12 @@ const onConfirm = () => {
<FormItem>
<FormLabel>Naslov</FormLabel>
<FormControl>
<Input type="text" placeholder="Naslov" autocomplete="street-address" v-bind="componentField" />
<Input
type="text"
placeholder="Naslov"
autocomplete="street-address"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -233,7 +232,12 @@ const onConfirm = () => {
<FormItem>
<FormLabel>Država</FormLabel>
<FormControl>
<Input type="text" placeholder="Država" autocomplete="country" v-bind="componentField" />
<Input
type="text"
placeholder="Država"
autocomplete="country"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -243,7 +247,12 @@ const onConfirm = () => {
<FormItem>
<FormLabel>Poštna številka</FormLabel>
<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>
<FormMessage />
</FormItem>
@@ -253,7 +262,22 @@ const onConfirm = () => {
<FormItem>
<FormLabel>Mesto</FormLabel>
<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>
<FormMessage />
</FormItem>
@@ -6,12 +6,7 @@ import * as z from "zod";
import { router } from "@inertiajs/vue3";
import UpdateDialog from "../Dialogs/UpdateDialog.vue";
import SectionTitle from "../SectionTitle.vue";
import {
FormControl,
FormItem,
FormLabel,
FormMessage,
} from "@/Components/ui/form";
import { FormControl, FormItem, FormLabel, FormMessage } from "@/Components/ui/form";
import { Input } from "@/Components/ui/input";
import {
Select,
@@ -85,7 +80,7 @@ const hydrate = () => {
country: a.country || "",
post_code: a.post_code || a.postal_code || "",
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 || "",
});
return;
@@ -94,10 +89,17 @@ const hydrate = () => {
resetForm();
};
watch(() => props.id, () => hydrate(), { immediate: true });
watch(() => props.show, (v) => {
watch(
() => props.id,
() => hydrate(),
{ immediate: true }
);
watch(
() => props.show,
(v) => {
if (v) hydrate();
});
}
);
const update = async () => {
processing.value = true;
@@ -157,7 +159,12 @@ const onConfirm = () => {
<FormItem>
<FormLabel>Naslov</FormLabel>
<FormControl>
<Input type="text" placeholder="Naslov" autocomplete="street-address" v-bind="componentField" />
<Input
type="text"
placeholder="Naslov"
autocomplete="street-address"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -167,7 +174,12 @@ const onConfirm = () => {
<FormItem>
<FormLabel>Država</FormLabel>
<FormControl>
<Input type="text" placeholder="Država" autocomplete="country" v-bind="componentField" />
<Input
type="text"
placeholder="Država"
autocomplete="country"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -177,7 +189,12 @@ const onConfirm = () => {
<FormItem>
<FormLabel>Poštna številka</FormLabel>
<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>
<FormMessage />
</FormItem>
@@ -187,7 +204,22 @@ const onConfirm = () => {
<FormItem>
<FormLabel>Mesto</FormLabel>
<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>
<FormMessage />
</FormItem>
@@ -24,9 +24,9 @@ const handleDelete = (id, label) => emit("delete", id, label);
<template>
<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">
<div class="flex items-center justify-between mb-2">
<div class="flex flex-wrap gap-2">
<Card class="p-2 gap-0" v-for="address in person.addresses" :key="address.id">
<div class="flex items-center justify-between">
<div class="flex flex-wrap gap-1">
<span
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>
</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.address}, ${address.post_code} ${address.city}`
: address.address
}}
</p>
<p class="text-sm text-muted-foreground p-1" v-if="address.description">
{{ address.description }}
</p>
</Card>
<button
v-if="edit"
@@ -27,9 +27,9 @@ const handleDelete = (id, label) => emit("delete", id, label);
<template>
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<template v-if="getEmails(person).length">
<Card class="p-2 gap-1" v-for="(email, idx) in getEmails(person)" :key="idx">
<div class="flex items-center justify-between mb-2" v-if="edit">
<div class="flex flex-wrap gap-2">
<Card class="p-2 gap-0" v-for="(email, idx) in getEmails(person)" :key="idx">
<div class="flex items-center justify-between" v-if="edit">
<div class="flex flex-wrap gap-1">
<span
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"
@@ -69,7 +69,7 @@ const handleDelete = (id, label) => emit("delete", id, label);
</div>
</div>
<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 || "-" }}
</p>
<p
@@ -299,7 +299,7 @@ const switchToTab = (tab) => {
<template>
<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
value="person"
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>
<TabsContent value="person" class="py-2">
<PersonInfoPersonTab
:is-client-case="clientCaseUuid ? true : false"
:person="person"
:edit="edit"
:person-edit="personEdit"
@@ -1,14 +1,16 @@
<script setup>
import { UserEditIcon } from "@/Utilities/Icons";
import { Button } from "../ui/button";
import { fmtDateDMY } from "@/Utilities/functions";
const props = defineProps({
person: Object,
isClientCase: { type: Boolean, default: false },
edit: { type: Boolean, default: true },
personEdit: { type: Boolean, default: true },
});
const emit = defineEmits(['edit']);
const emit = defineEmits(["edit"]);
const getMainAddress = (adresses) => {
const addr = adresses.filter((a) => a.type.id === 1)[0] ?? "";
@@ -30,7 +32,7 @@ const getMainPhone = (phones) => {
};
const handleEdit = () => {
emit('edit');
emit("edit");
};
</script>
@@ -44,51 +46,126 @@ const handleEdit = () => {
>
<UserEditIcon size="md" />
<span>Uredi</span>
</button>
</Button>
</div>
<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">
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Nu.</p>
<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">
Primer ref.
</p>
<p class="text-sm font-semibold text-gray-900">{{ person.nu }}</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">Name.</p>
<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">Naziv</p>
<p class="text-sm font-semibold text-gray-900">
{{ person.full_name }}
</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">Tax NU.</p>
<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">
Davčna
</p>
<p class="text-sm font-semibold text-gray-900">
{{ person.tax_number }}
</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">Social security NU.</p>
<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">Emšo</p>
<p class="text-sm font-semibold text-gray-900">
{{ person.social_security_number }}
</p>
</div>
</div>
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 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">Address</p>
<div
v-if="isClientCase"
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">
{{ 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">Phone</p>
<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 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">Description</p>
<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">
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">
{{ person.description }}
</p>
</div>
</div>
</template>
@@ -8,7 +8,13 @@ import {
} from "@/Components/ui/dropdown-menu";
import { Card } from "@/Components/ui/card";
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";
const props = defineProps({
@@ -30,9 +36,9 @@ const handleSms = (phone) => emit("sms", phone);
<template>
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<template v-if="getPhones(person).length">
<Card class="p-2 gap-1" v-for="phone in getPhones(person)" :key="phone.id">
<div class="flex items-center justify-between mb-2">
<div class="flex flex-wrap gap-2">
<Card class="p-2 gap-0" v-for="phone in getPhones(person)" :key="phone.id">
<div class="flex items-center justify-between">
<div class="flex flex-wrap gap-1">
<span
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>
</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 }}
<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>
</Card>
</template>
@@ -1,5 +1,6 @@
<script setup>
import { ref, watch, computed } from "vue";
import axios from "axios";
import {
Dialog,
DialogContent,
@@ -301,28 +302,14 @@ const updateSmsFromSelection = async () => {
const url = route("clientCase.sms.preview", {
client_case: props.clientCaseUuid,
});
const res = await fetch(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({
const { data } = await axios.post(url, {
template_id: form.values.template_id,
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() !== "") {
form.setFieldValue("message", data.content);
return;
}
}
} catch (e) {
// ignore and fallback
}
@@ -1,73 +1,72 @@
<script setup>
import UpdateDialog from '@/Components/Dialogs/UpdateDialog.vue';
import SectionTitle from '@/Components/SectionTitle.vue';
import UpdateDialog from "@/Components/Dialogs/UpdateDialog.vue";
import SectionTitle from "@/Components/SectionTitle.vue";
import { useForm, Field as FormField } from "vee-validate";
import { toTypedSchema } from "@vee-validate/zod";
import * as z from "zod";
import { router } from '@inertiajs/vue3';
import { ref } from 'vue';
import {
FormControl,
FormItem,
FormLabel,
FormMessage,
} from "@/Components/ui/form";
import { router } from "@inertiajs/vue3";
import { ref } from "vue";
import { FormControl, FormItem, FormLabel, FormMessage } from "@/Components/ui/form";
import { Input } from "@/Components/ui/input";
import { Textarea } from "@/Components/ui/textarea";
import DatePicker from "../DatePicker.vue";
const props = defineProps({
show: {
type: Boolean,
default: false
default: false,
},
person: Object
person: Object,
});
const processingUpdate = ref(false);
const emit = defineEmits(['close']);
const emit = defineEmits(["close"]);
const formSchema = toTypedSchema(
z.object({
full_name: z.string().min(1, "Naziv je obvezen."),
tax_number: z.string().optional(),
social_security_number: z.string().optional(),
birthday: z.string().optional(),
description: z.string().optional(),
employer: z.string().optional(),
})
);
const form = useForm({
validationSchema: formSchema,
initialValues: {
full_name: props.person?.full_name || '',
tax_number: props.person?.tax_number || '',
social_security_number: props.person?.social_security_number || '',
description: props.person?.description || ''
full_name: props.person?.full_name || "",
tax_number: props.person?.tax_number || "",
social_security_number: props.person?.social_security_number || "",
birthday: props.person?.birthday || "",
description: props.person?.description || "",
employer: props.person?.employer || "",
},
});
const close = () => {
emit('close');
emit("close");
setTimeout(() => {
form.resetForm({
values: {
full_name: props.person?.full_name || '',
tax_number: props.person?.tax_number || '',
social_security_number: props.person?.social_security_number || '',
description: props.person?.description || ''
}
full_name: props.person?.full_name || "",
tax_number: props.person?.tax_number || "",
social_security_number: props.person?.social_security_number || "",
birthday: props.person?.birthday || "",
description: props.person?.description || "",
employer: props.person?.employer || "",
},
});
}, 500);
}
};
const updatePerson = async () => {
processingUpdate.value = true;
const { values } = form;
router.put(
route('person.update', props.person),
values,
{
router.put(route("person.update", props.person), values, {
preserveScroll: true,
onSuccess: () => {
processingUpdate.value = false;
@@ -86,9 +85,8 @@ const updatePerson = async () => {
onFinish: () => {
processingUpdate.value = false;
},
}
);
}
});
};
const onSubmit = form.handleSubmit(() => {
updatePerson();
@@ -96,7 +94,7 @@ const onSubmit = form.handleSubmit(() => {
const onConfirm = () => {
onSubmit();
}
};
</script>
<template>
<UpdateDialog
@@ -109,9 +107,7 @@ const onConfirm = () => {
>
<form @submit.prevent="onSubmit">
<SectionTitle class="border-b mb-4">
<template #title>
Oseba
</template>
<template #title> Oseba </template>
</SectionTitle>
<div class="space-y-4">
@@ -163,15 +159,42 @@ const onConfirm = () => {
</FormItem>
</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">
<FormItem>
<FormLabel>Opis</FormLabel>
<FormControl>
<Textarea
id="cdescription"
placeholder="Opis"
v-bind="componentField"
/>
<Textarea id="cdescription" placeholder="Opis" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
@@ -6,12 +6,7 @@ import * as z from "zod";
import { router } from "@inertiajs/vue3";
import CreateDialog from "../Dialogs/CreateDialog.vue";
import SectionTitle from "../SectionTitle.vue";
import {
FormControl,
FormItem,
FormLabel,
FormMessage,
} from "@/Components/ui/form";
import { FormControl, FormItem, FormLabel, FormMessage } from "@/Components/ui/form";
import { Input } from "@/Components/ui/input";
import {
Select,
@@ -101,10 +96,7 @@ const create = async () => {
processing.value = true;
const { values } = form;
router.post(
route("person.phone.create", props.person),
values,
{
router.post(route("person.phone.create", props.person), values, {
preserveScroll: true,
onSuccess: () => {
close();
@@ -122,8 +114,7 @@ const create = async () => {
onFinish: () => {
processing.value = false;
},
}
);
});
};
const onSubmit = form.handleSubmit(() => {
@@ -150,7 +141,12 @@ const onSubmit = form.handleSubmit(() => {
<FormItem>
<FormLabel>Številka</FormLabel>
<FormControl>
<Input type="text" placeholder="Številka telefona" autocomplete="tel" v-bind="componentField" />
<Input
type="text"
placeholder="Številka telefona"
autocomplete="tel"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -166,7 +162,11 @@ const onSubmit = form.handleSubmit(() => {
</SelectTrigger>
</FormControl>
<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 }}
</SelectItem>
</SelectContent>
@@ -204,7 +204,11 @@ const onSubmit = form.handleSubmit(() => {
</SelectTrigger>
</FormControl>
<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 }}
</SelectItem>
</SelectContent>
@@ -213,6 +217,16 @@ const onSubmit = form.handleSubmit(() => {
</FormItem>
</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">
<FormItem class="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
@@ -6,12 +6,7 @@ import * as z from "zod";
import { router } from "@inertiajs/vue3";
import UpdateDialog from "../Dialogs/UpdateDialog.vue";
import SectionTitle from "../SectionTitle.vue";
import {
FormControl,
FormItem,
FormLabel,
FormMessage,
} from "@/Components/ui/form";
import { FormControl, FormItem, FormLabel, FormMessage } from "@/Components/ui/form";
import { Input } from "@/Components/ui/input";
import {
Select,
@@ -108,7 +103,7 @@ function hydrateFromProps() {
form.setValues({
nu: p.nu || "",
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 || "",
validated: !!p.validated,
phone_type: p.phone_type ?? null,
@@ -119,8 +114,17 @@ function hydrateFromProps() {
resetForm();
}
watch(() => props.id, () => hydrateFromProps(), { immediate: true });
watch(() => props.show, (val) => { if (val) hydrateFromProps(); });
watch(
() => props.id,
() => hydrateFromProps(),
{ immediate: true }
);
watch(
() => props.show,
(val) => {
if (val) hydrateFromProps();
}
);
const update = async () => {
processing.value = true;
@@ -175,7 +179,12 @@ const onSubmit = form.handleSubmit(() => {
<FormItem>
<FormLabel>Številka</FormLabel>
<FormControl>
<Input type="text" placeholder="Številka telefona" autocomplete="tel" v-bind="componentField" />
<Input
type="text"
placeholder="Številka telefona"
autocomplete="tel"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -191,7 +200,11 @@ const onSubmit = form.handleSubmit(() => {
</SelectTrigger>
</FormControl>
<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 }}
</SelectItem>
</SelectContent>
@@ -229,7 +242,11 @@ const onSubmit = form.handleSubmit(() => {
</SelectTrigger>
</FormControl>
<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 }}
</SelectItem>
</SelectContent>
@@ -238,6 +255,16 @@ const onSubmit = form.handleSubmit(() => {
</FormItem>
</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">
<FormItem class="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
@@ -84,8 +84,8 @@ const summaryText = computed(() => {
const found = props.items.find((i) => String(i.value) === String(v));
return found?.label || v;
});
if (labels.length <= 3) return labels.join(', ');
const firstThree = labels.slice(0, 3).join(', ');
if (labels.length <= 3) return labels.join(", ");
const firstThree = labels.slice(0, 3).join(", ");
const remaining = labels.length - 3;
return `${firstThree}, … +${remaining}`; // show ellipsis and remaining count
});
@@ -154,7 +154,7 @@ const summaryText = computed(() => {
:variant="chipVariant"
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 }}
</span>
<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 },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
disableOutsidePointerEvents: { type: Boolean, required: false },
class: { type: null, required: false },
});
const emits = defineEmits([
+4 -6
View File
@@ -251,19 +251,17 @@ function isActive(patterns) {
: 'sticky top-0 h-screen overflow-y-auto',
]"
>
<div
class="h-16 px-4 flex items-center justify-between border-b border-gray-200 bg-white"
>
<div class="h-16 px-4 flex items-center border-b border-sidebar-border bg-sidebar">
<Link
: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 />
<span
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>
</Link>
</div>
+4 -17
View File
@@ -10,19 +10,6 @@ import GlobalSearch from "./Partials/GlobalSearch.vue";
import NotificationsBell from "./Partials/NotificationsBell.vue";
import ToastContainer from "@/Components/Toast/ToastContainer.vue";
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 { SearchIcon } from "lucide-vue-next";
import { ChevronDownIcon } from "lucide-vue-next";
@@ -310,18 +297,18 @@ function isActive(patterns) {
]"
>
<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
: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 />
<span
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>
</Link>
</div>
+3 -3
View File
@@ -149,14 +149,14 @@ const closeSearch = () => (searchOpen.value = false);
>
<Link
: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 />
<span
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>
</Link>
</div>
+80 -104
View File
@@ -1,8 +1,18 @@
<script setup>
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 { debounce } from "lodash";
import { SearchIcon } from "@/Utilities/Icons";
import { SearchIcon, XIcon } from "lucide-vue-next";
import { onMounted, onUnmounted, ref, watch } from "vue";
import { Link } from "@inertiajs/vue3";
@@ -55,139 +65,114 @@ onMounted(() => window.addEventListener("keydown", onKeydown));
onUnmounted(() => window.removeEventListener("keydown", onKeydown));
</script>
<template>
<teleport to="body">
<transition name="fade">
<div v-if="isOpen" class="fixed inset-0 z-50">
<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"
>
<Dialog :open="isOpen" @update:open="(v) => (isOpen = v)">
<DialogContent class="max-w-3xl p-0 gap-0 [&>button]:hidden">
<div class="p-4 border-b" ref="inputWrap">
<div class="relative">
<div class="relative">
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500">
<SearchIcon />
</div>
<SearchIcon
class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground"
/>
<Input
v-model="query"
placeholder="Išči po naročnikih ali primerih (Ctrl+K za zapiranje)"
class="w-full pl-10 pr-16 rounded-xl"
placeholder="Išči po naročnikih ali primerih (ESC za zapiranje)"
class="w-full pl-10 pr-16"
/>
<button
v-if="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>
</div>
</div>
</div>
<div
class="max-h-[65vh] overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-300"
>
<div class="max-h-[65vh] overflow-y-auto">
<div
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 class="text-xs">
Namig: uporabi
<kbd
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
>
Namig: uporabi <Badge variant="secondary" class="font-mono">Ctrl</Badge> +
<Badge variant="secondary" class="font-mono">K</Badge>
</p>
</div>
<div v-else class="divide-y divide-slate-200/70">
<div v-if="result.clients.length" class="py-3">
<div v-else class="space-y-4 p-4">
<!-- Clients Results -->
<div v-if="result.clients.length">
<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
class="rounded bg-slate-100 text-slate-600 px-2 py-0.5 text-[10px]"
>{{ result.clients.length }}</span
>
<Badge variant="secondary">{{ result.clients.length }}</Badge>
</div>
<ul role="list" class="px-2 space-y-1">
<li v-for="client in result.clients" :key="client.client_uuid">
<div class="space-y-1">
<Link
v-for="client in result.clients"
:key="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"
>
<span
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"
>C</span
>
<span
class="text-slate-700 group-hover:text-slate-900"
>{{ client.full_name }}</span
<Badge
variant="outline"
class="shrink-0 w-6 h-6 flex items-center justify-center"
>C</Badge
>
<span class="font-medium">{{ client.full_name }}</span>
</Link>
</li>
</ul>
</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
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
class="rounded bg-slate-100 text-slate-600 px-2 py-0.5 text-[10px]"
>{{ result.client_cases.length }}</span
>
<Badge variant="secondary">{{ result.client_cases.length }}</Badge>
</div>
<ul role="list" class="px-2 space-y-1">
<li
<div class="space-y-2">
<Card
v-for="clientcase in result.client_cases"
: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
:href="
route('clientCase.show', {
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"
>
{{ clientcase.full_name }}
</Link>
<template v-if="clientcase.contract_reference">
<span
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"
<div
v-if="clientcase.client_full_name"
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 }}
</span>
</template>
</Badge>
</div>
<div
v-if="
clientcase.contract_segments &&
clientcase.contract_segments.length
clientcase.contract_segments && clientcase.contract_segments.length
"
class="flex flex-wrap gap-1 mt-1"
class="flex flex-wrap gap-1"
>
<Link
v-for="seg in clientcase.contract_segments"
@@ -199,17 +184,18 @@ onUnmounted(() => window.removeEventListener("keydown", onKeydown));
'?segment=' +
(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"
>
<Badge variant="secondary" class="text-xs uppercase">
{{ seg.name || seg }}
</Badge>
</Link>
</div>
<div
v-else-if="
clientcase.case_segments && clientcase.case_segments.length
"
class="flex flex-wrap gap-1 mt-1"
class="flex flex-wrap gap-1"
>
<Link
v-for="seg in clientcase.case_segments"
@@ -221,37 +207,27 @@ onUnmounted(() => window.removeEventListener("keydown", onKeydown));
'?segment=' +
(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"
>
<Badge variant="outline" class="text-xs uppercase">
{{ seg.name }}
</Badge>
</Link>
</div>
</li>
</ul>
</CardContent>
</Card>
</div>
</div>
<!-- No Results -->
<div
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.
</div>
</div>
</div>
</div>
</div>
</div>
</transition>
</teleport>
</DialogContent>
</Dialog>
</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>
import { computed, onMounted, ref, watch } from "vue";
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 { Badge } from "@/Components/ui/badge";
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 due = computed(
@@ -83,12 +83,8 @@ function markRead(item) {
</script>
<template>
<Dropdown
align="right"
width="72"
:content-classes="['p-0', 'bg-white', 'max-h-96', 'overflow-hidden']"
>
<template #trigger>
<Popover>
<PopoverTrigger as-child>
<Button variant="ghost" size="default" class="relative">
<BellIcon />
@@ -100,32 +96,30 @@ function markRead(item) {
{{ count }}
</Badge>
</Button>
</template>
</PopoverTrigger>
<template #content>
<div
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>Zapadejo danes</span>
<PopoverContent align="end" class="w-96 p-0">
<div class="px-4 py-3 flex items-center justify-between border-b">
<span class="text-sm font-medium">Zapadejo danes</span>
<Link
:href="route('notifications.unread')"
class="text-indigo-600 hover:text-indigo-700"
class="text-sm text-primary hover:underline"
>Vsa obvestila</Link
>
</div>
<!-- Scrollable content area with max height -->
<div class="max-h-80 overflow-auto">
<div v-if="!count" class="px-3 py-3 text-sm text-gray-500">
Ni zapadlih aktivnosti danes.
<ScrollArea class="h-72">
<div v-if="!count" class="px-4 py-8 text-center">
<p class="text-sm text-muted-foreground">Ni zapadlih aktivnosti danes.</p>
</div>
<ul v-else class="divide-y">
<li
<div v-else class="divide-y">
<div
v-for="item in items"
: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="font-medium text-gray-800 truncate">
<div class="flex-1 min-w-0 space-y-1">
<div class="font-medium truncate">
<template v-if="item.contract?.uuid">
Pogodba:
<Link
@@ -135,7 +129,7 @@ function markRead(item) {
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 || "—" }}
</Link>
@@ -148,7 +142,7 @@ function markRead(item) {
:href="
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 || "—" }}
</Link>
@@ -157,37 +151,38 @@ function markRead(item) {
</div>
<!-- Partner / Client full name (use contract.client when available; fallback to case.client) -->
<div
class="text-xs text-gray-500 truncate"
class="text-xs text-muted-foreground truncate"
v-if="item.contract?.client?.person?.full_name"
>
Partner: {{ item.contract.client.person.full_name }}
</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"
>
Partner: {{ item.client_case.client.person.full_name }}
</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) }}
</div>
</div>
<div class="flex flex-col items-end gap-1">
<div class="text-xs text-gray-500 whitespace-nowrap">
<div class="flex flex-col items-end gap-1.5 shrink-0">
<div class="text-xs text-muted-foreground whitespace-nowrap">
{{ fmtDate(item.due_date) }}
</div>
<button
type="button"
class="text-[11px] text-gray-400 hover:text-gray-600"
<Button
variant="ghost"
size="sm"
class="h-6 px-2 text-xs"
@click.stop="markRead(item)"
title="Skrij obvestilo"
>
Skrij
</button>
</Button>
</div>
</li>
</ul>
</div>
</template>
</Dropdown>
</div>
</ScrollArea>
</PopoverContent>
</Popover>
</template>
+70 -60
View File
@@ -2,6 +2,7 @@
import AdminLayout from "@/Layouts/AdminLayout.vue";
import { Link, router, useForm } from "@inertiajs/vue3";
import { ref, computed, nextTick } from "vue";
import axios from "axios";
import {
Card,
CardContent,
@@ -39,6 +40,9 @@ import {
BadgeCheckIcon,
} from "lucide-vue-next";
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({
profiles: { type: Array, default: () => [] },
@@ -123,13 +127,19 @@ const contracts = ref({
const segmentId = ref(null);
const search = ref("");
const clientId = ref(null);
const startDateFrom = ref("");
const startDateTo = ref("");
const promiseDateFrom = ref("");
const promiseDateTo = ref("");
const startDateRange = ref({ start: null, end: null });
const promiseDateRange = ref({ start: null, end: null });
const onlyMobile = ref(false);
const onlyValidated = 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 perPage = ref(25);
@@ -153,6 +163,11 @@ const contractColumns = [
accessorFn: (row) => row.selected_phone?.number || "—",
header: "Izbrana številka",
},
{
id: "segment",
accessorFn: (row) => upperFirst(row.segment?.name) || "—",
header: "Segment",
},
{ 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 (search.value) params.append("q", search.value);
if (clientId.value) params.append("client_id", clientId.value);
if (startDateFrom.value) params.append("start_date_from", startDateFrom.value);
if (startDateTo.value) params.append("start_date_to", startDateTo.value);
if (promiseDateFrom.value) params.append("promise_date_from", promiseDateFrom.value);
if (promiseDateTo.value) params.append("promise_date_to", promiseDateTo.value);
if (startDateRange.value?.start)
params.append("start_date_from", startDateRange.value.start);
if (startDateRange.value?.end)
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 (onlyValidated.value) params.append("only_validated", "1");
params.append("per_page", perPage.value);
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" },
});
const json = await res.json();
// Wait for next tick before updating to avoid Vue reconciliation issues
await nextTick();
@@ -238,10 +256,13 @@ function goToPage(page) {
if (segmentId.value) params.append("segment_id", segmentId.value);
if (search.value) params.append("q", search.value);
if (clientId.value) params.append("client_id", clientId.value);
if (startDateFrom.value) params.append("start_date_from", startDateFrom.value);
if (startDateTo.value) params.append("start_date_to", startDateTo.value);
if (promiseDateFrom.value) params.append("promise_date_from", promiseDateFrom.value);
if (promiseDateTo.value) params.append("promise_date_to", promiseDateTo.value);
if (startDateRange.value?.start)
params.append("start_date_from", startDateRange.value.start);
if (startDateRange.value?.end) 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 (onlyValidated.value) params.append("only_validated", "1");
params.append("per_page", perPage.value);
@@ -255,10 +276,8 @@ function resetFilters() {
segmentId.value = null;
clientId.value = null;
search.value = "";
startDateFrom.value = "";
startDateTo.value = "";
promiseDateFrom.value = "";
promiseDateTo.value = "";
startDateRange.value = { start: null, end: null };
promiseDateRange.value = { start: null, end: null };
onlyMobile.value = false;
onlyValidated.value = false;
contracts.value = {
@@ -448,9 +467,10 @@ const numbersCount = computed(() => {
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Checkbox
:checked="form.delivery_report"
@update:checked="(val) => (form.delivery_report = val)"
:model-value="form.delivery_report"
@update:model-value="(val) => (form.delivery_report = val)"
id="delivery-report"
:disabled="true"
/>
<Label for="delivery-report" class="cursor-pointer text-sm">
Zahtevaj delivery report
@@ -553,17 +573,15 @@ const numbersCount = computed(() => {
</div>
<div class="space-y-2">
<Label>Stranka</Label>
<Select v-model="clientId" @update:model-value="loadContracts()">
<SelectTrigger>
<SelectValue placeholder="Vse stranke" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null">Vse stranke</SelectItem>
<SelectItem v-for="c in clients" :key="c.id" :value="c.id">
{{ c.name }}
</SelectItem>
</SelectContent>
</Select>
<AppCombobox
v-model="clientId"
:items="clientItems"
placeholder="Vse stranke"
search-placeholder="Išči stranko..."
empty-text="Stranka ni najdena."
button-class="w-full"
@update:model-value="loadContracts()"
/>
</div>
<div class="space-y-2">
<Label>Iskanje po referenci</Label>
@@ -586,29 +604,21 @@ const numbersCount = computed(() => {
<div class="grid gap-4 md:grid-cols-2">
<div class="space-y-3">
<p class="text-sm text-muted-foreground">Datum začetka pogodbe</p>
<div class="grid grid-cols-2 gap-2">
<div class="space-y-2">
<Label class="text-xs">Od</Label>
<Input v-model="startDateFrom" type="date" />
</div>
<div class="space-y-2">
<Label class="text-xs">Do</Label>
<Input v-model="startDateTo" type="date" />
</div>
</div>
<AppRangeDatePicker
v-model="startDateRange"
placeholder="Izberi obdobje"
button-class="w-full"
:number-of-months="1"
/>
</div>
<div class="space-y-3">
<p class="text-sm text-muted-foreground">Datum obljube plačila</p>
<div class="grid grid-cols-2 gap-2">
<div class="space-y-2">
<Label class="text-xs">Od</Label>
<Input v-model="promiseDateFrom" type="date" />
</div>
<div class="space-y-2">
<Label class="text-xs">Do</Label>
<Input v-model="promiseDateTo" type="date" />
</div>
</div>
<AppRangeDatePicker
v-model="promiseDateRange"
placeholder="Izberi obdobje"
button-class="w-full"
:number-of-months="1"
/>
</div>
</div>
</div>
@@ -621,8 +631,8 @@ const numbersCount = computed(() => {
<div class="flex flex-wrap gap-4">
<div class="flex items-center gap-2">
<Checkbox
:checked="onlyMobile"
@update:checked="
:model-value="onlyMobile"
@update:model-value="
(val) => {
onlyMobile = val;
}
@@ -635,8 +645,8 @@ const numbersCount = computed(() => {
</div>
<div class="flex items-center gap-2">
<Checkbox
:checked="onlyValidated"
@update:checked="
:model-value="onlyValidated"
@update:model-value="
(val) => {
onlyValidated = val;
}
@@ -653,11 +663,11 @@ const numbersCount = computed(() => {
<!-- Action buttons -->
<div class="flex items-center gap-2">
<Button @click="loadContracts()">
<SearchIcon class="h-4 w-4 mr-2" />
<SearchIcon class="h-4 w-4" />
Išči pogodbe
</Button>
<Button @click="resetFilters" variant="outline">
<XCircleIcon class="h-4 w-4 mr-2" />
<XCircleIcon class="h-4 w-4" />
Počisti filtre
</Button>
</div>
@@ -669,7 +679,7 @@ const numbersCount = computed(() => {
<CardHeader>
<div class="flex items-center justify-between">
<div>
<CardTitle>Rezultati iskanja</CardTitle>
<CardTitle>Rezultati iskanja (do 500 zapisov)</CardTitle>
<CardDescription v-if="contracts.meta.total > 0">
Najdeno {{ contracts.meta.total }}
{{
@@ -689,7 +699,7 @@ const numbersCount = computed(() => {
variant="secondary"
class="text-sm"
>
<CheckCircle2Icon class="h-3 w-3 mr-1" />
<CheckCircle2Icon class="h-3 w-3" />
Izbrano: {{ selectedContractIds.size }}
</Badge>
<Button
@@ -702,7 +712,7 @@ const numbersCount = computed(() => {
@click="submitCreateFromContracts"
:disabled="selectedContractIds.size === 0 || creatingFromContracts"
>
<SaveIcon class="h-4 w-4 mr-2" />
<SaveIcon class="h-4 w-4" />
Ustvari paket ({{ selectedContractIds.size }}
{{
selectedContractIds.size === 1
+9 -9
View File
@@ -18,6 +18,7 @@ import {
import DataTableNew2 from "@/Components/DataTable/DataTableNew2.vue";
import { PackageIcon, PlusIcon, Trash2Icon, EyeIcon } from "lucide-vue-next";
import AppCard from "@/Components/app/ui/card/AppCard.vue";
import { fmtDateTime } from "@/Utilities/functions";
const props = defineProps({
packages: { type: Object, required: true },
@@ -29,7 +30,6 @@ const showDeleteDialog = ref(false);
const columns = [
{ accessorKey: "id", header: "ID" },
{ accessorKey: "uuid", header: "UUID" },
{ accessorKey: "name", header: "Ime" },
{ accessorKey: "type", header: "Tip" },
{ accessorKey: "status", header: "Status" },
@@ -84,7 +84,7 @@ function confirmDelete() {
</div>
<Link :href="route('admin.packages.create')">
<Button>
<PlusIcon class="h-4 w-4 mr-2" />
<PlusIcon class="h-4 w-4" />
Nov paket
</Button>
</Link>
@@ -111,10 +111,6 @@ function confirmDelete() {
:meta="packages"
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 }">
<span class="text-sm">{{ row.name ?? "—" }}</span>
</template>
@@ -128,7 +124,9 @@ function confirmDelete() {
</template>
<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 #cell-actions="{ row }">
@@ -157,8 +155,10 @@ function confirmDelete() {
<AlertDialogTitle>Izbriši paket?</AlertDialogTitle>
<AlertDialogDescription>
Ali ste prepričani, da želite izbrisati paket
<strong v-if="packageToDelete">#{{ packageToDelete.id }} - {{ packageToDelete.name || 'Brez imena' }}</strong>?
Tega dejanja ni mogoče razveljaviti.
<strong v-if="packageToDelete"
>#{{ packageToDelete.id }} -
{{ packageToDelete.name || "Brez imena" }}</strong
>? Tega dejanja ni mogoče razveljaviti.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
@@ -120,7 +120,8 @@ const store = async () => {
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
: null;
@@ -175,9 +176,9 @@ const autoMailRequiresContract = computed(() => {
});
const contractItems = computed(() => {
return pageContracts.value.map(c => ({
return pageContracts.value.map((c) => ({
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;
// 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;
}
@@ -202,7 +206,10 @@ const autoMailDisabledHint = computed(() => {
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.";
}
@@ -373,8 +380,12 @@ watch(
:clearable="true"
:show-selected-chips="true"
/>
<p 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
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>
</div>
@@ -383,7 +394,7 @@ watch(
<Textarea
id="activityNote"
v-model="form.note"
class="block w-full"
class="block w-full max-h-72"
placeholder="Opomba"
/>
</div>
@@ -412,10 +423,7 @@ watch(
<div v-if="showSendAutoMail()" class="space-y-2">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-2">
<Switch
v-model="form.send_auto_mail"
:disabled="autoMailDisabled"
/>
<Switch v-model="form.send_auto_mail" :disabled="autoMailDisabled" />
<Label class="cursor-pointer">Send auto email</Label>
</div>
</div>
@@ -423,7 +431,14 @@ watch(
{{ autoMailDisabledHint }}
</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">
<Switch v-model="form.attach_documents" />
<span class="text-sm">Dodaj priponke iz izbrane pogodbe</span>
@@ -445,21 +460,28 @@ watch(
<div
v-for="doc in availableContractDocs"
: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
:model-value="form.attachment_document_ids.includes(doc.id)"
@update:model-value="(checked) => {
@update:model-value="
(checked) => {
if (checked) {
if (!form.attachment_document_ids.includes(doc.id)) {
form.attachment_document_ids.push(doc.id);
}
} 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"
>({{ doc.extension?.toUpperCase() || "" }},
{{ (doc.size / 1024 / 1024).toFixed(2) }} MB)</span
@@ -467,6 +489,7 @@ watch(
</div>
</div>
</div>
</div>
</template>
<div
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 PaymentDialog from "./PaymentDialog.vue";
import ViewPaymentsDialog from "./ViewPaymentsDialog.vue";
import ContractMetaEditDialog from "./ContractMetaEditDialog.vue";
import CreateDialog from "@/Components/Dialogs/CreateDialog.vue";
import ConfirmationDialog from "@/Components/Dialogs/ConfirmationDialog.vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
@@ -33,6 +34,16 @@ import {
} from "@fortawesome/free-solid-svg-icons";
import EmptyState from "@/Components/EmptyState.vue";
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({
client: { type: Object, default: null },
@@ -433,6 +444,19 @@ const closePaymentsDialog = () => {
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
const columns = computed(() => [
{ key: "reference", label: "Ref.", sortable: false, align: "center" },
@@ -638,6 +662,19 @@ const availableSegmentsCount = computed(() => {
<div class="text-gray-500">Ni meta podatkov.</div>
</template>
</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>
</DropdownMenu>
@@ -901,6 +938,13 @@ const availableSegmentsCount = computed(() => {
:edit="edit"
/>
<ContractMetaEditDialog
:show="showMetaEditDialog"
:client_case="client_case"
:contract="selectedContract"
@close="closeMetaEditDialog"
/>
<!-- Generate Document Dialog -->
<CreateDialog
:show="showGenerateDialog"
@@ -913,18 +957,18 @@ const availableSegmentsCount = computed(() => {
@confirm="submitGenerate"
>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700">Predloga</label>
<select
v-model="selectedTemplateSlug"
@change="onTemplateChange"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
>
<option :value="null">Izberi predlogo...</option>
<option v-for="t in templates" :key="t.slug" :value="t.slug">
<div class="space-y-2">
<Label>Predloga</Label>
<Select v-model="selectedTemplateSlug" @update:model-value="onTemplateChange">
<SelectTrigger>
<SelectValue placeholder="Izberi predlogo..." />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="t in templates" :key="t.slug" :value="t.slug">
{{ t.name }} (v{{ t.version }})
</option>
</select>
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- Custom inputs -->
@@ -932,14 +976,30 @@ const availableSegmentsCount = computed(() => {
<div class="border-t border-gray-200 pt-4">
<h3 class="text-sm font-medium text-gray-700 mb-3">Prilagojene vrednosti</h3>
<div class="space-y-3">
<div v-for="token in customTokenList" :key="token">
<label class="block text-sm font-medium text-gray-700">
<div v-for="token in customTokenList" :key="token" class="space-y-2">
<Label>
{{ token.replace(/^custom\./, "") }}
</label>
<input
</Label>
<Textarea
v-if="templateCustomTypes[token.replace(/^custom\./, '')] === 'text'"
v-model="customInputs[token.replace(/^custom\./, '')]"
type="text"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
rows="3"
/>
<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>
@@ -948,26 +1008,30 @@ const availableSegmentsCount = computed(() => {
<!-- Address overrides -->
<div class="border-t border-gray-200 pt-4 space-y-3">
<h3 class="text-sm font-medium text-gray-700">Naslovi</h3>
<div>
<label class="block text-sm font-medium text-gray-700">Naslov stranke</label>
<select
v-model="clientAddressSource"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
>
<option value="client">Stranka</option>
<option value="case_person">Oseba primera</option>
</select>
<h3 class="text-sm font-medium text-gray-700 mb-2">Naslovi</h3>
<div class="space-y-2">
<Label>Naslov stranke</Label>
<Select v-model="clientAddressSource">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="client">Stranka</SelectItem>
<SelectItem value="case_person">Oseba primera</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Naslov osebe</label>
<select
v-model="personAddressSource"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
>
<option value="case_person">Oseba primera</option>
<option value="client">Stranka</option>
</select>
<div class="space-y-2">
<Label>Naslov osebe</Label>
<Select v-model="personAddressSource">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="case_person">Oseba primera</SelectItem>
<SelectItem value="client">Stranka</SelectItem>
</SelectContent>
</Select>
</div>
</div>
+12 -10
View File
@@ -107,7 +107,7 @@ const onDocSaved = () => {
router.reload({ only: ["documents"] });
};
const viewer = ref({ open: false, src: "", title: "" });
const viewer = ref({ open: false, src: "", title: "", mimeType: "", filename: "" });
const openViewer = (doc) => {
const kind = classifyDocument(doc);
const isContractDoc = (doc?.documentable_type || "").toLowerCase().includes("contract");
@@ -122,7 +122,13 @@ const openViewer = (doc) => {
client_case: props.client_case.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 {
const url =
isContractDoc && doc.contract_uuid
@@ -140,6 +146,8 @@ const openViewer = (doc) => {
const closeViewer = () => {
viewer.value.open = false;
viewer.value.src = "";
viewer.value.mimeType = "";
viewer.value.filename = "";
};
const clientDetails = ref(false);
@@ -210,14 +218,6 @@ const closeDrawer = () => {
drawerAddActivity.value = false;
};
const showClientDetails = () => {
clientDetails.value = false;
};
const hideClietnDetails = () => {
clientDetails.value = true;
};
// Attach segment to case
const showAttachSegment = ref(false);
const openAttachSegment = () => {
@@ -490,6 +490,8 @@ const submitAttachSegment = () => {
:show="viewer.open"
:src="viewer.src"
:title="viewer.title"
:mime-type="viewer.mimeType"
:filename="viewer.filename"
@close="closeViewer"
/>
</AppLayout>
+91 -5
View File
@@ -24,18 +24,31 @@ import DateRangePicker from "@/Components/DateRangePicker.vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { ButtonGroup } from "@/Components/ui/button-group";
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 { Badge } from "@/Components/ui/badge";
import { hasPermission } from "@/Services/permissions";
import InputLabel from "@/Components/InputLabel.vue";
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({
client: Object,
contracts: Object,
filters: Object,
segments: Object,
segments: Array,
types: Object,
});
@@ -59,10 +72,20 @@ const selectedSegments = ref(
: []
);
const filterPopoverOpen = ref(false);
const selectedContracts = ref([]);
const changeSegmentDialogOpen = ref(false);
const contractTable = ref(null);
const exportDialogOpen = ref(false);
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 isExporting = ref(false);
@@ -85,6 +108,12 @@ const allColumnsSelected = computed(
const exportDisabled = computed(
() => exportColumns.value.length === 0 || isExporting.value
);
const segmentSelectItems = computed(() =>
props.segments.map((val, i) => ({
label: val.name,
value: val.id,
}))
);
function applyDateFilter() {
filterPopoverOpen.value = false;
@@ -288,6 +317,24 @@ function extractFilenameFromHeaders(headers) {
const asciiMatch = disposition.match(/filename="?([^";]+)"?/i);
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>
<template>
@@ -357,6 +404,7 @@ function extractFilenameFromHeaders(headers) {
</Link>
</div>
<DataTable
ref="contractTable"
:columns="[
{ key: 'reference', label: 'Referenca', sortable: false },
{ key: 'customer', label: 'Stranka', sortable: false },
@@ -380,11 +428,13 @@ function extractFilenameFromHeaders(headers) {
row-key="uuid"
:only-props="['contracts']"
:page-size-options="[10, 15, 25, 50, 100]"
:enable-row-selection="true"
@selection:change="handleSelectionChange"
page-param-name="contracts_page"
per-page-param-name="contracts_per_page"
:show-toolbar="true"
>
<template #toolbar-filters>
<template #toolbar-filters="{ table }">
<div class="flex flex-wrap items-center gap-2">
<AppPopover
v-model:open="filterPopoverOpen"
@@ -481,6 +531,32 @@ function extractFilenameFromHeaders(headers) {
<FileDown class="h-4 w-4" />
Izvozi v Excel
</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>
</template>
<template #cell-reference="{ row }">
@@ -519,7 +595,7 @@ function extractFilenameFromHeaders(headers) {
</div>
</div>
</div>
<!-- Excel export dialog -->
<DialogModal :show="exportDialogOpen" max-width="3xl" @close="closeExportDialog">
<template #title>
<div class="space-y-1">
@@ -626,5 +702,15 @@ function extractFilenameFromHeaders(headers) {
</div>
</template>
</DialogModal>
<!-- Change segment selected contracts dialog -->
<FormChangeSegment
:show="changeSegmentDialogOpen"
@close="changeSegmentDialogOpen = false"
:segments="segmentSelectItems"
:contracts="selectedContracts"
:clear-selected-rows="clearContractTableSelected"
/>
</AppLayout>
</template>
+4 -6
View File
@@ -6,10 +6,8 @@ import CreateDialog from "@/Components/Dialogs/CreateDialog.vue";
import DataTable from "@/Components/DataTable/DataTableNew2.vue";
import { hasPermission } from "@/Services/permissions";
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 ActionMenuItem from "@/Components/DataTable/ActionMenuItem.vue";
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import {
Select,
SelectContent,
@@ -27,8 +25,7 @@ import {
import { useForm } from "vee-validate";
import { toTypedSchema } from "@vee-validate/zod";
import * as z from "zod";
import ActionMessage from "@/Components/ActionMessage.vue";
import { Mail, Plug2Icon, Plus, UsersRoundIcon } from "lucide-vue-next";
import { Plus, UsersRoundIcon } from "lucide-vue-next";
import { Separator } from "@/Components/ui/separator";
import AppCard from "@/Components/app/ui/card/AppCard.vue";
@@ -162,7 +159,7 @@ const fmtCurrency = (v) => {
</script>
<template>
<AppLayout>
<AppLayout title="Clients">
<template #header> </template>
<div class="py-6">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
@@ -201,6 +198,7 @@ const fmtCurrency = (v) => {
:show-pagination="false"
:show-toolbar="true"
:hoverable="true"
:page-size="100"
row-key="uuid"
:striped="true"
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 AppMultiSelect from "@/Components/app/ui/AppMultiSelect.vue";
import AppCard from "@/Components/app/ui/card/AppCard.vue";
import { toNumber } from "lodash";
const props = defineProps({
setting: Object,
unassignedContracts: Object,
assignedContracts: Object,
users: Array,
unassignedClients: Array,
assignedClients: Array,
unassignedClients: [Array, Object],
assignedClients: [Array, Object],
filters: Object,
});
@@ -54,6 +55,8 @@ const filterAssignedSelectedClient = ref(
: []
);
const unassignedContractTable = ref(null);
const form = useForm({
contract_uuid: null,
assigned_user_id: null,
@@ -107,6 +110,14 @@ function toggleContractSelection(uuid, checked) {
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)
// Initialize search and filter from URL params
@@ -296,6 +307,7 @@ function assignSelected() {
bulkForm.contract_uuids = selectedContractUuids.value;
bulkForm.post(route("fieldjobs.assign-bulk"), {
onSuccess: () => {
unassignedContractTable.value.clearSelection();
selectedContractUuids.value = [];
bulkForm.contract_uuids = [];
},
@@ -304,7 +316,11 @@ function assignSelected() {
function cancelAssignment(contract) {
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
@@ -437,6 +453,7 @@ const assignedRows = computed(() =>
</div>
</div>
<DataTable
ref="unassignedContractTable"
:columns="unassignedColumns"
:data="unassignedRows"
:meta="{
@@ -449,6 +466,8 @@ const assignedRows = computed(() =>
links: unassignedContracts.links,
}"
row-key="uuid"
:enable-row-selection="true"
@selection:change="handleContractSelection"
:page-size="props.unassignedContracts?.per_page || 10"
:page-size-options="[10, 15, 25, 50, 100]"
:show-toolbar="true"
@@ -482,7 +501,10 @@ const assignedRows = computed(() =>
<AppMultiSelect
v-model="filterUnassignedSelectedClient"
:items="
(props.unassignedClients || []).map((client) => ({
(Array.isArray(props.unassignedClients)
? props.unassignedClients
: props.unassignedClients?.data || []
).map((client) => ({
value: client.uuid,
label: client.person.full_name,
}))
@@ -497,14 +519,6 @@ const assignedRows = computed(() =>
</AppPopover>
</div>
</template>
<template #cell-_select="{ row }">
<Checkbox
@update:model-value="
(checked) => toggleContractSelection(row.uuid, checked)
"
/>
</template>
<template #cell-case_person="{ row }">
<Link
v-if="row.client_case?.uuid"
@@ -605,7 +619,10 @@ const assignedRows = computed(() =>
<AppMultiSelect
v-model="filterAssignedSelectedClient"
:items="
(props.assignedClients || []).map((client) => ({
(Array.isArray(props.assignedClients)
? props.assignedClients
: props.assignedClients?.data || []
).map((client) => ({
value: client.uuid,
label: client.person.full_name,
}))
+1 -1
View File
@@ -245,7 +245,7 @@ async function startImport() {
<!-- Has Header Checkbox -->
<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
for="has-header"
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
const showPaymentSim = ref(false);
const paymentSimLoading = ref(false);
@@ -1307,7 +1317,8 @@ async function fetchSimulation() {
<Checkbox
:id="'show-missing-checkbox'"
:checked="showMissingEnabled"
@update:checked="
:model-value="showMissingEnabled"
@update:model-value="
(val) => {
showMissingEnabled = val;
saveImportOptions();
@@ -1339,6 +1350,7 @@ async function fetchSimulation() {
:can-process="canProcess"
:selected-mappings-count="selectedMappingsCount"
@preview="openPreview"
@download="downloadImport"
@save-mappings="saveMappings"
@process-import="processImport"
@simulate="openSimulation"
@@ -4,9 +4,10 @@ import {
ArrowPathIcon,
BeakerIcon,
ArrowDownOnSquareIcon,
ArrowDownTrayIcon,
} from "@heroicons/vue/24/outline";
import { Button } from '@/Components/ui/button';
import { Badge } from '@/Components/ui/badge';
import { Button } from "@/Components/ui/button";
import { Badge } from "@/Components/ui/badge";
const props = defineProps({
importId: [Number, String],
@@ -16,15 +17,30 @@ const props = defineProps({
canProcess: Boolean,
selectedMappingsCount: Number,
});
const emits = defineEmits(["preview", "save-mappings", "process-import", "simulate"]);
const emits = defineEmits([
"preview",
"save-mappings",
"process-import",
"simulate",
"download",
]);
</script>
<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
variant="secondary"
@click.prevent="$emit('preview')"
@click.prevent="$emit('download')"
: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" />
Predogled vrstic
</Button>
@@ -41,11 +57,9 @@ const emits = defineEmits(["preview", "save-mappings", "process-import", "simula
></span>
<ArrowPathIcon v-else class="h-4 w-4 mr-2" />
<span>Shrani preslikave</span>
<Badge
v-if="selectedMappingsCount"
variant="secondary"
class="ml-2 text-xs"
>{{ selectedMappingsCount }}</Badge>
<Badge v-if="selectedMappingsCount" variant="secondary" class="ml-2 text-xs">{{
selectedMappingsCount
}}</Badge>
</Button>
<Button
variant="default"
@@ -66,4 +80,5 @@ const emits = defineEmits(["preview", "save-mappings", "process-import", "simula
Simulacija vnosa
</Button>
</div>
</div>
</template>
@@ -2,9 +2,12 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/Components/ui/dialog";
import { Button } from "@/Components/ui/button";
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 { 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({
show: Boolean,
limit: Number,
@@ -14,20 +17,63 @@ const props = defineProps({
truncated: Boolean,
hasHeader: Boolean,
})
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>
<template>
<Dialog :open="show" @update:open="(val) => !val && $emit('close')">
<DialogContent class="max-w-6xl max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>CSV Preview ({{ rows.length }} / {{ limit }})</DialogTitle>
</DialogHeader>
<div class="flex items-center gap-3 pb-3 border-b">
<DialogContent class="max-w-7xl max-h-[90vh] overflow-hidden flex flex-col p-0">
<!-- Header -->
<div class="px-6 py-4 border-b bg-linear-to-r from-gray-50 to-white">
<div class="flex items-center justify-between">
<div>
<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">
<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">
<SelectValue />
</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">
{{ loading ? 'Loading…' : 'Refresh' }}
</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">
Truncated at limit
</Badge>
</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">
Showing up to {{ limit }} rows from source file.
<!-- Split View -->
<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>
Click a row to view full details
</div>
</DialogContent>
</Dialog>
@@ -1,10 +1,24 @@
<script setup>
import { Table, TableBody, TableCell, TableHead, 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';
import {
Table,
TableBody,
TableCell,
TableHead,
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({
rows: Array,
@@ -19,12 +33,12 @@ const props = defineProps({
mappingError: String,
show: { type: Boolean, default: true },
fieldsForEntity: Function,
})
const emits = defineEmits(['update:rows','save'])
});
const emits = defineEmits(["update:rows", "save"]);
function duplicateTarget(row) {
if(!row || !row.entity || !row.field) return false
return props.duplicateTargets?.has?.(row.entity + '.' + row.field) || false
if (!row || !row.entity || !row.field) return false;
return props.duplicateTargets?.has?.(row.entity + "." + row.field) || false;
}
</script>
<template>
@@ -32,39 +46,63 @@ function duplicateTarget(row){
<div class="flex items-center justify-between mb-2">
<h3 class="font-semibold">
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>
<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>
<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">
<ScrollArea class="h-[420px]">
<Table>
<TableHeader class="sticky top-0 z-10 bg-background">
<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">Field</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">Transform</TableHead>
<TableHead class="w-[130px] bg-muted/95 backdrop-blur">Apply mode</TableHead>
<TableHead class="w-[60px] text-center bg-muted/95 backdrop-blur">Skip</TableHead>
<TableHead class="w-[130px] bg-muted/95 backdrop-blur"
>Apply mode</TableHead
>
<TableHead class="w-[60px] text-center bg-muted/95 backdrop-blur"
>Skip</TableHead
>
</TableRow>
</TableHeader>
<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>
<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">
<SelectValue placeholder="Select entity..." />
</SelectTrigger>
<SelectContent>
<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>
</SelectContent>
</Select>
@@ -72,16 +110,26 @@ function duplicateTarget(row){
<TableCell>
<Select
:model-value="row.field || ''"
@update:model-value="(val) => row.field = val || ''"
@update:model-value="(val) => (row.field = val || '')"
:disabled="isCompleted"
: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..." />
</SelectTrigger>
<SelectContent>
<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>
</SelectContent>
</Select>
@@ -101,7 +149,7 @@ function duplicateTarget(row){
<Select
v-if="row.field === 'meta'"
:model-value="(row.options ||= {}).type || 'string'"
@update:model-value="(val) => (row.options ||= {}).type = val"
@update:model-value="(val) => ((row.options ||= {}).type = val)"
:disabled="isCompleted"
>
<SelectTrigger class="h-8 text-xs">
@@ -119,7 +167,13 @@ function duplicateTarget(row){
<span v-else class="text-muted-foreground text-xs">—</span>
</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">
<SelectValue />
</SelectTrigger>
@@ -134,7 +188,11 @@ function duplicateTarget(row){
</Select>
</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">
<SelectValue />
</SelectTrigger>
@@ -149,20 +207,31 @@ function duplicateTarget(row){
</Select>
</TableCell>
<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>
</TableRow>
</TableBody>
</Table>
</ScrollArea>
</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>
<span>{{ mappingSavedCount }} mappings saved</span>
</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">
<Badge variant="destructive" class="text-xs">Missing critical: {{ missingCritical.join(', ') }}</Badge>
<Badge variant="destructive" class="text-xs"
>Missing critical: {{ missingCritical.join(", ") }}</Badge
>
</div>
</div>
</template>
+7 -1
View File
@@ -67,7 +67,7 @@ const props = defineProps({
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) {
const kind = classifyDocument(doc);
const isContractDoc = (doc?.documentable_type || "").toLowerCase().includes("contract");
@@ -85,6 +85,8 @@ function openViewer(doc) {
viewer.open = true;
viewer.src = url;
viewer.title = doc.original_name || doc.name;
viewer.mimeType = doc.mime_type || "";
viewer.filename = doc.original_name || doc.name || "";
} else {
const url =
isContractDoc && doc.contract_uuid
@@ -102,6 +104,8 @@ function openViewer(doc) {
function closeViewer() {
viewer.open = false;
viewer.src = "";
viewer.mimeType = "";
viewer.filename = "";
}
function formatAmount(val) {
@@ -610,6 +614,8 @@ const clientSummary = computed(() => {
:show="viewer.open"
:src="viewer.src"
:title="viewer.title"
:mime-type="viewer.mimeType"
:filename="viewer.filename"
@close="closeViewer"
/>
<ActivityDrawer
@@ -20,6 +20,7 @@ import {
} from "@/Components/ui/dialog";
import InputError from "@/Components/InputError.vue";
import { Monitor, Smartphone, LogOut, CheckCircle } from "lucide-vue-next";
import AppCard from "@/Components/app/ui/card/AppCard.vue";
defineProps({
sessions: Array,
@@ -55,33 +56,30 @@ const closeModal = () => {
</script>
<template>
<Card>
<CardHeader>
<AppCard
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">
<LogOut class="h-5 w-5 text-muted-foreground" />
<CardTitle>Browser Sessions</CardTitle>
<LogOut size="18" />
<CardTitle>Aktivne prijave</CardTitle>
</div>
<CardDescription>
Manage and log out your active sessions on other browsers and devices.
Upravljanje in izpis aktivnih prijav no drugih brskalnikih in napravah.
</CardDescription>
</CardHeader>
<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>
</template>
<!-- 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
v-for="(session, i) in sessions"
:key="i"
class="flex items-center gap-3 rounded-lg border p-3"
>
<div class="flex-shrink-0">
<div class="shrink-0">
<Monitor
v-if="session.agent.is_desktop"
class="h-8 w-8 text-muted-foreground"
@@ -100,15 +98,22 @@ const closeModal = () => {
v-if="session.is_current_device"
class="inline-flex items-center ml-2 text-green-600 dark:text-green-400 font-semibold"
>
This device
Ta naprava
</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 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">
<LogOut class="h-4 w-4 mr-2" />
Log Out Other Browser Sessions
@@ -122,7 +127,8 @@ const closeModal = () => {
<span>Done.</span>
</div>
</div>
</CardContent>
</template>
</AppCard>
<!-- Log Out Other Devices Confirmation Dialog -->
<Dialog :open="confirmingLogout" @update:open="closeModal">
@@ -155,5 +161,4 @@ const closeModal = () => {
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
</template>
@@ -1,14 +1,21 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { router, useForm, usePage } from '@inertiajs/vue3';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/Components/ui/card';
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 { ref, computed, watch } from "vue";
import { router, useForm, usePage } from "@inertiajs/vue3";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/Components/ui/card";
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({
requiresConfirmation: Boolean,
@@ -23,11 +30,11 @@ const setupKey = ref(null);
const recoveryCodes = ref([]);
const confirmationForm = useForm({
code: '',
code: "",
});
const twoFactorEnabled = computed(
() => ! enabling.value && page.props.auth.user?.two_factor_enabled,
() => !enabling.value && page.props.auth.user?.two_factor_enabled
);
watch(twoFactorEnabled, () => {
@@ -40,40 +47,40 @@ watch(twoFactorEnabled, () => {
const enableTwoFactorAuthentication = () => {
enabling.value = true;
router.post(route('two-factor.enable'), {}, {
router.post(
route("two-factor.enable"),
{},
{
preserveScroll: true,
onSuccess: () => Promise.all([
showQrCode(),
showSetupKey(),
showRecoveryCodes(),
]),
onSuccess: () => Promise.all([showQrCode(), showSetupKey(), showRecoveryCodes()]),
onFinish: () => {
enabling.value = false;
confirming.value = props.requiresConfirmation;
},
});
}
);
};
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;
});
};
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;
});
}
};
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;
});
};
const confirmTwoFactorAuthentication = () => {
confirmationForm.post(route('two-factor.confirm'), {
confirmationForm.post(route("two-factor.confirm"), {
errorBag: "confirmTwoFactorAuthentication",
preserveScroll: true,
preserveState: true,
@@ -86,15 +93,13 @@ const confirmTwoFactorAuthentication = () => {
};
const regenerateRecoveryCodes = () => {
axios
.post(route('two-factor.recovery-codes'))
.then(() => showRecoveryCodes());
axios.post(route("two-factor.recovery-codes")).then(() => showRecoveryCodes());
};
const disableTwoFactorAuthentication = () => {
disabling.value = true;
router.delete(route('two-factor.disable'), {
router.delete(route("two-factor.disable"), {
preserveScroll: true,
onSuccess: () => {
disabling.value = false;
@@ -107,42 +112,50 @@ const copyToClipboard = async (text) => {
try {
await navigator.clipboard.writeText(text);
} catch (err) {
console.error('Failed to copy:', err);
console.error("Failed to copy:", err);
}
};
</script>
<template>
<Card>
<CardHeader>
<AppCard
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">
<Shield class="h-5 w-5 text-muted-foreground" />
<CardTitle>Two Factor Authentication</CardTitle>
<Shield size="18" />
<CardTitle>Dvonivojska overitev</CardTitle>
</div>
<CardDescription>
Add additional security to your account using two factor authentication.
Dodatna varnost za vaš račun z dvonivojsko overitvijo.
</CardDescription>
</CardHeader>
</template>
<CardContent class="space-y-6">
<!-- Status Header -->
<div class="flex items-start gap-3">
<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" />
Two factor authentication is enabled
Dvonivojska overitev omogočena
</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" />
Finish enabling two factor authentication
Dokončaj namestitev dvonivojske overitve
</h3>
<h3 v-else class="text-lg font-semibold flex items-center gap-2">
<Shield class="h-5 w-5 text-muted-foreground" />
Two factor authentication is disabled
Dvonivojska overitev onemogočena
</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>
@@ -151,10 +164,13 @@ const copyToClipboard = async (text) => {
<div v-if="qrCode" class="space-y-4">
<div class="rounded-lg border bg-muted/50 p-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 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>
<!-- 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 class="flex items-center justify-between gap-2">
<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>
</div>
<Button
@@ -181,7 +197,7 @@ const copyToClipboard = async (text) => {
<!-- Confirmation Code Input -->
<div v-if="confirming" class="space-y-2">
<Label for="code">Confirmation Code</Label>
<Label for="code">Potrdite kodo</Label>
<Input
id="code"
v-model="confirmationForm.code"
@@ -200,18 +216,27 @@ const copyToClipboard = async (text) => {
<!-- Recovery Codes -->
<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">
<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">
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>
</div>
</div>
<div class="rounded-lg border bg-muted p-4">
<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>
<Button
type="button"
@@ -227,9 +252,9 @@ const copyToClipboard = async (text) => {
</div>
</div>
</div>
<template #footer>
<!-- Action Buttons -->
<div class="flex flex-wrap gap-2">
<div class="flex flex-row gap-2 items-center justify-end w-full">
<!-- Enable -->
<div v-if="!twoFactorEnabled">
<ConfirmsPassword @confirmed="enableTwoFactorAuthentication">
@@ -243,11 +268,7 @@ const copyToClipboard = async (text) => {
<!-- Confirm -->
<template v-else>
<ConfirmsPassword @confirmed="confirmTwoFactorAuthentication">
<Button
v-if="confirming"
type="button"
:disabled="enabling"
>
<Button v-if="confirming" type="button" :disabled="enabling">
<CheckCircle class="h-4 w-4 mr-2" />
Confirm
</Button>
@@ -301,6 +322,6 @@ const copyToClipboard = async (text) => {
</ConfirmsPassword>
</template>
</div>
</CardContent>
</Card>
</template>
</AppCard>
</template>
@@ -1,35 +1,36 @@
<script setup>
import { ref } from 'vue';
import { useForm } from '@inertiajs/vue3';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/Components/ui/card';
import { Button } from '@/Components/ui/button';
import { Input } from '@/Components/ui/input';
import { Label } from '@/Components/ui/label';
import InputError from '@/Components/InputError.vue';
import { CheckCircle, Lock } from 'lucide-vue-next';
import { ref } from "vue";
import { useForm } from "@inertiajs/vue3";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label";
import InputError from "@/Components/InputError.vue";
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 currentPasswordInput = ref(null);
const form = useForm({
current_password: '',
password: '',
password_confirmation: '',
current_password: "",
password: "",
password_confirmation: "",
});
const updatePassword = () => {
form.put(route('user-password.update'), {
errorBag: 'updatePassword',
form.put(route("user-password.update"), {
errorBag: "updatePassword",
preserveScroll: true,
onSuccess: () => form.reset(),
onError: () => {
if (form.errors.password) {
form.reset('password', 'password_confirmation');
form.reset("password", "password_confirmation");
passwordInput.value.focus();
}
if (form.errors.current_password) {
form.reset('current_password');
form.reset("current_password");
currentPasswordInput.value.focus();
}
},
@@ -38,21 +39,26 @@ const updatePassword = () => {
</script>
<template>
<Card>
<form @submit.prevent="updatePassword">
<CardHeader>
<AppCard
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">
<Lock class="h-5 w-5 text-muted-foreground" />
<CardTitle>Update Password</CardTitle>
<Lock size="18" />
<CardTitle>Posodobi geslo</CardTitle>
</div>
<CardDescription>
Ensure your account is using a long, random password to stay secure.
</CardDescription>
</CardHeader>
<p class="text-sm">
Poskrbite, da vaš račun uporablja dolgo, naključno geslo za varnost.
</p>
</template>
<CardContent class="space-y-6">
<form @submit.prevent="updatePassword" class="space-y-6">
<div class="space-y-2">
<Label for="current_password">Current Password</Label>
<Label for="current_password">Trenutno geslo</Label>
<Input
id="current_password"
ref="currentPasswordInput"
@@ -64,7 +70,7 @@ const updatePassword = () => {
</div>
<div class="space-y-2">
<Label for="password">New Password</Label>
<Label for="password">Novo geslo</Label>
<Input
id="password"
ref="passwordInput"
@@ -76,7 +82,7 @@ const updatePassword = () => {
</div>
<div class="space-y-2">
<Label for="password_confirmation">Confirm Password</Label>
<Label for="password_confirmation">Potrdi geslo</Label>
<Input
id="password_confirmation"
v-model="form.password_confirmation"
@@ -85,17 +91,16 @@ const updatePassword = () => {
/>
<InputError :message="form.errors.password_confirmation" class="mt-2" />
</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">
<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>
<Button type="submit" :disabled="form.processing"> Shrani </Button>
</div>
<Button type="submit" :disabled="form.processing">
Save
</Button>
</CardFooter>
</form>
</Card>
</template>
</AppCard>
</template>
@@ -1,20 +1,21 @@
<script setup>
import { ref } from 'vue';
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 { Input } from '@/Components/ui/input';
import { Label } from '@/Components/ui/label';
import { Avatar, AvatarImage, AvatarFallback } from '@/Components/ui/avatar';
import InputError from '@/Components/InputError.vue';
import { User, Mail, Camera, Trash2, CheckCircle, AlertCircle } from 'lucide-vue-next';
import { ref } from "vue";
import { Link, router, useForm } from "@inertiajs/vue3";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label";
import { Avatar, AvatarImage, AvatarFallback } from "@/Components/ui/avatar";
import InputError from "@/Components/InputError.vue";
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({
user: Object,
});
const form = useForm({
_method: 'PUT',
_method: "PUT",
name: props.user.name,
email: props.user.email,
photo: null,
@@ -29,8 +30,8 @@ const updateProfileInformation = () => {
form.photo = photoInput.value.files[0];
}
form.post(route('user-profile-information.update'), {
errorBag: 'updateProfileInformation',
form.post(route("user-profile-information.update"), {
errorBag: "updateProfileInformation",
preserveScroll: true,
onSuccess: () => clearPhotoFileInput(),
});
@@ -59,7 +60,7 @@ const updatePhotoPreview = () => {
};
const deletePhoto = () => {
router.delete(route('current-user-photo.destroy'), {
router.delete(route("current-user-photo.destroy"), {
preserveScroll: true,
onSuccess: () => {
photoPreview.value = null;
@@ -76,19 +77,22 @@ const clearPhotoFileInput = () => {
</script>
<template>
<Card>
<form @submit.prevent="updateProfileInformation">
<CardHeader>
<AppCard
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">
<User class="h-5 w-5 text-muted-foreground" />
<CardTitle>Profile Information</CardTitle>
<User size="18" />
<CardTitle>Informacije profila</CardTitle>
</div>
<CardDescription>
Update your account's profile information and email address.
</CardDescription>
</CardHeader>
<p class="text-sm">Posodobite informacije vašega profila in e-poštni naslov.</p>
</template>
<CardContent class="space-y-6">
<form @submit.prevent="updateProfileInformation" class="space-y-6">
<!-- Profile Photo -->
<div v-if="$page.props.jetstream.managesProfilePhotos" class="space-y-4">
<input
@@ -98,23 +102,15 @@ const clearPhotoFileInput = () => {
class="hidden"
accept="image/*"
@change="updatePhotoPreview"
>
/>
<Label for="photo">Photo</Label>
<Label for="photo">Fotografija</Label>
<div class="flex items-center gap-4">
<!-- Current/Preview Photo -->
<Avatar class="h-20 w-20">
<AvatarImage
v-if="photoPreview"
:src="photoPreview"
:alt="user.name"
/>
<AvatarImage
v-else
:src="user.profile_photo_url"
:alt="user.name"
/>
<AvatarImage v-if="photoPreview" :src="photoPreview" :alt="user.name" />
<AvatarImage v-else :src="user.profile_photo_url" :alt="user.name" />
<AvatarFallback>
<User class="h-8 w-8" />
</AvatarFallback>
@@ -128,7 +124,7 @@ const clearPhotoFileInput = () => {
@click.prevent="selectNewPhoto"
>
<Camera class="h-4 w-4 mr-2" />
Select Photo
Izberi fotografijo
</Button>
<Button
@@ -139,7 +135,7 @@ const clearPhotoFileInput = () => {
@click.prevent="deletePhoto"
>
<Trash2 class="h-4 w-4 mr-2" />
Remove
Odstrani
</Button>
</div>
</div>
@@ -149,20 +145,14 @@ const clearPhotoFileInput = () => {
<!-- Name -->
<div class="space-y-2">
<Label for="name">Name</Label>
<Input
id="name"
v-model="form.name"
type="text"
required
autocomplete="name"
/>
<Label for="name">Ime</Label>
<Input id="name" v-model="form.name" type="text" required autocomplete="name" />
<InputError :message="form.errors.name" class="mt-2" />
</div>
<!-- Email -->
<div class="space-y-2">
<Label for="email">Email</Label>
<Label for="email">E-pošta</Label>
<Input
id="email"
v-model="form.email"
@@ -173,12 +163,17 @@ const clearPhotoFileInput = () => {
<InputError :message="form.errors.email" class="mt-2" />
<!-- 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">
<AlertCircle class="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5" />
<div class="flex-1 text-sm">
<p class="text-amber-800 dark:text-amber-200">
Your email address is unverified.
Vaš e-poštni naslov ni potrjen.
<Link
:href="route('verification.send')"
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"
@click.prevent="sendEmailVerification"
>
Click here to re-send the verification email.
Kliknite tukaj za ponovno pošiljanje potrditvenega e-sporočila.
</Link>
</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" />
<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>
</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">
<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>
<Button type="submit" :disabled="form.processing"> Shrani </Button>
</div>
<Button type="submit" :disabled="form.processing">
Save
</Button>
</CardFooter>
</form>
</Card>
</template>
</AppCard>
</template>
+82 -1
View File
@@ -1,10 +1,11 @@
<script setup>
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 axios from "axios";
import DataTable from "@/Components/DataTable/DataTableNew2.vue";
import DialogModal from "@/Components/DialogModal.vue";
import ConfirmDialog from "@/Components/ConfirmDialog.vue";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label";
@@ -30,6 +31,7 @@ import {
import { cn } from "@/lib/utils";
import AppCard from "@/Components/app/ui/card/AppCard.vue";
import { CardTitle } from "@/Components/ui/card";
import { toNumber } from "lodash";
const props = defineProps({
segment: Object,
@@ -63,6 +65,14 @@ const exportColumns = ref(columns.map((col) => col.key));
const exportError = ref("");
const isExporting = ref(false);
const contractTable = ref(null);
const selectedRows = ref([]);
const showConfirmDialog = ref(false);
const archiveForm = useForm({
contracts: [],
reactivate: false,
});
const hasActiveFilters = computed(() => {
return Boolean(search.value?.trim()) || Boolean(selectedClient.value);
});
@@ -78,6 +88,13 @@ const appliedFilterCount = computed(() => {
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 contractsPerPage = computed(() => props.contracts?.per_page ?? 15);
const totalContracts = computed(
@@ -90,6 +107,11 @@ const exportDisabled = computed(
() => exportColumns.value.length === 0 || isExporting.value
);
const canManageSettings = computed(() => {
const permissions = usePage().props?.auth?.user?.permissions || [];
return permissions.includes("mass-archive");
});
function toggleAllColumns(checked) {
exportColumns.value = checked ? columns.map((col) => col.key) : [];
}
@@ -311,6 +333,36 @@ function extractFilenameFromHeaders(headers) {
const asciiMatch = disposition.match(/filename="?([^";]+)"?/i);
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>
<template>
@@ -364,10 +416,13 @@ function extractFilenameFromHeaders(headers) {
</div>
</template>
<DataTable
ref="contractTable"
:columns="columns"
:data="contracts?.data || []"
:meta="contracts || {}"
route-name="segments.show"
:enable-row-selection="canManageSettings"
@selection:change="handleSelectionChange"
:route-params="{ segment: segment?.id ?? segment }"
:only-props="['contracts']"
:page-size="contracts?.per_page ?? 15"
@@ -500,6 +555,17 @@ function extractFilenameFromHeaders(headers) {
</Button>
</div>
</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 }">
<Link
@@ -541,6 +607,21 @@ function extractFilenameFromHeaders(headers) {
</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">
<template #title>
<div class="space-y-1">
@@ -305,7 +305,7 @@ const destroyAction = () => {
</div>
</div>
<div>
<div class="space-y-1.5">
<InputLabel for="segmentEdit">Segment</InputLabel>
<AppCombobox
id="segmentEdit"
@@ -323,7 +323,7 @@ const destroyAction = () => {
v-model="form.decisions"
:items="selectOptions"
placeholder="Dodaj odločitev"
content-class="p-0 w-full"
chip-variant="secondary"
/>
</div>
@@ -373,7 +373,7 @@ const destroyAction = () => {
v-model="createForm.decisions"
:items="selectOptions"
placeholder="Dodaj odločitev"
content-class="p-0 w-full"
chip-variant="secondary"
/>
</div>
@@ -265,20 +265,22 @@ const update = () => {
// Transform actions from array of IDs to array of objects
const actionsPayload = form.actions
.map(id => {
const action = props.actions.find(a => a.id === Number(id) || a.id === id);
.map((id) => {
const action = props.actions.find((a) => a.id === Number(id) || a.id === id);
if (!action) {
console.warn('Action not found for id:', id);
console.warn("Action not found for id:", id);
return null;
}
return { id: action.id, name: action.name };
})
.filter(Boolean); // Remove null entries
form.transform((data) => ({
form
.transform((data) => ({
...data,
actions: actionsPayload
})).put(route("settings.decisions.update", { id: form.id }), {
actions: actionsPayload,
}))
.put(route("settings.decisions.update", { id: form.id }), {
onSuccess: () => {
closeEditDrawer();
},
@@ -299,20 +301,22 @@ const store = () => {
// Transform actions from array of IDs to array of objects
const actionsPayload = createForm.actions
.map(id => {
const action = props.actions.find(a => a.id === Number(id) || a.id === id);
.map((id) => {
const action = props.actions.find((a) => a.id === Number(id) || a.id === id);
if (!action) {
console.warn('Action not found for id:', id);
console.warn("Action not found for id:", id);
return null;
}
return { id: action.id, name: action.name };
})
.filter(Boolean); // Remove null entries
createForm.transform((data) => ({
createForm
.transform((data) => ({
...data,
actions: actionsPayload
})).post(route("settings.decisions.store"), {
actions: actionsPayload,
}))
.post(route("settings.decisions.store"), {
onSuccess: () => {
closeCreateDrawer();
},
@@ -665,7 +669,7 @@ const destroyDecision = () => {
</div>
<div class="flex items-center gap-2 self-end">
<label class="flex items-center gap-2 text-sm">
<Checkbox v-model:checked="ev.active" />
<Checkbox v-model="ev.active" />
Aktivno
</label>
<Button
@@ -703,7 +707,7 @@ const destroyDecision = () => {
</div>
<div class="flex items-end">
<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
</label>
</div>
+19 -4
View File
@@ -1,13 +1,21 @@
<script setup>
import { ref } from "vue";
import { ref, watch } from "vue";
import AppLayout from "@/Layouts/AppLayout.vue";
import DataTableClient from "@/Components/DataTable/DataTableClient.vue";
import DataTableExample from "../Examples/DataTableExample.vue";
import { useForm } from "@inertiajs/vue3";
import Checkbox from "@/Components/ui/checkbox/Checkbox.vue";
const props = defineProps({
example: { type: String, default: "Demo" },
});
const checkboxValue = ref(false);
const testForm = useForm({
allowed: false,
});
// Dummy columns
const columns = [
{ key: "id", label: "ID", sortable: true, class: "w-16" },
@@ -53,10 +61,17 @@ function onRowClick(row) {
// no-op demo; could show toast or details
console.debug("Row clicked:", row);
}
watch(
() => testForm.allowed,
(newVal) => {
console.log(newVal);
}
);
</script>
<template>
<DataTableExample></DataTableExample>
<AppLayout>
<Checkbox v-model:checked="testForm.allowed" />
</AppLayout>
</template>
+14 -2
View File
@@ -203,7 +203,14 @@
->leftJoin('person_addresses', 'person.id', '=', 'person_addresses.person_id')
->leftJoin('person_phones', 'person.id', '=', 'person_phones.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'));
})
->get();
@@ -215,6 +222,8 @@
$contractCases = \App\Models\Contract::query()
->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.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) {
$j->on('contract_segment.contract_id', '=', 'contracts.id')
->where('contract_segment.active', true);
@@ -227,9 +236,10 @@
'client_cases.uuid as case_uuid',
'client_cases.id as case_id',
'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")
)
->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)
->get();
@@ -324,6 +334,7 @@
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}/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/{client_case:uuid}/emergency-person', [ClientCaseContoller::class, 'emergencyCreatePerson'])->name('clientCase.emergencyPerson');
// 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-csv', [ImportController::class, 'exportMissingKeyrefCsv'])->name('imports.missing-keyref-csv');
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::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
+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();
});