Added call later, option to limit auto mail so for a client person email you can limit which decision activity will be send to that specific email and moved SMS packages from admin panel to default app view
This commit is contained in:
parent
c16dd51199
commit
b0d2aa93ab
|
|
@ -12,7 +12,6 @@
|
|||
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;
|
||||
|
|
@ -30,7 +29,7 @@ public function index(Request $request): Response
|
|||
->latest('id')
|
||||
->paginate($perPage);
|
||||
|
||||
return Inertia::render('Admin/Packages/Index', [
|
||||
return Inertia::render('Packages/Index', [
|
||||
'packages' => $packages,
|
||||
]);
|
||||
}
|
||||
|
|
@ -70,7 +69,7 @@ public function create(Request $request): Response
|
|||
})
|
||||
->values();
|
||||
|
||||
return Inertia::render('Admin/Packages/Create', [
|
||||
return Inertia::render('Packages/Create', [
|
||||
'profiles' => $profiles,
|
||||
'senders' => $senders,
|
||||
'templates' => $templates,
|
||||
|
|
@ -213,7 +212,7 @@ public function show(Package $package, SmsService $sms): Response
|
|||
}
|
||||
}
|
||||
|
||||
return Inertia::render('Admin/Packages/Show', [
|
||||
return Inertia::render('Packages/Show', [
|
||||
'package' => $package,
|
||||
'items' => $items,
|
||||
'preview' => $preview,
|
||||
|
|
|
|||
53
app/Http/Controllers/CallLaterController.php
Normal file
53
app/Http/Controllers/CallLaterController.php
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\CallLater;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class CallLaterController extends Controller
|
||||
{
|
||||
public function index(Request $request): \Inertia\Response
|
||||
{
|
||||
$query = CallLater::query()
|
||||
->with([
|
||||
'clientCase.person',
|
||||
'contract',
|
||||
'user',
|
||||
'activity',
|
||||
])
|
||||
->whereNull('completed_at')
|
||||
->orderBy('call_back_at', 'asc');
|
||||
|
||||
if ($request->filled('date_from')) {
|
||||
$query->whereDate('call_back_at', '>=', $request->date_from);
|
||||
}
|
||||
if ($request->filled('date_to')) {
|
||||
$query->whereDate('call_back_at', '<=', $request->date_to);
|
||||
}
|
||||
if ($request->filled('search')) {
|
||||
$term = '%'.$request->search.'%';
|
||||
$query->whereHas('clientCase.person', function ($q) use ($term) {
|
||||
$q->where('first_name', 'ilike', $term)
|
||||
->orWhere('last_name', 'ilike', $term)
|
||||
->orWhere('full_name', 'ilike', $term)
|
||||
->orWhereRaw("CONCAT(first_name, ' ', last_name) ILIKE ?", [$term]);
|
||||
});
|
||||
}
|
||||
|
||||
$callLaters = $query->paginate(50)->withQueryString();
|
||||
|
||||
return Inertia::render('CallLaters/Index', [
|
||||
'callLaters' => $callLaters,
|
||||
'filters' => $request->only(['date_from', 'date_to', 'search']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function complete(CallLater $callLater): \Illuminate\Http\RedirectResponse
|
||||
{
|
||||
$callLater->update(['completed_at' => now()]);
|
||||
|
||||
return back()->with('success', 'Klic označen kot opravljen.');
|
||||
}
|
||||
}
|
||||
|
|
@ -306,6 +306,7 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
|||
try {
|
||||
$attributes = $request->validate([
|
||||
'due_date' => 'nullable|date',
|
||||
'call_back_at' => 'nullable|date_format:Y-m-d H:i:s|after_or_equal:now',
|
||||
'amount' => 'nullable|decimal:0,4',
|
||||
'note' => 'nullable|string',
|
||||
'action_id' => 'exists:\App\Models\Action,id',
|
||||
|
|
@ -326,14 +327,14 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
|||
|
||||
// Determine which contracts to process
|
||||
$contractIds = [];
|
||||
if ($createForAll && !empty($contractUuids)) {
|
||||
if ($createForAll && ! empty($contractUuids)) {
|
||||
// Get all contract IDs from the provided UUIDs
|
||||
$contracts = Contract::withTrashed()
|
||||
->whereIn('uuid', $contractUuids)
|
||||
->where('client_case_id', $clientCase->id)
|
||||
->get();
|
||||
$contractIds = $contracts->pluck('id')->toArray();
|
||||
} elseif (!empty($contractUuids) && isset($contractUuids[0])) {
|
||||
} elseif (! empty($contractUuids) && isset($contractUuids[0])) {
|
||||
// Single contract mode
|
||||
$contract = Contract::withTrashed()
|
||||
->where('uuid', $contractUuids[0])
|
||||
|
|
@ -342,7 +343,7 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
|||
if ($contract) {
|
||||
$contractIds = [$contract->id];
|
||||
}
|
||||
} elseif (!empty($attributes['contract_uuid'])) {
|
||||
} elseif (! empty($attributes['contract_uuid'])) {
|
||||
// Legacy single contract_uuid support
|
||||
$contract = Contract::withTrashed()
|
||||
->where('uuid', $attributes['contract_uuid'])
|
||||
|
|
@ -371,6 +372,7 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
|||
// Create activity
|
||||
$row = $clientCase->activities()->create([
|
||||
'due_date' => $attributes['due_date'] ?? null,
|
||||
'call_back_at' => $attributes['call_back_at'] ?? null,
|
||||
'amount' => $attributes['amount'] ?? null,
|
||||
'note' => $attributes['note'] ?? null,
|
||||
'action_id' => $attributes['action_id'],
|
||||
|
|
@ -867,6 +869,9 @@ public function show(ClientCase $clientCase)
|
|||
'decisions.emailTemplate' => function ($q) {
|
||||
$q->select('id', 'name', 'entity_types', 'allow_attachments');
|
||||
},
|
||||
'decisions.events' => function ($q) {
|
||||
$q->select('events.id', 'events.key', 'events.name');
|
||||
},
|
||||
])
|
||||
->get(['id', 'name', 'color_tag', 'segment_id']),
|
||||
'types' => $types,
|
||||
|
|
@ -888,6 +893,7 @@ public function show(ClientCase $clientCase)
|
|||
->select(['id', 'name', 'content', 'allow_custom_body'])
|
||||
->orderBy('name')
|
||||
->get(),
|
||||
'auto_mail_decisions' => \App\Models\Decision::query()->where('auto_mail', true)->orderBy('name')->get(['id', 'name']),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -1101,6 +1107,7 @@ public function archiveBatch(Request $request)
|
|||
|
||||
if (! $setting) {
|
||||
\Log::warning('No archive settings found for batch archive');
|
||||
|
||||
return back()->with('flash', [
|
||||
'error' => 'No archive settings found',
|
||||
]);
|
||||
|
|
@ -1116,8 +1123,9 @@ public function archiveBatch(Request $request)
|
|||
$contract = Contract::where('uuid', $contractUuid)->firstOrFail();
|
||||
|
||||
// Skip if contract is already archived (active = 0)
|
||||
if (!$contract->active) {
|
||||
if (! $contract->active) {
|
||||
$skippedCount++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -1207,7 +1215,7 @@ public function archiveBatch(Request $request)
|
|||
if ($skippedCount > 0) {
|
||||
$message .= ", skipped $skippedCount already archived";
|
||||
}
|
||||
$message .= ", " . count($errors) . " failed";
|
||||
$message .= ', '.count($errors).' failed';
|
||||
|
||||
return back()->with('flash', [
|
||||
'error' => $message,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
@ -71,6 +71,7 @@ public function show(Client $client, Request $request)
|
|||
|
||||
return Inertia::render('Client/Show', [
|
||||
'client' => $data,
|
||||
'auto_mail_decisions' => \App\Models\Decision::query()->where('auto_mail', true)->orderBy('name')->get(['id', 'name']),
|
||||
'client_cases' => $data->clientCases()
|
||||
->select('client_cases.*')
|
||||
->when($request->input('search'), function ($que, $search) {
|
||||
|
|
@ -162,6 +163,7 @@ public function contracts(Client $client, Request $request)
|
|||
|
||||
return Inertia::render('Client/Contracts', [
|
||||
'client' => $data,
|
||||
'auto_mail_decisions' => \App\Models\Decision::query()->where('auto_mail', true)->orderBy('name')->get(['id', 'name']),
|
||||
'contracts' => $contractsQuery
|
||||
->paginate($perPage, ['*'], 'contracts_page', $pageNumber)
|
||||
->withQueryString(),
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\BankAccount;
|
||||
use App\Models\Person\Person;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
|
|
@ -28,8 +27,6 @@ public function update(Person $person, Request $request)
|
|||
|
||||
return back()->with('success', 'Person updated')->with('flash_method', 'PUT');
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
public function createAddress(Person $person, Request $request)
|
||||
|
|
@ -80,7 +77,6 @@ public function deleteAddress(Person $person, int $address_id, Request $request)
|
|||
$address = $person->addresses()->findOrFail($address_id);
|
||||
$address->delete(); // soft delete
|
||||
|
||||
|
||||
return back()->with('success', 'Address deleted')->with('flash_method', 'DELETE');
|
||||
}
|
||||
|
||||
|
|
@ -142,8 +138,14 @@ public function createEmail(Person $person, Request $request)
|
|||
'verified_at' => 'nullable|date',
|
||||
'preferences' => 'nullable|array',
|
||||
'meta' => 'nullable|array',
|
||||
'decision_ids' => 'nullable|array',
|
||||
'decision_ids.*' => 'integer|exists:decisions,id',
|
||||
]);
|
||||
|
||||
$decisionIds = array_map('intval', $attributes['decision_ids'] ?? []);
|
||||
unset($attributes['decision_ids']);
|
||||
$attributes['preferences'] = array_merge($attributes['preferences'] ?? [], ['decision_ids' => $decisionIds]);
|
||||
|
||||
// Dedup: avoid duplicate email per person by value
|
||||
$email = $person->emails()->firstOrCreate([
|
||||
'value' => $attributes['value'],
|
||||
|
|
@ -164,10 +166,16 @@ public function updateEmail(Person $person, int $email_id, Request $request)
|
|||
'verified_at' => 'nullable|date',
|
||||
'preferences' => 'nullable|array',
|
||||
'meta' => 'nullable|array',
|
||||
'decision_ids' => 'nullable|array',
|
||||
'decision_ids.*' => 'integer|exists:decisions,id',
|
||||
]);
|
||||
|
||||
$email = $person->emails()->findOrFail($email_id);
|
||||
|
||||
$decisionIds = array_map('intval', $attributes['decision_ids'] ?? []);
|
||||
unset($attributes['decision_ids']);
|
||||
$attributes['preferences'] = array_merge($email->preferences ?? [], $attributes['preferences'] ?? [], ['decision_ids' => $decisionIds]);
|
||||
|
||||
$email->update($attributes);
|
||||
|
||||
return back()->with('success', 'Email updated successfully')->with('flash_method', 'PUT');
|
||||
|
|
@ -204,10 +212,8 @@ public function createTrr(Person $person, Request $request)
|
|||
// Create without dedup (IBAN may be null or vary); could dedup by IBAN if provided
|
||||
$trr = $person->bankAccounts()->create($attributes);
|
||||
|
||||
|
||||
return back()->with('success', 'TRR added successfully')->with('flash_method', 'POST');
|
||||
|
||||
|
||||
}
|
||||
|
||||
public function updateTrr(Person $person, int $trr_id, Request $request)
|
||||
|
|
@ -238,7 +244,6 @@ public function deleteTrr(Person $person, int $trr_id, Request $request)
|
|||
$trr = $person->bankAccounts()->findOrFail($trr_id);
|
||||
$trr->delete();
|
||||
|
||||
|
||||
return back()->with('success', 'TRR deleted')->with('flash_method', 'DELETE');
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ class Activity extends Model
|
|||
|
||||
protected $fillable = [
|
||||
'due_date',
|
||||
'call_back_at',
|
||||
'amount',
|
||||
'note',
|
||||
'action_id',
|
||||
|
|
@ -27,6 +28,13 @@ class Activity extends Model
|
|||
'client_case_id',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'call_back_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
protected $hidden = [
|
||||
'action_id',
|
||||
'decision_id',
|
||||
|
|
@ -146,4 +154,9 @@ public function user(): BelongsTo
|
|||
{
|
||||
return $this->belongsTo(\App\Models\User::class);
|
||||
}
|
||||
|
||||
public function callLaters(): \Illuminate\Database\Eloquent\Relations\HasMany
|
||||
{
|
||||
return $this->hasMany(\App\Models\CallLater::class);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
46
app/Models/CallLater.php
Normal file
46
app/Models/CallLater.php
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class CallLater extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'activity_id',
|
||||
'client_case_id',
|
||||
'contract_id',
|
||||
'user_id',
|
||||
'call_back_at',
|
||||
'completed_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'call_back_at' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function activity(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Activity::class);
|
||||
}
|
||||
|
||||
public function clientCase(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ClientCase::class);
|
||||
}
|
||||
|
||||
public function contract(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Contract::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -59,10 +59,23 @@ public function maybeQueue(Activity $activity, bool $sendFlag = true, array $opt
|
|||
// Resolve eligible recipients: client's person emails with receive_auto_mails = true
|
||||
$recipients = [];
|
||||
if ($client && $client->person) {
|
||||
$recipients = Email::query()
|
||||
$emails = Email::query()
|
||||
->where('person_id', $client->person->id)
|
||||
->where('is_active', true)
|
||||
->where('receive_auto_mails', true)
|
||||
->get(['value', 'preferences']);
|
||||
|
||||
$recipients = $emails
|
||||
->filter(function (Email $email) use ($decision): bool {
|
||||
$decisionIds = $email->preferences['decision_ids'] ?? [];
|
||||
|
||||
// Empty list means "all decisions" — always receive
|
||||
if (empty($decisionIds)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array((int) $decision->id, array_map('intval', $decisionIds), true);
|
||||
})
|
||||
->pluck('value')
|
||||
->map(fn ($v) => strtolower(trim((string) $v)))
|
||||
->filter(fn ($v) => filter_var($v, FILTER_VALIDATE_EMAIL))
|
||||
|
|
|
|||
27
app/Services/DecisionEvents/Handlers/CallLaterHandler.php
Normal file
27
app/Services/DecisionEvents/Handlers/CallLaterHandler.php
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\DecisionEvents\Handlers;
|
||||
|
||||
use App\Models\CallLater;
|
||||
use App\Services\DecisionEvents\Contracts\DecisionEventHandler;
|
||||
use App\Services\DecisionEvents\DecisionEventContext;
|
||||
|
||||
class CallLaterHandler implements DecisionEventHandler
|
||||
{
|
||||
public function handle(DecisionEventContext $context, array $config = []): void
|
||||
{
|
||||
$activity = $context->activity;
|
||||
|
||||
if (empty($activity->call_back_at)) {
|
||||
return;
|
||||
}
|
||||
|
||||
CallLater::create([
|
||||
'activity_id' => $activity->id,
|
||||
'client_case_id' => $activity->client_case_id,
|
||||
'contract_id' => $activity->contract_id,
|
||||
'user_id' => $activity->user_id,
|
||||
'call_back_at' => $activity->call_back_at,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ class Registry
|
|||
'add_segment' => AddSegmentHandler::class,
|
||||
'archive_contract' => \App\Services\DecisionEvents\Handlers\ArchiveContractHandler::class,
|
||||
'end_field_job' => \App\Services\DecisionEvents\Handlers\EndFieldJobHandler::class,
|
||||
'add_call_later' => \App\Services\DecisionEvents\Handlers\CallLaterHandler::class,
|
||||
];
|
||||
|
||||
public static function resolve(string $key): DecisionEventHandler
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('activities', function (Blueprint $table) {
|
||||
$table->dateTime('call_back_at')->nullable()->after('due_date');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('activities', function (Blueprint $table) {
|
||||
$table->dropColumn('call_back_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<?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::create('call_laters', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('activity_id')->constrained('activities')->cascadeOnDelete();
|
||||
$table->foreignId('client_case_id')->constrained('client_cases')->cascadeOnDelete();
|
||||
$table->foreignId('contract_id')->nullable()->constrained('contracts')->nullOnDelete();
|
||||
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->dateTime('call_back_at');
|
||||
$table->dateTime('completed_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('call_laters');
|
||||
}
|
||||
};
|
||||
|
|
@ -31,6 +31,11 @@ public function run(): void
|
|||
'name' => 'End field job',
|
||||
'description' => 'Dispatches a queued job to finalize field-related processing (implementation-specific).',
|
||||
],
|
||||
[
|
||||
'key' => 'add_call_later',
|
||||
'name' => 'Klic kasneje',
|
||||
'description' => 'Ustvari zapis za povratni klic ob določenem datumu in uri.',
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
|
|
|
|||
18
package-lock.json
generated
18
package-lock.json
generated
|
|
@ -6029,24 +6029,6 @@
|
|||
"which": "bin/which"
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@
|
|||
<testsuite name="Feature">
|
||||
<directory>tests/Feature</directory>
|
||||
</testsuite>
|
||||
<testsuite name="Pure">
|
||||
<directory>tests/Pure</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
<include>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup>
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { ref, computed, watch, onUnmounted } from "vue";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -9,7 +9,7 @@ import {
|
|||
} from "@/Components/ui/dialog";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { Loader2 } from "lucide-vue-next";
|
||||
import { Loader2, RotateCcwIcon } from "lucide-vue-next";
|
||||
import axios from "axios";
|
||||
|
||||
const props = defineProps({
|
||||
|
|
@ -26,6 +26,157 @@ const loading = ref(false);
|
|||
const previewGenerating = ref(false);
|
||||
const previewError = ref("");
|
||||
|
||||
// Image viewer – zoom & pan state
|
||||
const containerRef = ref(null);
|
||||
const imageRef = ref(null);
|
||||
const imageScale = ref(1);
|
||||
const translateX = ref(0);
|
||||
const translateY = ref(0);
|
||||
const fitScale = ref(1);
|
||||
const isDragging = ref(false);
|
||||
const hasMoved = ref(false);
|
||||
const dragStartX = ref(0);
|
||||
const dragStartY = ref(0);
|
||||
const dragStartTX = ref(0);
|
||||
const dragStartTY = ref(0);
|
||||
|
||||
const MAX_SCALE = 8;
|
||||
const ZOOM_FACTOR = 2;
|
||||
|
||||
const imageCursorClass = computed(() => {
|
||||
if (isDragging.value && hasMoved.value) return "cursor-grabbing";
|
||||
if (imageScale.value > fitScale.value + 0.01) return "cursor-grab";
|
||||
return "cursor-zoom-in";
|
||||
});
|
||||
|
||||
const initImageView = () => {
|
||||
const container = containerRef.value;
|
||||
const img = imageRef.value;
|
||||
if (!container || !img) return;
|
||||
const cW = container.clientWidth;
|
||||
const cH = container.clientHeight;
|
||||
const iW = img.naturalWidth || cW;
|
||||
const iH = img.naturalHeight || cH;
|
||||
const fs = Math.min(cW / iW, cH / iH);
|
||||
fitScale.value = fs;
|
||||
imageScale.value = fs;
|
||||
translateX.value = (cW - iW * fs) / 2;
|
||||
translateY.value = (cH - iH * fs) / 2;
|
||||
};
|
||||
|
||||
const resetImageView = () => {
|
||||
initImageView();
|
||||
};
|
||||
|
||||
const clampTranslate = (tx, ty, scale) => {
|
||||
const container = containerRef.value;
|
||||
const img = imageRef.value;
|
||||
if (!container || !img) return { tx, ty };
|
||||
const cW = container.clientWidth;
|
||||
const cH = container.clientHeight;
|
||||
const iW = img.naturalWidth * scale;
|
||||
const iH = img.naturalHeight * scale;
|
||||
// When image fills the container: clamp so image edges stay within container.
|
||||
// When image is smaller than container: keep it centered.
|
||||
const minX = iW >= cW ? cW - iW : (cW - iW) / 2;
|
||||
const maxX = iW >= cW ? 0 : (cW - iW) / 2;
|
||||
const minY = iH >= cH ? cH - iH : (cH - iH) / 2;
|
||||
const maxY = iH >= cH ? 0 : (cH - iH) / 2;
|
||||
return {
|
||||
tx: Math.min(maxX, Math.max(minX, tx)),
|
||||
ty: Math.min(maxY, Math.max(minY, ty)),
|
||||
};
|
||||
};
|
||||
|
||||
const zoomAt = (mx, my, factor) => {
|
||||
const img = imageRef.value;
|
||||
const iW = img?.naturalWidth ?? 1;
|
||||
const iH = img?.naturalHeight ?? 1;
|
||||
const raw = imageScale.value * factor;
|
||||
const newScale = Math.min(MAX_SCALE, Math.max(fitScale.value, raw));
|
||||
if (newScale === imageScale.value) return;
|
||||
let tx = mx - ((mx - translateX.value) / imageScale.value) * newScale;
|
||||
let ty = my - ((my - translateY.value) / imageScale.value) * newScale;
|
||||
const clamped = clampTranslate(tx, ty, newScale);
|
||||
translateX.value = clamped.tx;
|
||||
translateY.value = clamped.ty;
|
||||
imageScale.value = newScale;
|
||||
};
|
||||
|
||||
const mousePos = (e) => {
|
||||
const rect = containerRef.value.getBoundingClientRect();
|
||||
return { mx: e.clientX - rect.left, my: e.clientY - rect.top };
|
||||
};
|
||||
|
||||
const handleImageLoad = () => {
|
||||
initImageView();
|
||||
};
|
||||
|
||||
const handleClick = (e) => {
|
||||
if (hasMoved.value) return;
|
||||
const { mx, my } = mousePos(e);
|
||||
zoomAt(mx, my, ZOOM_FACTOR);
|
||||
};
|
||||
|
||||
const handleContextMenu = (e) => {
|
||||
e.preventDefault();
|
||||
if (hasMoved.value) return;
|
||||
if (imageScale.value <= fitScale.value + 0.01) return;
|
||||
const { mx, my } = mousePos(e);
|
||||
zoomAt(mx, my, 1 / ZOOM_FACTOR);
|
||||
};
|
||||
|
||||
const handleWheel = (e) => {
|
||||
e.preventDefault();
|
||||
const { mx, my } = mousePos(e);
|
||||
zoomAt(mx, my, e.deltaY < 0 ? 1.2 : 1 / 1.2);
|
||||
};
|
||||
|
||||
const onMouseMove = (e) => {
|
||||
if (!isDragging.value) return;
|
||||
const dx = e.clientX - dragStartX.value;
|
||||
const dy = e.clientY - dragStartY.value;
|
||||
if (!hasMoved.value && (Math.abs(dx) > 3 || Math.abs(dy) > 3)) {
|
||||
hasMoved.value = true;
|
||||
}
|
||||
if (hasMoved.value) {
|
||||
const clamped = clampTranslate(
|
||||
dragStartTX.value + dx,
|
||||
dragStartTY.value + dy,
|
||||
imageScale.value
|
||||
);
|
||||
translateX.value = clamped.tx;
|
||||
translateY.value = clamped.ty;
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
isDragging.value = false;
|
||||
// Delay reset so the click/contextmenu handler that fires after mouseup can still read hasMoved
|
||||
setTimeout(() => {
|
||||
hasMoved.value = false;
|
||||
}, 0);
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
};
|
||||
|
||||
const handleMouseDown = (e) => {
|
||||
if (e.button !== 0) return;
|
||||
isDragging.value = true;
|
||||
hasMoved.value = false;
|
||||
dragStartX.value = e.clientX;
|
||||
dragStartY.value = e.clientY;
|
||||
dragStartTX.value = translateX.value;
|
||||
dragStartTY.value = translateY.value;
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("mouseup", onMouseUp);
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
});
|
||||
|
||||
const fileExtension = computed(() => {
|
||||
if (props.filename) {
|
||||
return props.filename.split(".").pop()?.toLowerCase() || "";
|
||||
|
|
@ -118,6 +269,10 @@ watch(
|
|||
previewGenerating.value = false;
|
||||
previewError.value = "";
|
||||
docxPreviewUrl.value = "";
|
||||
imageScale.value = 1;
|
||||
translateX.value = 0;
|
||||
translateY.value = 0;
|
||||
fitScale.value = 1;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
|
|
@ -179,11 +334,52 @@ watch(
|
|||
|
||||
<!-- Image Viewer -->
|
||||
<template v-else-if="viewerType === 'image' && props.src">
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="relative h-full overflow-hidden select-none"
|
||||
:class="imageCursorClass"
|
||||
@click="handleClick"
|
||||
@contextmenu="handleContextMenu"
|
||||
@mousedown="handleMouseDown"
|
||||
@wheel.prevent="handleWheel"
|
||||
>
|
||||
<img
|
||||
ref="imageRef"
|
||||
:src="props.src"
|
||||
:alt="props.title"
|
||||
class="max-w-full max-h-full mx-auto object-contain"
|
||||
class="absolute top-0 left-0 max-w-none pointer-events-none"
|
||||
:style="{
|
||||
transformOrigin: '0 0',
|
||||
transform: `translate(${translateX}px, ${translateY}px) scale(${imageScale})`,
|
||||
transition: isDragging ? 'none' : 'transform 0.12s ease',
|
||||
}"
|
||||
@load="handleImageLoad"
|
||||
/>
|
||||
<!-- Zoom level badge -->
|
||||
<div
|
||||
class="absolute bottom-2 right-2 bg-black/50 text-white text-xs px-2 py-1 rounded pointer-events-none"
|
||||
>
|
||||
{{ Math.round(imageScale * 100) }}%
|
||||
</div>
|
||||
<!-- Reset button -->
|
||||
<Button
|
||||
v-if="imageScale > fitScale + 0.01"
|
||||
size="icon-sm"
|
||||
variant="secondary"
|
||||
class="absolute top-2 right-2 opacity-70 hover:opacity-100"
|
||||
title="Ponastavi pogled"
|
||||
@click.stop="resetImageView"
|
||||
>
|
||||
<RotateCcwIcon class="h-3 w-3" />
|
||||
</Button>
|
||||
<!-- Hint -->
|
||||
<div
|
||||
v-if="imageScale <= fitScale + 0.01"
|
||||
class="absolute bottom-2 left-1/2 -translate-x-1/2 bg-black/40 text-white text-xs px-2 py-1 rounded pointer-events-none select-none"
|
||||
>
|
||||
Klik za povečavo · Desni klik / kolesce za pomanjšavo · Povleči za premik
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Text/CSV/XML Viewer -->
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ import { computed, ref, watch } from "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 { router, usePage } from "@inertiajs/vue3";
|
||||
import AppMultiSelect from "@/Components/app/ui/AppMultiSelect.vue";
|
||||
import CreateDialog from "../Dialogs/CreateDialog.vue";
|
||||
import UpdateDialog from "../Dialogs/UpdateDialog.vue";
|
||||
import SectionTitle from "../SectionTitle.vue";
|
||||
|
|
@ -27,12 +28,22 @@ const props = defineProps({
|
|||
|
||||
const emit = defineEmits(["close"]);
|
||||
|
||||
// Decisions with auto_mail = true from shared Inertia data
|
||||
const page = usePage();
|
||||
const decisionOptions = computed(() =>
|
||||
(page.props.auto_mail_decisions ?? []).map((d) => ({
|
||||
value: String(d.id),
|
||||
label: d.name,
|
||||
}))
|
||||
);
|
||||
|
||||
// Zod schema for form validation
|
||||
const formSchema = toTypedSchema(
|
||||
z.object({
|
||||
value: z.string().email("E-pošta mora biti veljavna.").min(1, "E-pošta je obvezna."),
|
||||
label: z.string().optional(),
|
||||
receive_auto_mails: z.boolean().optional(),
|
||||
decision_ids: z.array(z.string()).optional().default([]),
|
||||
})
|
||||
);
|
||||
|
||||
|
|
@ -43,9 +54,13 @@ const form = useForm({
|
|||
value: "",
|
||||
label: "",
|
||||
receive_auto_mails: false,
|
||||
decision_ids: [],
|
||||
},
|
||||
});
|
||||
|
||||
// Whether to limit sending to specific decisions (UI-only toggle)
|
||||
const limitToDecisions = ref(false);
|
||||
|
||||
const processing = ref(false);
|
||||
|
||||
const close = () => {
|
||||
|
|
@ -57,22 +72,44 @@ const close = () => {
|
|||
};
|
||||
|
||||
const resetForm = () => {
|
||||
limitToDecisions.value = false;
|
||||
form.resetForm({
|
||||
values: {
|
||||
value: "",
|
||||
label: "",
|
||||
receive_auto_mails: false,
|
||||
decision_ids: [],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// When auto mails is disabled, collapse the decision filter
|
||||
watch(
|
||||
() => form.values.receive_auto_mails,
|
||||
(val) => {
|
||||
if (!val) {
|
||||
limitToDecisions.value = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// When limit toggle is turned off, clear the selection
|
||||
watch(limitToDecisions, (val) => {
|
||||
if (!val) {
|
||||
form.setFieldValue("decision_ids", []);
|
||||
}
|
||||
});
|
||||
|
||||
const create = async () => {
|
||||
processing.value = true;
|
||||
const { values } = form;
|
||||
const payload = {
|
||||
...form.values,
|
||||
decision_ids: limitToDecisions.value ? (form.values.decision_ids ?? []) : [],
|
||||
};
|
||||
|
||||
router.post(
|
||||
route("person.email.create", props.person),
|
||||
values,
|
||||
payload,
|
||||
{
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
|
|
@ -98,11 +135,14 @@ const create = async () => {
|
|||
|
||||
const update = async () => {
|
||||
processing.value = true;
|
||||
const { values } = form;
|
||||
const payload = {
|
||||
...form.values,
|
||||
decision_ids: limitToDecisions.value ? (form.values.decision_ids ?? []) : [],
|
||||
};
|
||||
|
||||
router.put(
|
||||
route("person.email.update", { person: props.person, email_id: props.id }),
|
||||
values,
|
||||
payload,
|
||||
{
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
|
|
@ -136,10 +176,13 @@ watch(
|
|||
const list = Array.isArray(props.person?.emails) ? props.person.emails : [];
|
||||
const email = list.find((e) => e.id === props.id);
|
||||
if (email) {
|
||||
const existingDecisionIds = (email.preferences?.decision_ids ?? []).map(String);
|
||||
limitToDecisions.value = existingDecisionIds.length > 0;
|
||||
form.setValues({
|
||||
value: email.value ?? email.email ?? email.address ?? "",
|
||||
label: email.label ?? "",
|
||||
receive_auto_mails: !!email.receive_auto_mails,
|
||||
decision_ids: existingDecisionIds,
|
||||
});
|
||||
} else {
|
||||
resetForm();
|
||||
|
|
@ -228,6 +271,36 @@ const onConfirm = () => {
|
|||
</div>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- Limit to specific decisions — only shown when receive_auto_mails is on and decisions exist -->
|
||||
<template v-if="(props.person?.client || isClientContext) && form.values.receive_auto_mails && decisionOptions.length > 0">
|
||||
<div class="flex flex-row items-start space-x-3 space-y-0">
|
||||
<Switch
|
||||
:model-value="limitToDecisions"
|
||||
@update:model-value="(val) => (limitToDecisions = val)"
|
||||
/>
|
||||
<div class="space-y-1 leading-none">
|
||||
<label class="text-sm font-medium leading-none cursor-pointer" @click="limitToDecisions = !limitToDecisions">
|
||||
Omeji na posamezne odločitve
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormField v-if="limitToDecisions" v-slot="{ value, handleChange }" name="decision_ids">
|
||||
<FormItem>
|
||||
<FormLabel>Odločitve, za katere se pošlje e-pošta</FormLabel>
|
||||
<FormControl>
|
||||
<AppMultiSelect
|
||||
:model-value="value ?? []"
|
||||
:items="decisionOptions"
|
||||
placeholder="Izberi odločitve..."
|
||||
@update:model-value="handleChange"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</template>
|
||||
</div>
|
||||
</form>
|
||||
</component>
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ import {
|
|||
InboxIcon,
|
||||
AtSignIcon,
|
||||
BookUserIcon,
|
||||
MessageSquareIcon,
|
||||
ArrowLeftIcon,
|
||||
} from "lucide-vue-next";
|
||||
import Dropdown from "@/Components/Dropdown.vue";
|
||||
|
|
@ -211,13 +210,6 @@ const navGroups = computed(() => [
|
|||
icon: Settings2Icon,
|
||||
active: ["admin.sms-profiles.index"],
|
||||
},
|
||||
{
|
||||
key: "admin.packages.index",
|
||||
label: "SMS paketi",
|
||||
route: "admin.packages.index",
|
||||
icon: MessageSquareIcon,
|
||||
active: ["admin.packages.index", "admin.packages.show"],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ import { SettingsIcon } from "lucide-vue-next";
|
|||
import { ShieldUserIcon } from "lucide-vue-next";
|
||||
import { SmartphoneIcon } from "lucide-vue-next";
|
||||
import { TabletSmartphoneIcon } from "lucide-vue-next";
|
||||
import { PhoneCallIcon } from "lucide-vue-next";
|
||||
import { PackageIcon } from "lucide-vue-next";
|
||||
|
||||
const props = defineProps({
|
||||
title: String,
|
||||
|
|
@ -157,6 +159,13 @@ const rawMenuGroups = [
|
|||
routeName: "segments.index",
|
||||
active: ["segments.index"],
|
||||
},
|
||||
{
|
||||
key: "call-laters",
|
||||
icon: PhoneCallIcon,
|
||||
title: "Pokliči kasneje",
|
||||
routeName: "callLaters.index",
|
||||
active: ["callLaters.index"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
@ -212,6 +221,13 @@ const rawMenuGroups = [
|
|||
routeName: "settings",
|
||||
active: ["settings", "settings.*"],
|
||||
},
|
||||
{
|
||||
key: "packages",
|
||||
icon: PackageIcon,
|
||||
title: "SMS paketi",
|
||||
routeName: "packages.index",
|
||||
active: ["packages.index", "packages.show", "packages.create"],
|
||||
},
|
||||
// Admin panel (roles & permissions management)
|
||||
// Only shown if current user has admin role or manage-settings permission.
|
||||
// We'll filter it out below if not authorized.
|
||||
|
|
|
|||
|
|
@ -107,12 +107,6 @@ const cards = [
|
|||
route: "admin.sms-logs.index",
|
||||
icon: InboxIcon,
|
||||
},
|
||||
{
|
||||
title: "SMS paketi",
|
||||
description: "Kreiranje in pošiljanje serijskih SMS paketov",
|
||||
route: "admin.packages.index",
|
||||
icon: MessageSquareIcon,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
|
|||
297
resources/js/Pages/CallLaters/Index.vue
Normal file
297
resources/js/Pages/CallLaters/Index.vue
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
<script setup>
|
||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||
import { Link, router } from "@inertiajs/vue3";
|
||||
import { computed, ref } from "vue";
|
||||
import DataTable from "@/Components/DataTable/DataTableNew2.vue";
|
||||
import AppCard from "@/Components/app/ui/card/AppCard.vue";
|
||||
import CardTitle from "@/Components/ui/card/CardTitle.vue";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import InputLabel from "@/Components/InputLabel.vue";
|
||||
import Pagination from "@/Components/Pagination.vue";
|
||||
import {
|
||||
PhoneCallIcon,
|
||||
CheckIcon,
|
||||
Filter,
|
||||
ExternalLinkIcon,
|
||||
MoreHorizontalIcon,
|
||||
} from "lucide-vue-next";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/Components/ui/dropdown-menu";
|
||||
|
||||
import AppPopover from "@/Components/app/ui/AppPopover.vue";
|
||||
|
||||
const props = defineProps({
|
||||
callLaters: Object,
|
||||
filters: Object,
|
||||
});
|
||||
|
||||
const search = ref(props.filters?.search || "");
|
||||
const dateFrom = ref(props.filters?.date_from || "");
|
||||
const dateTo = ref(props.filters?.date_to || "");
|
||||
const filterPopoverOpen = ref(false);
|
||||
|
||||
const appliedFilterCount = computed(() => {
|
||||
let count = 0;
|
||||
if (search.value?.trim()) count += 1;
|
||||
if (dateFrom.value) count += 1;
|
||||
if (dateTo.value) count += 1;
|
||||
return count;
|
||||
});
|
||||
|
||||
function applyFilters() {
|
||||
filterPopoverOpen.value = false;
|
||||
const params = {};
|
||||
if (search.value?.trim()) {
|
||||
params.search = search.value.trim();
|
||||
}
|
||||
if (dateFrom.value) {
|
||||
params.date_from = dateFrom.value;
|
||||
}
|
||||
if (dateTo.value) {
|
||||
params.date_to = dateTo.value;
|
||||
}
|
||||
router.get(route("callLaters.index"), params, {
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
preserveScroll: true,
|
||||
});
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
search.value = "";
|
||||
dateFrom.value = "";
|
||||
dateTo.value = "";
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function markDone(item) {
|
||||
router.patch(
|
||||
route("callLaters.complete", item.id),
|
||||
{},
|
||||
{
|
||||
preserveScroll: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function openAndComplete(item) {
|
||||
router.patch(
|
||||
route("callLaters.complete", item.id),
|
||||
{},
|
||||
{
|
||||
preserveScroll: false,
|
||||
onSuccess: () => {
|
||||
if (item.client_case?.uuid) {
|
||||
router.visit(route("clientCase.show", { client_case: item.client_case.uuid }));
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function isOverdue(item) {
|
||||
if (!item.call_back_at) return false;
|
||||
return new Date(item.call_back_at) < new Date();
|
||||
}
|
||||
|
||||
function fmtDateTime(value) {
|
||||
if (!value) return "-";
|
||||
const d = new Date(value);
|
||||
if (isNaN(d.getTime())) return value;
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
const month = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const year = d.getFullYear();
|
||||
const hours = String(d.getHours()).padStart(2, "0");
|
||||
const minutes = String(d.getMinutes()).padStart(2, "0");
|
||||
return `${day}.${month}.${year} ${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ key: "person", label: "Stranka / Primer", sortable: false },
|
||||
{ key: "contract", label: "Pogodba", sortable: false },
|
||||
{ key: "call_back_at", label: "Datum klica", sortable: false },
|
||||
{ key: "user", label: "Agent", sortable: false },
|
||||
{ key: "note", label: "Opomba", sortable: false },
|
||||
{ key: "actions", label: "", sortable: false, class: "w-12" },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout title="Pokliči kasneje">
|
||||
<template #header></template>
|
||||
<div class="py-6">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<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">
|
||||
<PhoneCallIcon :size="18" />
|
||||
<CardTitle class="uppercase">Pokliči kasneje</CardTitle>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="callLaters.data || []"
|
||||
:meta="callLaters"
|
||||
:search="search"
|
||||
route-name="callLaters.index"
|
||||
:show-toolbar="true"
|
||||
:show-pagination="false"
|
||||
:hoverable="true"
|
||||
row-key="id"
|
||||
empty-text="Ni zakazanih klicev."
|
||||
:row-class="(row) => (isOverdue(row) ? 'bg-red-50 dark:bg-red-950/20' : '')"
|
||||
>
|
||||
<template #toolbar-filters>
|
||||
<AppPopover
|
||||
v-model:open="filterPopoverOpen"
|
||||
align="start"
|
||||
content-class="w-[420px]"
|
||||
>
|
||||
<template #trigger>
|
||||
<Button variant="outline" size="sm" class="gap-2">
|
||||
<Filter class="h-4 w-4" />
|
||||
Filtri
|
||||
<span
|
||||
v-if="appliedFilterCount > 0"
|
||||
class="ml-1 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground"
|
||||
>
|
||||
{{ appliedFilterCount }}
|
||||
</span>
|
||||
</Button>
|
||||
</template>
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<h4 class="font-medium text-sm">Filtri klicev</h4>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-1.5">
|
||||
<InputLabel>Iskanje (stranka)</InputLabel>
|
||||
<Input
|
||||
v-model="search"
|
||||
type="text"
|
||||
placeholder="Ime stranke..."
|
||||
@keydown.enter="applyFilters"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<InputLabel>Datum od</InputLabel>
|
||||
<Input v-model="dateFrom" type="date" />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<InputLabel>Datum do</InputLabel>
|
||||
<Input v-model="dateTo" type="date" />
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 pt-2 border-t">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="appliedFilterCount === 0"
|
||||
@click="clearFilters"
|
||||
>
|
||||
Počisti
|
||||
</Button>
|
||||
<Button type="button" size="sm" @click="applyFilters">
|
||||
Uporabi
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppPopover>
|
||||
</template>
|
||||
|
||||
<template #cell-person="{ row }">
|
||||
<div>
|
||||
<Link
|
||||
v-if="row.client_case"
|
||||
:href="route('clientCase.show', { client_case: row.client_case.uuid })"
|
||||
class="font-medium text-indigo-600 hover:underline"
|
||||
>
|
||||
{{ row.client_case.person?.full_name || "-" }}
|
||||
</Link>
|
||||
<span v-else class="text-muted-foreground">-</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-contract="{ row }">
|
||||
<span v-if="row.contract">{{ row.contract.reference }}</span>
|
||||
<span v-else class="text-muted-foreground">-</span>
|
||||
</template>
|
||||
|
||||
<template #cell-call_back_at="{ row }">
|
||||
<span
|
||||
:class="[
|
||||
'font-medium',
|
||||
isOverdue(row) ? 'text-red-600 dark:text-red-400' : '',
|
||||
]"
|
||||
>
|
||||
{{ fmtDateTime(row.call_back_at) }}
|
||||
</span>
|
||||
<span v-if="isOverdue(row)" class="ml-2 text-xs text-red-500 font-semibold">
|
||||
Zamuda
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-user="{ row }">
|
||||
<span v-if="row.user">{{ row.user.name }}</span>
|
||||
<span v-else class="text-muted-foreground">-</span>
|
||||
</template>
|
||||
|
||||
<template #cell-note="{ row }">
|
||||
<span class="line-clamp-2 text-sm text-muted-foreground">
|
||||
{{ row.activity?.note || "-" }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button size="icon" variant="ghost" class="h-8 w-8">
|
||||
<MoreHorizontalIcon class="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem @click="markDone(row)">
|
||||
<CheckIcon class="mr-2 h-4 w-4" />
|
||||
Opravljeno
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
v-if="row.client_case?.uuid"
|
||||
@click="openAndComplete(row)"
|
||||
>
|
||||
<ExternalLinkIcon class="mr-2 h-4 w-4" />
|
||||
Odpri in opravi
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<div class="border-t border-gray-200 p-4">
|
||||
<Pagination
|
||||
:links="callLaters.links"
|
||||
:from="callLaters.from"
|
||||
:to="callLaters.to"
|
||||
:total="callLaters.total"
|
||||
:per-page="callLaters.per_page || 50"
|
||||
:last-page="callLaters.last_page"
|
||||
:current-page="callLaters.current_page"
|
||||
/>
|
||||
</div>
|
||||
</AppCard>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
|
@ -58,6 +58,8 @@ const form = useInertiaForm({
|
|||
send_auto_mail: true,
|
||||
attach_documents: false,
|
||||
attachment_document_ids: [],
|
||||
call_back_at_date: null,
|
||||
call_back_at_time: null,
|
||||
});
|
||||
|
||||
watch(
|
||||
|
|
@ -127,6 +129,20 @@ const store = async () => {
|
|||
|
||||
const isMultipleContracts = contractUuids && contractUuids.length > 1;
|
||||
|
||||
const buildCallBackAt = (date, time) => {
|
||||
if (!date) return null;
|
||||
const t = time || '00:00';
|
||||
const [h, m] = t.split(':');
|
||||
const d = date instanceof Date ? date : new Date(date);
|
||||
if (isNaN(d.getTime())) return null;
|
||||
const y = d.getFullYear();
|
||||
const mo = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dy = String(d.getDate()).padStart(2, '0');
|
||||
const hh = String(Number(h || 0)).padStart(2, '0');
|
||||
const mm = String(Number(m || 0)).padStart(2, '0');
|
||||
return `${y}-${mo}-${dy} ${hh}:${mm}:00`;
|
||||
};
|
||||
|
||||
form
|
||||
.transform((data) => ({
|
||||
...data,
|
||||
|
|
@ -138,11 +154,16 @@ const store = async () => {
|
|||
templateAllowsAttachments.value && data.attach_documents && !isMultipleContracts
|
||||
? data.attachment_document_ids
|
||||
: [],
|
||||
call_back_at: hasCallLaterEvent.value
|
||||
? buildCallBackAt(data.call_back_at_date, data.call_back_at_time)
|
||||
: null,
|
||||
call_back_at_date: undefined,
|
||||
call_back_at_time: undefined,
|
||||
}))
|
||||
.post(route("clientCase.activity.store", props.client_case), {
|
||||
onSuccess: () => {
|
||||
close();
|
||||
form.reset("due_date", "amount", "note", "contract_uuids");
|
||||
form.reset("due_date", "amount", "note", "contract_uuids", "call_back_at_date", "call_back_at_time");
|
||||
emit("saved");
|
||||
},
|
||||
});
|
||||
|
|
@ -156,6 +177,22 @@ const currentDecision = () => {
|
|||
decisions.value.find((d) => d.id === form.decision_id) || decisions.value[0] || null
|
||||
);
|
||||
};
|
||||
|
||||
const hasCallLaterEvent = computed(() => {
|
||||
const d = currentDecision();
|
||||
if (!d) return false;
|
||||
return Array.isArray(d.events) && d.events.some((e) => e.key === 'add_call_later');
|
||||
});
|
||||
|
||||
watch(
|
||||
() => hasCallLaterEvent.value,
|
||||
(has) => {
|
||||
if (!has) {
|
||||
form.call_back_at_date = null;
|
||||
form.call_back_at_time = null;
|
||||
}
|
||||
}
|
||||
);
|
||||
const showSendAutoMail = () => {
|
||||
const d = currentDecision();
|
||||
return !!(d && d.auto_mail && d.email_template_id);
|
||||
|
|
@ -409,6 +446,26 @@ watch(
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="hasCallLaterEvent" class="space-y-2">
|
||||
<Label>Datum in ura povratnega klica</Label>
|
||||
<div class="flex gap-2">
|
||||
<DatePicker
|
||||
v-model="form.call_back_at_date"
|
||||
format="dd.MM.yyyy"
|
||||
:error="form.errors.call_back_at"
|
||||
class="flex-1"
|
||||
/>
|
||||
<input
|
||||
v-model="form.call_back_at_time"
|
||||
type="time"
|
||||
class="flex-1 border rounded-md px-3 py-2 text-sm bg-background focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="form.errors.call_back_at" class="text-xs text-destructive">
|
||||
{{ form.errors.call_back_at }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="activityAmount">Znesek</Label>
|
||||
<CurrencyInput
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup>
|
||||
import AdminLayout from "@/Layouts/AdminLayout.vue";
|
||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||
import { Link, router, useForm } from "@inertiajs/vue3";
|
||||
import { ref, computed, nextTick } from "vue";
|
||||
import axios from "axios";
|
||||
|
|
@ -112,9 +112,9 @@ function submitCreate() {
|
|||
})),
|
||||
};
|
||||
|
||||
router.post(route("admin.packages.store"), payload, {
|
||||
router.post(route("packages.store"), payload, {
|
||||
onSuccess: () => {
|
||||
router.visit(route("admin.packages.index"));
|
||||
router.visit(route("packages.index"));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -202,7 +202,7 @@ async function loadContracts(url = null) {
|
|||
if (onlyValidated.value) params.append("only_validated", "1");
|
||||
params.append("per_page", perPage.value);
|
||||
|
||||
const target = url || `${route("admin.packages.contracts")}?${params.toString()}`;
|
||||
const target = url || `${route("packages.contracts")}?${params.toString()}`;
|
||||
const { data: json } = await axios.get(target, {
|
||||
headers: { "X-Requested-With": "XMLHttpRequest" },
|
||||
});
|
||||
|
|
@ -268,7 +268,7 @@ function goToPage(page) {
|
|||
params.append("per_page", perPage.value);
|
||||
params.append("page", page);
|
||||
|
||||
const url = `${route("admin.packages.contracts")}?${params.toString()}`;
|
||||
const url = `${route("packages.contracts")}?${params.toString()}`;
|
||||
loadContracts(url);
|
||||
}
|
||||
|
||||
|
|
@ -312,9 +312,9 @@ function submitCreateFromContracts() {
|
|||
};
|
||||
|
||||
creatingFromContracts.value = true;
|
||||
router.post(route("admin.packages.store-from-contracts"), payload, {
|
||||
router.post(route("packages.store-from-contracts"), payload, {
|
||||
onSuccess: () => {
|
||||
router.visit(route("admin.packages.index"));
|
||||
router.visit(route("packages.index"));
|
||||
},
|
||||
onError: (errors) => {
|
||||
const first = errors && Object.values(errors)[0];
|
||||
|
|
@ -337,11 +337,11 @@ const numbersCount = computed(() => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<AdminLayout title="Ustvari SMS paket">
|
||||
<AppLayout title="Ustvari SMS paket">
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<Link :href="route('admin.packages.index')">
|
||||
<Link :href="route('packages.index')">
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeftIcon class="h-4 w-4 mr-2" />
|
||||
Nazaj
|
||||
|
|
@ -520,7 +520,7 @@ const numbersCount = computed(() => {
|
|||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button
|
||||
@click="router.visit(route('admin.packages.index'))"
|
||||
@click="router.visit(route('packages.index'))"
|
||||
variant="outline"
|
||||
>
|
||||
Prekliči
|
||||
|
|
@ -703,7 +703,7 @@ const numbersCount = computed(() => {
|
|||
Izbrano: {{ selectedContractIds.size }}
|
||||
</Badge>
|
||||
<Button
|
||||
@click="router.visit(route('admin.packages.index'))"
|
||||
@click="router.visit(route('packages.index'))"
|
||||
variant="outline"
|
||||
>
|
||||
Prekliči
|
||||
|
|
@ -806,5 +806,5 @@ const numbersCount = computed(() => {
|
|||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</AdminLayout>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup>
|
||||
import AdminLayout from "@/Layouts/AdminLayout.vue";
|
||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||
import { Link, router } from "@inertiajs/vue3";
|
||||
import { ref } from "vue";
|
||||
import { Card, CardHeader, CardTitle } from "@/Components/ui/card";
|
||||
|
|
@ -48,7 +48,7 @@ function getStatusVariant(status) {
|
|||
}
|
||||
|
||||
function goShow(id) {
|
||||
router.visit(route("admin.packages.show", id));
|
||||
router.visit(route("packages.show", id));
|
||||
}
|
||||
|
||||
function openDeleteDialog(pkg) {
|
||||
|
|
@ -60,7 +60,7 @@ function openDeleteDialog(pkg) {
|
|||
function confirmDelete() {
|
||||
if (!packageToDelete.value) return;
|
||||
deletingId.value = packageToDelete.value.id;
|
||||
router.delete(route("admin.packages.destroy", packageToDelete.value.id), {
|
||||
router.delete(route("packages.destroy", packageToDelete.value.id), {
|
||||
onSuccess: () => {
|
||||
router.reload({ only: ["packages"] });
|
||||
},
|
||||
|
|
@ -74,7 +74,7 @@ function confirmDelete() {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<AdminLayout title="SMS paketi">
|
||||
<AppLayout title="SMS paketi">
|
||||
<Card class="mb-4">
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
|
|
@ -82,7 +82,7 @@ function confirmDelete() {
|
|||
<PackageIcon class="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle>SMS paketi</CardTitle>
|
||||
</div>
|
||||
<Link :href="route('admin.packages.create')">
|
||||
<Link :href="route('packages.create')">
|
||||
<Button>
|
||||
<PlusIcon class="h-4 w-4" />
|
||||
Nov paket
|
||||
|
|
@ -109,7 +109,7 @@ function confirmDelete() {
|
|||
:columns="columns"
|
||||
:data="packages.data"
|
||||
:meta="packages"
|
||||
route-name="admin.packages.index"
|
||||
route-name="packages.index"
|
||||
>
|
||||
<template #cell-name="{ row }">
|
||||
<span class="text-sm">{{ row.name ?? "—" }}</span>
|
||||
|
|
@ -172,5 +172,5 @@ function confirmDelete() {
|
|||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</AdminLayout>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup>
|
||||
import AdminLayout from "@/Layouts/AdminLayout.vue";
|
||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||
import { Link, router } from "@inertiajs/vue3";
|
||||
import { onMounted, onUnmounted, ref, computed } from "vue";
|
||||
import {
|
||||
|
|
@ -88,14 +88,14 @@ function reload() {
|
|||
|
||||
function dispatchPkg() {
|
||||
router.post(
|
||||
route("admin.packages.dispatch", props.package.id),
|
||||
route("packages.dispatch", props.package.id),
|
||||
{},
|
||||
{ onSuccess: reload }
|
||||
);
|
||||
}
|
||||
function cancelPkg() {
|
||||
router.post(
|
||||
route("admin.packages.cancel", props.package.id),
|
||||
route("packages.cancel", props.package.id),
|
||||
{},
|
||||
{ onSuccess: reload }
|
||||
);
|
||||
|
|
@ -132,7 +132,7 @@ async function copyText(text) {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<AdminLayout :title="`Paket #${package.id}`">
|
||||
<AppLayout :title="`Paket #${package.id}`">
|
||||
<Card class="mb-4">
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
|
|
@ -147,7 +147,7 @@ async function copyText(text) {
|
|||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" as-child>
|
||||
<Link :href="route('admin.packages.index')">
|
||||
<Link :href="route('packages.index')">
|
||||
<ArrowLeftIcon class="h-4 w-4 mr-2" />
|
||||
Nazaj
|
||||
</Link>
|
||||
|
|
@ -281,7 +281,7 @@ async function copyText(text) {
|
|||
:columns="columns"
|
||||
:data="items.data"
|
||||
:meta="items"
|
||||
route-name="admin.packages.show"
|
||||
route-name="packages.show"
|
||||
:route-params="{ id: package.id }"
|
||||
>
|
||||
<template #cell-target="{ row }">
|
||||
|
|
@ -333,5 +333,5 @@ async function copyText(text) {
|
|||
<div v-if="refreshing" class="mt-2 text-xs text-muted-foreground">
|
||||
Osveževanje ...
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
<script setup>
|
||||
// flowbite-vue table imports removed; using DataTableClient
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -27,7 +26,7 @@ import { Input } from "@/Components/ui/input";
|
|||
import AppCombobox from "@/Components/app/ui/AppCombobox.vue";
|
||||
import AppMultiSelect from "@/Components/app/ui/AppMultiSelect.vue";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import DataTableClient from "@/Components/DataTable/DataTableClient.vue";
|
||||
import DataTableNew2 from "@/Components/DataTable/DataTableNew2.vue";
|
||||
import InlineColorPicker from "@/Components/InlineColorPicker.vue";
|
||||
import AppPopover from "@/Components/app/ui/AppPopover.vue";
|
||||
import { FilterIcon, MoreHorizontal, Pencil, Trash } from "lucide-vue-next";
|
||||
|
|
@ -60,16 +59,13 @@ const segmentOptions = computed(() =>
|
|||
}))
|
||||
);
|
||||
|
||||
// DataTable state
|
||||
const sort = ref({ key: null, direction: null });
|
||||
const page = ref(1);
|
||||
const pageSize = ref(25);
|
||||
const columns = [
|
||||
{ key: "id", label: "#", sortable: true, class: "w-16" },
|
||||
{ key: "name", label: "Ime", sortable: true },
|
||||
{ key: "color_tag", label: "Barva", sortable: false },
|
||||
{ key: "segment", label: "Segment", sortable: false },
|
||||
{ key: "decisions", label: "Odločitve", sortable: false, class: "w-32" },
|
||||
{ key: "actions", label: "", sortable: false, class: "w-12" },
|
||||
];
|
||||
|
||||
const form = useForm({
|
||||
|
|
@ -231,18 +227,12 @@ const destroyAction = () => {
|
|||
<Button @click="openCreateDrawer">+ Dodaj akcijo</Button>
|
||||
</div>
|
||||
<div>
|
||||
<DataTableClient
|
||||
<DataTableNew2
|
||||
:columns="columns"
|
||||
:rows="filtered"
|
||||
:sort="sort"
|
||||
:search="''"
|
||||
:page="page"
|
||||
:pageSize="pageSize"
|
||||
:data="filtered"
|
||||
:pageSize="25"
|
||||
:showToolbar="false"
|
||||
:showPagination="true"
|
||||
@update:sort="(v) => (sort = v)"
|
||||
@update:page="(v) => (page = v)"
|
||||
@update:pageSize="(v) => (pageSize = v)"
|
||||
>
|
||||
<template #cell-color_tag="{ row }">
|
||||
<div class="flex items-center gap-2">
|
||||
|
|
@ -262,7 +252,7 @@ const destroyAction = () => {
|
|||
{{ row.segment?.name || "" }}
|
||||
</span>
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<template #cell-actions="{ row }">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="icon">
|
||||
|
|
@ -285,7 +275,7 @@ const destroyAction = () => {
|
|||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
</DataTableClient>
|
||||
</DataTableNew2>
|
||||
</div>
|
||||
|
||||
<Dialog v-model:open="drawerEdit">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
<script setup>
|
||||
// flowbite-vue table imports removed; using DataTableClient
|
||||
import { EditIcon, TrashBinIcon, DottedMenu } from "@/Utilities/Icons";
|
||||
import { DottedMenu } from "@/Utilities/Icons";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -30,11 +29,11 @@ import {
|
|||
import AppCombobox from "@/Components/app/ui/AppCombobox.vue";
|
||||
import AppMultiSelect from "@/Components/app/ui/AppMultiSelect.vue";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import DataTableClient from "@/Components/DataTable/DataTableClient.vue";
|
||||
import DataTableNew2 from "@/Components/DataTable/DataTableNew2.vue";
|
||||
import InlineColorPicker from "@/Components/InlineColorPicker.vue";
|
||||
import Dropdown from "@/Components/Dropdown.vue";
|
||||
import AppPopover from "@/Components/app/ui/AppPopover.vue";
|
||||
import { FilterIcon, Trash2, MoreHorizontal, Pencil, Trash } from "lucide-vue-next";
|
||||
import { FilterIcon, MoreHorizontal, Pencil, Trash } from "lucide-vue-next";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -64,10 +63,6 @@ const selectedEvents = ref([]);
|
|||
|
||||
const actionOptions = ref([]);
|
||||
|
||||
// DataTable state
|
||||
const sort = ref({ key: null, direction: null });
|
||||
const page = ref(1);
|
||||
const pageSize = ref(25);
|
||||
const columns = [
|
||||
{ key: "id", label: "#", sortable: true },
|
||||
{ key: "name", label: "Ime", sortable: true },
|
||||
|
|
@ -75,6 +70,7 @@ const columns = [
|
|||
{ key: "events", label: "Dogodki", sortable: false },
|
||||
{ key: "belongs", label: "Pripada akcijam", sortable: false },
|
||||
{ key: "auto_mail", label: "Auto mail", sortable: false },
|
||||
{ key: "actions", label: "", sortable: false, class: "w-12" },
|
||||
];
|
||||
|
||||
const form = useForm({
|
||||
|
|
@ -191,6 +187,8 @@ function defaultConfigForKey(key) {
|
|||
return { archive_setting_id: null, reactivate: false };
|
||||
case "end_field_job":
|
||||
return {};
|
||||
case "add_call_later":
|
||||
return {};
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
|
|
@ -466,18 +464,12 @@ const destroyDecision = () => {
|
|||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<DataTableClient
|
||||
<DataTableNew2
|
||||
:columns="columns"
|
||||
:rows="filtered"
|
||||
:sort="sort"
|
||||
:search="''"
|
||||
:page="page"
|
||||
:pageSize="pageSize"
|
||||
:data="filtered"
|
||||
:pageSize="25"
|
||||
:showToolbar="false"
|
||||
:showPagination="true"
|
||||
@update:sort="(v) => (sort = v)"
|
||||
@update:page="(v) => (page = v)"
|
||||
@update:pageSize="(v) => (pageSize = v)"
|
||||
>
|
||||
<template #cell-color_tag="{ row }">
|
||||
<div class="flex items-center gap-2">
|
||||
|
|
@ -494,14 +486,13 @@ const destroyDecision = () => {
|
|||
</template>
|
||||
<template #cell-events="{ row }">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-gray-600">{{ row.events?.length ?? 0 }}</span>
|
||||
<Dropdown align="left" width="64" :close-on-content-click="false">
|
||||
<template #trigger>
|
||||
<button
|
||||
type="button"
|
||||
class="p-1 rounded hover:bg-gray-100 border border-transparent hover:border-gray-200"
|
||||
>
|
||||
<DottedMenu size="sm" css="text-gray-600" />
|
||||
{{ row.events?.length ?? 0 }}
|
||||
</button>
|
||||
</template>
|
||||
<template #content>
|
||||
|
|
@ -549,7 +540,7 @@ const destroyDecision = () => {
|
|||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<template #cell-actions="{ row }">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="icon">
|
||||
|
|
@ -572,7 +563,7 @@ const destroyDecision = () => {
|
|||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
</DataTableClient>
|
||||
</DataTableNew2>
|
||||
</div>
|
||||
<Dialog v-model:open="drawerEdit">
|
||||
<DialogContent class="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
|
|
@ -752,6 +743,11 @@ const destroyDecision = () => {
|
|||
Ta dogodek nima dodatnih nastavitev.
|
||||
</p>
|
||||
</template>
|
||||
<template v-else-if="eventKey(ev) === 'add_call_later'">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Datum in ura povratnega klica se vneseta ob ustvarjanju aktivnosti.
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<!-- Fallback advanced editor for unknown event keys -->
|
||||
<InputLabel :for="`cfg-${idx}`" value="Napredna nastavitev (JSON)" />
|
||||
|
|
@ -981,6 +977,11 @@ const destroyDecision = () => {
|
|||
Ta dogodek nima dodatnih nastavitev.
|
||||
</p>
|
||||
</template>
|
||||
<template v-else-if="eventKey(ev) === 'add_call_later'">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Datum in ura povratnega klica se vneseta ob ustvarjanju aktivnosti.
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<InputLabel :for="`ccfg-${idx}`" value="Napredna nastavitev (JSON)" />
|
||||
<textarea
|
||||
|
|
|
|||
|
|
@ -157,18 +157,19 @@
|
|||
Route::get('sms-logs', [\App\Http\Controllers\Admin\SmsLogController::class, 'index'])->name('sms-logs.index');
|
||||
Route::get('sms-logs/{smsLog}', [\App\Http\Controllers\Admin\SmsLogController::class, 'show'])->name('sms-logs.show');
|
||||
|
||||
// Packages (batch jobs)
|
||||
Route::get('packages', [\App\Http\Controllers\Admin\PackageController::class, 'index'])->name('packages.index');
|
||||
Route::get('packages/create', [\App\Http\Controllers\Admin\PackageController::class, 'create'])->name('packages.create');
|
||||
Route::get('packages/{package}', [\App\Http\Controllers\Admin\PackageController::class, 'show'])->name('packages.show');
|
||||
Route::post('packages', [\App\Http\Controllers\Admin\PackageController::class, 'store'])->name('packages.store');
|
||||
Route::post('packages/{package}/dispatch', [\App\Http\Controllers\Admin\PackageController::class, 'dispatch'])->name('packages.dispatch');
|
||||
Route::post('packages/{package}/cancel', [\App\Http\Controllers\Admin\PackageController::class, 'cancel'])->name('packages.cancel');
|
||||
Route::delete('packages/{package}', [\App\Http\Controllers\Admin\PackageController::class, 'destroy'])->name('packages.destroy');
|
||||
// Packages - contract-based helpers
|
||||
Route::get('packages-contracts', [\App\Http\Controllers\Admin\PackageController::class, 'contracts'])->name('packages.contracts');
|
||||
Route::post('packages-from-contracts', [\App\Http\Controllers\Admin\PackageController::class, 'storeFromContracts'])->name('packages.store-from-contracts');
|
||||
});
|
||||
|
||||
// Packages (SMS batch sender) — accessible to users with manage-settings permission
|
||||
Route::middleware(['permission:manage-settings'])->prefix('packages')->name('packages.')->group(function () {
|
||||
Route::get('/', [\App\Http\Controllers\Admin\PackageController::class, 'index'])->name('index');
|
||||
Route::get('/create', [\App\Http\Controllers\Admin\PackageController::class, 'create'])->name('create');
|
||||
Route::get('/{package}', [\App\Http\Controllers\Admin\PackageController::class, 'show'])->name('show');
|
||||
Route::post('/', [\App\Http\Controllers\Admin\PackageController::class, 'store'])->name('store');
|
||||
Route::post('/{package}/dispatch', [\App\Http\Controllers\Admin\PackageController::class, 'dispatch'])->name('dispatch');
|
||||
Route::post('/{package}/cancel', [\App\Http\Controllers\Admin\PackageController::class, 'cancel'])->name('cancel');
|
||||
Route::delete('/{package}', [\App\Http\Controllers\Admin\PackageController::class, 'destroy'])->name('destroy');
|
||||
Route::get('/contracts/list', [\App\Http\Controllers\Admin\PackageController::class, 'contracts'])->name('contracts');
|
||||
Route::post('/from-contracts', [\App\Http\Controllers\Admin\PackageController::class, 'storeFromContracts'])->name('store-from-contracts');
|
||||
});
|
||||
|
||||
// Contract document generation (JSON) - protected by auth+verified; permission enforced inside controller service
|
||||
|
|
@ -526,4 +527,8 @@
|
|||
});
|
||||
Route::get('reports/{slug}/export', [\App\Http\Controllers\ReportController::class, 'export'])->middleware('permission:reports-export')->name('reports.export');
|
||||
|
||||
// Call laters
|
||||
Route::get('call-laters', [\App\Http\Controllers\CallLaterController::class, 'index'])->name('callLaters.index');
|
||||
Route::patch('call-laters/{callLater}/complete', [\App\Http\Controllers\CallLaterController::class, 'complete'])->name('callLaters.complete');
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@
|
|||
$contract3->segments()->attach($segment->id, ['active' => true]);
|
||||
|
||||
// Test without date filters - should return all contracts
|
||||
$response = $this->getJson(route('admin.packages.contracts', [
|
||||
$response = $this->getJson(route('packages.contracts', [
|
||||
'segment_id' => $segment->id,
|
||||
]));
|
||||
|
||||
|
|
@ -81,7 +81,7 @@
|
|||
expect($data)->toHaveCount(3);
|
||||
|
||||
// Test with start_date_from filter
|
||||
$response = $this->getJson(route('admin.packages.contracts', [
|
||||
$response = $this->getJson(route('packages.contracts', [
|
||||
'segment_id' => $segment->id,
|
||||
'start_date_from' => '2024-02-01',
|
||||
]));
|
||||
|
|
@ -92,7 +92,7 @@
|
|||
expect(collect($data)->pluck('reference'))->toContain('CONTRACT-2024-002', 'CONTRACT-2024-003');
|
||||
|
||||
// Test with start_date_to filter
|
||||
$response = $this->getJson(route('admin.packages.contracts', [
|
||||
$response = $this->getJson(route('packages.contracts', [
|
||||
'segment_id' => $segment->id,
|
||||
'start_date_to' => '2024-03-31',
|
||||
]));
|
||||
|
|
@ -103,7 +103,7 @@
|
|||
expect(collect($data)->pluck('reference'))->toContain('CONTRACT-2024-001', 'CONTRACT-2024-002');
|
||||
|
||||
// Test with both date filters
|
||||
$response = $this->getJson(route('admin.packages.contracts', [
|
||||
$response = $this->getJson(route('packages.contracts', [
|
||||
'segment_id' => $segment->id,
|
||||
'start_date_from' => '2024-02-01',
|
||||
'start_date_to' => '2024-04-30',
|
||||
|
|
@ -133,7 +133,7 @@
|
|||
$segment = Segment::factory()->create(['active' => true]);
|
||||
|
||||
// Test invalid start_date_from
|
||||
$response = $this->getJson(route('admin.packages.contracts', [
|
||||
$response = $this->getJson(route('packages.contracts', [
|
||||
'segment_id' => $segment->id,
|
||||
'start_date_from' => 'invalid-date',
|
||||
]));
|
||||
|
|
@ -142,7 +142,7 @@
|
|||
$response->assertJsonValidationErrors('start_date_from');
|
||||
|
||||
// Test invalid start_date_to
|
||||
$response = $this->getJson(route('admin.packages.contracts', [
|
||||
$response = $this->getJson(route('packages.contracts', [
|
||||
'segment_id' => $segment->id,
|
||||
'start_date_to' => 'invalid-date',
|
||||
]));
|
||||
|
|
|
|||
46
tests/Feature/AutoMailDecisionFilterTest.php
Normal file
46
tests/Feature/AutoMailDecisionFilterTest.php
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
// Override the global uses() so these pure-logic tests skip RefreshDatabase
|
||||
uses(\PHPUnit\Framework\TestCase::class);
|
||||
|
||||
/**
|
||||
* Unit-level tests for the decision_ids filter logic used in AutoMailDispatcher.
|
||||
* These tests execute the filter predicate in isolation without database interaction.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Simulates the filter closure from AutoMailDispatcher::maybeQueue().
|
||||
*
|
||||
* @param array<string,mixed> $preferences
|
||||
*/
|
||||
function emailPassesDecisionFilter(array $preferences, int $decisionId): bool
|
||||
{
|
||||
$decisionIds = $preferences['decision_ids'] ?? [];
|
||||
|
||||
if (empty($decisionIds)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array($decisionId, array_map('intval', $decisionIds), true);
|
||||
}
|
||||
|
||||
it('email with no decision_ids restriction passes the filter for any decision', function () {
|
||||
expect(emailPassesDecisionFilter([], 5))->toBeTrue();
|
||||
});
|
||||
|
||||
it('email with a matching decision_id in preferences passes the filter', function () {
|
||||
expect(emailPassesDecisionFilter(['decision_ids' => [3, 7]], 7))->toBeTrue();
|
||||
});
|
||||
|
||||
it('email with a non-matching decision_id in preferences is filtered out', function () {
|
||||
expect(emailPassesDecisionFilter(['decision_ids' => [3, 7]], 99))->toBeFalse();
|
||||
});
|
||||
|
||||
it('email with empty preferences is treated as no restriction', function () {
|
||||
expect(emailPassesDecisionFilter([], 42))->toBeTrue();
|
||||
});
|
||||
|
||||
it('string decision ids in preferences are cast to int for comparison', function () {
|
||||
expect(emailPassesDecisionFilter(['decision_ids' => ['3', '7']], 7))->toBeTrue();
|
||||
expect(emailPassesDecisionFilter(['decision_ids' => ['3', '7']], 99))->toBeFalse();
|
||||
});
|
||||
|
|
@ -3,3 +3,4 @@
|
|||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class)->in('Feature', 'Unit');
|
||||
uses(\PHPUnit\Framework\TestCase::class)->in('Pure');
|
||||
|
|
|
|||
43
tests/Pure/AutoMailDecisionFilterTest.php
Normal file
43
tests/Pure/AutoMailDecisionFilterTest.php
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Unit-level tests for the decision_ids filter logic used in AutoMailDispatcher.
|
||||
* These tests execute the filter predicate in isolation without database interaction.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Simulates the filter closure from AutoMailDispatcher::maybeQueue().
|
||||
*
|
||||
* @param array<string,mixed> $preferences
|
||||
*/
|
||||
function emailPassesDecisionFilter(array $preferences, int $decisionId): bool
|
||||
{
|
||||
$decisionIds = $preferences['decision_ids'] ?? [];
|
||||
|
||||
if (empty($decisionIds)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array($decisionId, array_map('intval', $decisionIds), true);
|
||||
}
|
||||
|
||||
it('email with no decision_ids restriction passes the filter for any decision', function () {
|
||||
expect(emailPassesDecisionFilter([], 5))->toBeTrue();
|
||||
});
|
||||
|
||||
it('email with a matching decision_id in preferences passes the filter', function () {
|
||||
expect(emailPassesDecisionFilter(['decision_ids' => [3, 7]], 7))->toBeTrue();
|
||||
});
|
||||
|
||||
it('email with a non-matching decision_id in preferences is filtered out', function () {
|
||||
expect(emailPassesDecisionFilter(['decision_ids' => [3, 7]], 99))->toBeFalse();
|
||||
});
|
||||
|
||||
it('email with empty preferences is treated as no restriction', function () {
|
||||
expect(emailPassesDecisionFilter([], 42))->toBeTrue();
|
||||
});
|
||||
|
||||
it('string decision ids in preferences are cast to int for comparison', function () {
|
||||
expect(emailPassesDecisionFilter(['decision_ids' => ['3', '7']], 7))->toBeTrue();
|
||||
expect(emailPassesDecisionFilter(['decision_ids' => ['3', '7']], 99))->toBeFalse();
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user