Mass changes
This commit is contained in:
parent
ab50336e97
commit
fe91c7e4bc
|
|
@ -1121,4 +1121,53 @@ public function destroy(string $id)
|
||||||
{
|
{
|
||||||
//
|
//
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a document that belongs either directly to the client case or to one of its (even soft deleted) contracts.
|
||||||
|
*/
|
||||||
|
public function deleteDocument(ClientCase $clientCase, Document $document, Request $request)
|
||||||
|
{
|
||||||
|
// Ownership check: direct case document?
|
||||||
|
$belongsToCase = $document->documentable_type === ClientCase::class && $document->documentable_id === $clientCase->id;
|
||||||
|
|
||||||
|
// Or document of a contract that belongs to this case (include trashed contracts)
|
||||||
|
$belongsToContractOfCase = false;
|
||||||
|
if ($document->documentable_type === Contract::class) {
|
||||||
|
$belongsToContractOfCase = Contract::withTrashed()
|
||||||
|
->where('id', $document->documentable_id)
|
||||||
|
->where('client_case_id', $clientCase->id)
|
||||||
|
->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! ($belongsToCase || $belongsToContractOfCase)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// (Optional future) $this->authorize('delete', $document);
|
||||||
|
|
||||||
|
$document->delete(); // soft delete
|
||||||
|
|
||||||
|
return $request->wantsJson()
|
||||||
|
? response()->json(['status' => 'ok'])
|
||||||
|
: back()->with('success', 'Document deleted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a document accessed through a contract route binding.
|
||||||
|
*/
|
||||||
|
public function deleteContractDocument(Contract $contract, Document $document, Request $request)
|
||||||
|
{
|
||||||
|
$belongs = $document->documentable_type === Contract::class && $document->documentable_id === $contract->id;
|
||||||
|
if (! $belongs) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// (Optional future) $this->authorize('delete', $document);
|
||||||
|
|
||||||
|
$document->delete();
|
||||||
|
|
||||||
|
return $request->wantsJson()
|
||||||
|
? response()->json(['status' => 'ok'])
|
||||||
|
: back()->with('success', 'Document deleted.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@
|
||||||
use App\Models\FieldJob;
|
use App\Models\FieldJob;
|
||||||
use App\Models\FieldJobSetting;
|
use App\Models\FieldJobSetting;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use Exception;
|
||||||
|
use Illuminate\Database\QueryException;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
|
|
@ -83,9 +85,12 @@ public function assign(Request $request)
|
||||||
'assigned_user_id' => 'required|integer|exists:users,id',
|
'assigned_user_id' => 'required|integer|exists:users,id',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
DB::transaction(function () use ($data) {
|
||||||
$setting = FieldJobSetting::query()->latest('id')->first();
|
$setting = FieldJobSetting::query()->latest('id')->first();
|
||||||
|
|
||||||
if (! $setting) {
|
if (! $setting) {
|
||||||
return back()->withErrors(['setting' => 'No Field Job Setting found. Create one in Settings → Field Job Settings.']);
|
throw new Exception('No Field Job Setting found. Create one in Settings → Field Job Settings.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$contract = Contract::query()->where('uuid', $data['contract_uuid'])->firstOrFail();
|
$contract = Contract::query()->where('uuid', $data['contract_uuid'])->firstOrFail();
|
||||||
|
|
@ -96,20 +101,8 @@ public function assign(Request $request)
|
||||||
'contract_id' => $contract->id,
|
'contract_id' => $contract->id,
|
||||||
'assigned_at' => now(),
|
'assigned_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Create an activity for the assignment
|
// Create an activity for the assignment
|
||||||
// Find the first action linked to the assign decision via pivot; also prefer actions within the same segment as the setting
|
if ($setting->action_id && $setting->assign_decision_id) {
|
||||||
$decisionId = $setting->assign_decision_id;
|
|
||||||
$actionId = null;
|
|
||||||
if ($decisionId) {
|
|
||||||
// Strictly use the action_decision pivot: take the first action mapped to this decision
|
|
||||||
$actionId = DB::table('action_decision')
|
|
||||||
->where('decision_id', $decisionId)
|
|
||||||
->orderBy('id')
|
|
||||||
->value('action_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($actionId) {
|
|
||||||
$assigneeName = User::query()->where('id', $data['assigned_user_id'])->value('name');
|
$assigneeName = User::query()->where('id', $data['assigned_user_id'])->value('name');
|
||||||
// Localized note: "Terensko opravilo dodeljeno" + assignee when present
|
// Localized note: "Terensko opravilo dodeljeno" + assignee when present
|
||||||
$note = 'Terensko opravilo dodeljeno'.($assigneeName ? ' uporabniku '.$assigneeName : '');
|
$note = 'Terensko opravilo dodeljeno'.($assigneeName ? ' uporabniku '.$assigneeName : '');
|
||||||
|
|
@ -117,14 +110,26 @@ public function assign(Request $request)
|
||||||
'due_date' => null,
|
'due_date' => null,
|
||||||
'amount' => null,
|
'amount' => null,
|
||||||
'note' => $note,
|
'note' => $note,
|
||||||
'action_id' => $actionId,
|
'action_id' => $setting->action_id,
|
||||||
'decision_id' => $decisionId,
|
'decision_id' => $setting->assign_decision_id,
|
||||||
'client_case_id' => $contract->client_case_id,
|
'client_case_id' => $contract->client_case_id,
|
||||||
'contract_id' => $contract->id,
|
'contract_id' => $contract->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Move contract to the configured segment for field jobs
|
||||||
|
$job->moveContractToSegment($setting->segment_id);
|
||||||
|
} else {
|
||||||
|
throw new Exception('The current Field Job Setting is missing an action or assign decision. Please update it in Settings → Field Job Settings.');
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return back()->with('success', 'Field job assigned.');
|
return back()->with('success', 'Field job assigned.');
|
||||||
|
} catch (QueryException $e) {
|
||||||
|
return back()->withErrors(['database' => 'Database error: '.$e->getMessage()]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
return back()->withErrors(['error' => 'Error: '.$e->getMessage()]);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function cancel(Request $request)
|
public function cancel(Request $request)
|
||||||
|
|
@ -149,14 +154,12 @@ public function cancel(Request $request)
|
||||||
// Create an activity for the cancellation, mirroring the assign flow
|
// Create an activity for the cancellation, mirroring the assign flow
|
||||||
// Prefer the job's setting for a consistent decision
|
// Prefer the job's setting for a consistent decision
|
||||||
$job->loadMissing('setting');
|
$job->loadMissing('setting');
|
||||||
|
$actionId = optional($job->setting)->action_id;
|
||||||
$decisionId = optional($job->setting)->cancel_decision_id;
|
$decisionId = optional($job->setting)->cancel_decision_id;
|
||||||
if ($decisionId) {
|
|
||||||
$actionId = DB::table('action_decision')
|
|
||||||
->where('decision_id', $decisionId)
|
|
||||||
->orderBy('id')
|
|
||||||
->value('action_id');
|
|
||||||
|
|
||||||
if ($actionId) {
|
// If no decision configured, skip logging
|
||||||
|
if ($actionId && $decisionId) {
|
||||||
|
|
||||||
Activity::create([
|
Activity::create([
|
||||||
'due_date' => null,
|
'due_date' => null,
|
||||||
'amount' => null,
|
'amount' => null,
|
||||||
|
|
@ -166,7 +169,7 @@ public function cancel(Request $request)
|
||||||
'client_case_id' => $contract->client_case_id,
|
'client_case_id' => $contract->client_case_id,
|
||||||
'contract_id' => $contract->id,
|
'contract_id' => $contract->id,
|
||||||
]);
|
]);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -183,13 +186,7 @@ public function complete(Request $request, \App\Models\ClientCase $clientCase)
|
||||||
}
|
}
|
||||||
|
|
||||||
$decisionId = $setting->complete_decision_id;
|
$decisionId = $setting->complete_decision_id;
|
||||||
$actionId = null;
|
$actionId = $setting->action_id;
|
||||||
if ($decisionId) {
|
|
||||||
$actionId = DB::table('action_decision')
|
|
||||||
->where('decision_id', $decisionId)
|
|
||||||
->orderBy('id')
|
|
||||||
->value('action_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find all active jobs for this case for the current user
|
// Find all active jobs for this case for the current user
|
||||||
$jobs = FieldJob::query()
|
$jobs = FieldJob::query()
|
||||||
|
|
|
||||||
|
|
@ -15,13 +15,14 @@ class FieldJobSettingController extends Controller
|
||||||
public function index(Request $request)
|
public function index(Request $request)
|
||||||
{
|
{
|
||||||
$settings = FieldJobSetting::query()
|
$settings = FieldJobSetting::query()
|
||||||
->with(['segment', 'initialDecision', 'assignDecision', 'completeDecision', 'cancelDecision', 'returnSegment', 'queueSegment'])
|
->with(['action', 'segment', 'initialDecision', 'assignDecision', 'completeDecision', 'cancelDecision', 'returnSegment', 'queueSegment'])
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
return Inertia::render('Settings/FieldJob/Index', [
|
return Inertia::render('Settings/FieldJob/Index', [
|
||||||
'settings' => $settings,
|
'settings' => $settings,
|
||||||
'segments' => Segment::query()->get(),
|
'segments' => Segment::query()->get(),
|
||||||
'decisions' => Decision::query()->get(),
|
'actions' => \App\Models\Action::query()->with(['decisions:id'])->get(),
|
||||||
|
'decisions' => Decision::query()->with(['actions:id'])->get(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -34,6 +35,7 @@ public function store(StoreFieldJobSettingRequest $request)
|
||||||
'initial_decision_id' => $attributes['initial_decision_id'],
|
'initial_decision_id' => $attributes['initial_decision_id'],
|
||||||
'assign_decision_id' => $attributes['assign_decision_id'],
|
'assign_decision_id' => $attributes['assign_decision_id'],
|
||||||
'complete_decision_id' => $attributes['complete_decision_id'],
|
'complete_decision_id' => $attributes['complete_decision_id'],
|
||||||
|
'action_id' => $attributes['action_id'] ?? null,
|
||||||
'cancel_decision_id' => $attributes['cancel_decision_id'] ?? null,
|
'cancel_decision_id' => $attributes['cancel_decision_id'] ?? null,
|
||||||
'return_segment_id' => $attributes['return_segment_id'] ?? null,
|
'return_segment_id' => $attributes['return_segment_id'] ?? null,
|
||||||
'queue_segment_id' => $attributes['queue_segment_id'] ?? null,
|
'queue_segment_id' => $attributes['queue_segment_id'] ?? null,
|
||||||
|
|
@ -54,6 +56,7 @@ public function update(FieldJobSetting $setting, UpdateFieldJobSettingRequest $r
|
||||||
'cancel_decision_id' => $attributes['cancel_decision_id'] ?? null,
|
'cancel_decision_id' => $attributes['cancel_decision_id'] ?? null,
|
||||||
'return_segment_id' => $attributes['return_segment_id'] ?? null,
|
'return_segment_id' => $attributes['return_segment_id'] ?? null,
|
||||||
'queue_segment_id' => $attributes['queue_segment_id'] ?? null,
|
'queue_segment_id' => $attributes['queue_segment_id'] ?? null,
|
||||||
|
'action_id' => $attributes['action_id'] ?? null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return to_route('settings.fieldjob.index')->with('success', 'Field job setting updated successfully!');
|
return to_route('settings.fieldjob.index')->with('success', 'Field job setting updated successfully!');
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Account;
|
||||||
use App\Models\Client;
|
use App\Models\Client;
|
||||||
|
use App\Models\Contract;
|
||||||
use App\Models\Import;
|
use App\Models\Import;
|
||||||
use App\Models\ImportEvent;
|
use App\Models\ImportEvent;
|
||||||
use App\Models\ImportTemplate;
|
use App\Models\ImportTemplate;
|
||||||
|
|
@ -366,6 +368,122 @@ public function getEvents(Import $import)
|
||||||
return response()->json(['events' => $events]);
|
return response()->json(['events' => $events]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Preview (up to N) raw CSV rows for an import for mapping review
|
||||||
|
public function preview(Import $import, Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'limit' => 'nullable|integer|min:1|max:500',
|
||||||
|
]);
|
||||||
|
$limit = (int) ($validated['limit'] ?? 200);
|
||||||
|
|
||||||
|
// Determine header/delimiter the same way as columns() stored them
|
||||||
|
$meta = $import->meta ?? [];
|
||||||
|
$hasHeader = (bool) ($meta['has_header'] ?? true);
|
||||||
|
// Forced delimiter overrides everything; else detected; fallback comma
|
||||||
|
$delimiter = $meta['forced_delimiter']
|
||||||
|
?? $meta['detected_delimiter']
|
||||||
|
?? ',';
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
$columns = [];
|
||||||
|
$truncated = false;
|
||||||
|
$path = Storage::disk($import->disk)->path($import->path);
|
||||||
|
if (! is_readable($path)) {
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'File not readable',
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
$fh = @fopen($path, 'r');
|
||||||
|
if (! $fh) {
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'Unable to open file',
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if ($hasHeader) {
|
||||||
|
$header = fgetcsv($fh, 0, $delimiter) ?: [];
|
||||||
|
$columns = array_map(function ($h) {
|
||||||
|
return is_string($h) ? trim($h) : (string) $h;
|
||||||
|
}, $header);
|
||||||
|
} else {
|
||||||
|
// Use meta stored columns when available, else infer later from widest row
|
||||||
|
$columns = is_array($meta['columns'] ?? null) ? $meta['columns'] : [];
|
||||||
|
}
|
||||||
|
$count = 0;
|
||||||
|
$widest = count($columns);
|
||||||
|
while (($data = fgetcsv($fh, 0, $delimiter)) !== false) {
|
||||||
|
if ($count >= $limit) {
|
||||||
|
$truncated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Track widest for non-header scenario
|
||||||
|
if (! $hasHeader) {
|
||||||
|
$widest = max($widest, count($data));
|
||||||
|
}
|
||||||
|
$rows[] = $data;
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
if (! $hasHeader && $widest > count($columns)) {
|
||||||
|
// Generate positional column labels if missing
|
||||||
|
$columns = [];
|
||||||
|
for ($i = 0; $i < $widest; $i++) {
|
||||||
|
$columns[] = 'col_'.($i + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
fclose($fh);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize each row into assoc keyed by columns (pad/truncate as needed)
|
||||||
|
$assocRows = [];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$assoc = [];
|
||||||
|
foreach ($columns as $i => $colName) {
|
||||||
|
$assoc[$colName] = array_key_exists($i, $r) ? $r[$i] : null;
|
||||||
|
}
|
||||||
|
$assocRows[] = $assoc;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'columns' => $columns,
|
||||||
|
'rows' => $assocRows,
|
||||||
|
'limit' => $limit,
|
||||||
|
'truncated' => $truncated,
|
||||||
|
'has_header' => $hasHeader,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simulate application of payment rows for a payments import without persisting changes.
|
||||||
|
* Returns per-row projected balance changes and resolution of contract / account references.
|
||||||
|
*/
|
||||||
|
public function simulatePayments(Import $import, Request $request)
|
||||||
|
{
|
||||||
|
// Delegate to the generic simulate method for backward compatibility.
|
||||||
|
return $this->simulate($import, $request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic simulation endpoint: projects what would happen if the import were processed
|
||||||
|
* using the first N rows and current saved mappings. Works for both payments and non-payments
|
||||||
|
* templates. For payments templates, payment-specific summaries/entities will be included
|
||||||
|
* automatically by the simulation service when mappings contain the payment root.
|
||||||
|
*/
|
||||||
|
public function simulate(Import $import, Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'limit' => 'nullable|integer|min:1|max:500',
|
||||||
|
'verbose' => 'nullable|boolean',
|
||||||
|
]);
|
||||||
|
$limit = (int) ($validated['limit'] ?? 100);
|
||||||
|
$verbose = (bool) ($validated['verbose'] ?? false);
|
||||||
|
|
||||||
|
$service = app(\App\Services\ImportSimulationService::class);
|
||||||
|
$result = $service->simulate($import, $limit, $verbose);
|
||||||
|
|
||||||
|
return response()->json($result);
|
||||||
|
}
|
||||||
|
|
||||||
// Show an existing import by UUID to continue where left off
|
// Show an existing import by UUID to continue where left off
|
||||||
public function show(Import $import)
|
public function show(Import $import)
|
||||||
{
|
{
|
||||||
|
|
@ -426,4 +544,40 @@ public function show(Import $import)
|
||||||
'client' => $client,
|
'client' => $client,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete an import if not finished (statuses allowed: uploaded, mapping, processing_failed etc.)
|
||||||
|
public function destroy(Request $request, Import $import)
|
||||||
|
{
|
||||||
|
// Only allow deletion if not completed or processing
|
||||||
|
if (in_array($import->status, ['completed', 'processing'])) {
|
||||||
|
return back()->with([
|
||||||
|
'ok' => false,
|
||||||
|
'message' => 'Import can not be deleted in its current status.',
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt to delete stored file
|
||||||
|
try {
|
||||||
|
if ($import->disk && $import->path && Storage::disk($import->disk)->exists($import->path)) {
|
||||||
|
Storage::disk($import->disk)->delete($import->path);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Log event but proceed with deletion
|
||||||
|
ImportEvent::create([
|
||||||
|
'import_id' => $import->id,
|
||||||
|
'user_id' => $request->user()?->getAuthIdentifier(),
|
||||||
|
'event' => 'file_delete_failed',
|
||||||
|
'level' => 'warning',
|
||||||
|
'message' => 'Failed to delete import file: '.$e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up related events/rows optionally (soft approach: rely on FKs if cascade configured)
|
||||||
|
// If not cascaded, we could manually delete; check quickly
|
||||||
|
// Assuming foreign key ON DELETE CASCADE for import_rows & import_events
|
||||||
|
|
||||||
|
$import->delete();
|
||||||
|
|
||||||
|
return back()->with(['ok' => true]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ public function rules(): array
|
||||||
'segment_id' => ['required', 'integer', 'exists:segments,id'],
|
'segment_id' => ['required', 'integer', 'exists:segments,id'],
|
||||||
'initial_decision_id' => ['required', 'integer', 'exists:decisions,id'],
|
'initial_decision_id' => ['required', 'integer', 'exists:decisions,id'],
|
||||||
'assign_decision_id' => ['required', 'integer', 'exists:decisions,id'],
|
'assign_decision_id' => ['required', 'integer', 'exists:decisions,id'],
|
||||||
|
'action_id' => ['nullable', 'integer', 'exists:actions,id'],
|
||||||
'complete_decision_id' => ['required', 'integer', 'exists:decisions,id'],
|
'complete_decision_id' => ['required', 'integer', 'exists:decisions,id'],
|
||||||
'cancel_decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
|
'cancel_decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
|
||||||
'return_segment_id' => ['nullable', 'integer', 'exists:segments,id'],
|
'return_segment_id' => ['nullable', 'integer', 'exists:segments,id'],
|
||||||
|
|
@ -42,14 +43,26 @@ public function withValidator($validator): void
|
||||||
{
|
{
|
||||||
$validator->after(function ($validator): void {
|
$validator->after(function ($validator): void {
|
||||||
// Validate that the assign_decision_id has a mapped action
|
// Validate that the assign_decision_id has a mapped action
|
||||||
|
$actionId = $this->input('action_id');
|
||||||
|
if (! empty($actionId)) {
|
||||||
|
$mapped = \App\Models\Action::query()
|
||||||
|
->where('id', $actionId)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if (! $mapped) {
|
||||||
|
$validator->errors()->add('action_id', 'The selected action does not exist. Please select a valid action.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$assignDecisionId = $this->input('assign_decision_id');
|
$assignDecisionId = $this->input('assign_decision_id');
|
||||||
if (! empty($assignDecisionId)) {
|
if (! empty($assignDecisionId)) {
|
||||||
$mapped = DB::table('action_decision')
|
$mapped = DB::table('action_decision')
|
||||||
->where('decision_id', $assignDecisionId)
|
->where('decision_id', $assignDecisionId)
|
||||||
|
->where('action_id', $actionId)
|
||||||
->exists();
|
->exists();
|
||||||
|
|
||||||
if (! $mapped) {
|
if (! $mapped) {
|
||||||
$validator->errors()->add('assign_decision_id', 'The selected assign decision must have a mapped action. Please map an action to this decision first.');
|
$validator->errors()->add('assign_decision_id', 'The selected assign decision must have a mapped action. Please map the correct action to this decision first.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -58,10 +71,11 @@ public function withValidator($validator): void
|
||||||
if (! empty($completeDecisionId)) {
|
if (! empty($completeDecisionId)) {
|
||||||
$mapped = DB::table('action_decision')
|
$mapped = DB::table('action_decision')
|
||||||
->where('decision_id', $completeDecisionId)
|
->where('decision_id', $completeDecisionId)
|
||||||
|
->where('action_id', $actionId)
|
||||||
->exists();
|
->exists();
|
||||||
|
|
||||||
if (! $mapped) {
|
if (! $mapped) {
|
||||||
$validator->errors()->add('complete_decision_id', 'The selected complete decision must have a mapped action. Please map an action to this decision first.');
|
$validator->errors()->add('complete_decision_id', 'The selected complete decision must have a mapped action. Please map the correct action to this decision first.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,6 +83,7 @@ public function withValidator($validator): void
|
||||||
if (! empty($cancelDecisionId)) {
|
if (! empty($cancelDecisionId)) {
|
||||||
$mapped = DB::table('action_decision')
|
$mapped = DB::table('action_decision')
|
||||||
->where('decision_id', $cancelDecisionId)
|
->where('decision_id', $cancelDecisionId)
|
||||||
|
->where('action_id', $actionId)
|
||||||
->exists();
|
->exists();
|
||||||
|
|
||||||
if (! $mapped) {
|
if (! $mapped) {
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ public function rules(): array
|
||||||
'cancel_decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
|
'cancel_decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
|
||||||
'return_segment_id' => ['nullable', 'integer', 'exists:segments,id'],
|
'return_segment_id' => ['nullable', 'integer', 'exists:segments,id'],
|
||||||
'queue_segment_id' => ['nullable', 'integer', 'exists:segments,id'],
|
'queue_segment_id' => ['nullable', 'integer', 'exists:segments,id'],
|
||||||
|
'action_id' => ['nullable', 'integer', 'exists:actions,id'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -38,13 +39,25 @@ public function messages(): array
|
||||||
public function withValidator($validator): void
|
public function withValidator($validator): void
|
||||||
{
|
{
|
||||||
$validator->after(function ($validator): void {
|
$validator->after(function ($validator): void {
|
||||||
|
$actionId = $this->input('action_id');
|
||||||
|
if (! empty($actionId)) {
|
||||||
|
$mapped = \App\Models\Action::query()
|
||||||
|
->where('id', $actionId)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if (! $mapped) {
|
||||||
|
$validator->errors()->add('action_id', 'The selected action does not exist. Please select a valid action.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$assignDecisionId = $this->input('assign_decision_id');
|
$assignDecisionId = $this->input('assign_decision_id');
|
||||||
if (! empty($assignDecisionId)) {
|
if (! empty($assignDecisionId)) {
|
||||||
$mapped = DB::table('action_decision')
|
$mapped = DB::table('action_decision')
|
||||||
->where('decision_id', $assignDecisionId)
|
->where('decision_id', $assignDecisionId)
|
||||||
|
->where('action_id', $actionId)
|
||||||
->exists();
|
->exists();
|
||||||
if (! $mapped) {
|
if (! $mapped) {
|
||||||
$validator->errors()->add('assign_decision_id', 'The selected assign decision must have a mapped action. Please map an action to this decision first.');
|
$validator->errors()->add('assign_decision_id', 'The selected assign decision must have a mapped action. Please map the correct action to this decision first.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -52,9 +65,10 @@ public function withValidator($validator): void
|
||||||
if (! empty($completeDecisionId)) {
|
if (! empty($completeDecisionId)) {
|
||||||
$mapped = DB::table('action_decision')
|
$mapped = DB::table('action_decision')
|
||||||
->where('decision_id', $completeDecisionId)
|
->where('decision_id', $completeDecisionId)
|
||||||
|
->where('action_id', $actionId)
|
||||||
->exists();
|
->exists();
|
||||||
if (! $mapped) {
|
if (! $mapped) {
|
||||||
$validator->errors()->add('complete_decision_id', 'The selected complete decision must have a mapped action. Please map an action to this decision first.');
|
$validator->errors()->add('complete_decision_id', 'The selected complete decision must have a mapped action. Please map the correct action to this decision first.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -62,9 +76,10 @@ public function withValidator($validator): void
|
||||||
if (! empty($cancelDecisionId)) {
|
if (! empty($cancelDecisionId)) {
|
||||||
$mapped = DB::table('action_decision')
|
$mapped = DB::table('action_decision')
|
||||||
->where('decision_id', $cancelDecisionId)
|
->where('decision_id', $cancelDecisionId)
|
||||||
|
->where('action_id', $actionId)
|
||||||
->exists();
|
->exists();
|
||||||
if (! $mapped) {
|
if (! $mapped) {
|
||||||
$validator->errors()->add('cancel_decision_id', 'The selected cancel decision must have a mapped action. Please map an action to this decision first.');
|
$validator->errors()->add('cancel_decision_id', 'The selected cancel decision must have a mapped action. Please map the correct action to this decision first.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
|
|
||||||
class Activity extends Model
|
class Activity extends Model
|
||||||
{
|
{
|
||||||
|
|
@ -43,9 +42,11 @@ protected static function booted()
|
||||||
|
|
||||||
// If an activity with a due date is added for a contract, update the related account's promise_date
|
// If an activity with a due date is added for a contract, update the related account's promise_date
|
||||||
if (! empty($activity->contract_id) && ! empty($activity->due_date)) {
|
if (! empty($activity->contract_id) && ! empty($activity->due_date)) {
|
||||||
DB::table('accounts')
|
\App\Models\Account::query()
|
||||||
->where('contract_id', $activity->contract_id)
|
->where('contract_id', $activity->contract_id)
|
||||||
->update(['promise_date' => $activity->due_date, 'updated_at' => now()]);
|
->update(
|
||||||
|
['promise_date' => $activity->due_date, 'updated_at' => now()],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,14 @@
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
class Email extends Model
|
class Email extends Model
|
||||||
{
|
{
|
||||||
use SoftDeletes;
|
use HasFactory, SoftDeletes;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'person_id',
|
'person_id',
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,14 @@ class FieldJobSetting extends Model
|
||||||
'cancel_decision_id',
|
'cancel_decision_id',
|
||||||
'return_segment_id',
|
'return_segment_id',
|
||||||
'queue_segment_id',
|
'queue_segment_id',
|
||||||
|
'action_id',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
public function action(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(\App\Models\Action::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function segment(): BelongsTo
|
public function segment(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Segment::class);
|
return $this->belongsTo(Segment::class);
|
||||||
|
|
@ -28,22 +34,34 @@ public function segment(): BelongsTo
|
||||||
|
|
||||||
public function assignDecision(): BelongsTo
|
public function assignDecision(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Decision::class, 'assign_decision_id');
|
return $this->belongsTo(Decision::class, 'assign_decision_id')
|
||||||
|
->with(['actions' => function ($query) {
|
||||||
|
$query->select('actions.id');
|
||||||
|
}]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function initialDecision(): BelongsTo
|
public function initialDecision(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Decision::class, 'initial_decision_id');
|
return $this->belongsTo(Decision::class, 'initial_decision_id')
|
||||||
|
->with(['actions' => function ($query) {
|
||||||
|
$query->select('actions.id');
|
||||||
|
}]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function completeDecision(): BelongsTo
|
public function completeDecision(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Decision::class, 'complete_decision_id');
|
return $this->belongsTo(Decision::class, 'complete_decision_id')
|
||||||
|
->with(['actions' => function ($query) {
|
||||||
|
$query->select('actions.id');
|
||||||
|
}]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function cancelDecision(): BelongsTo
|
public function cancelDecision(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Decision::class, 'cancel_decision_id');
|
return $this->belongsTo(Decision::class, 'cancel_decision_id')
|
||||||
|
->with(['actions' => function ($query) {
|
||||||
|
$query->select('actions.id');
|
||||||
|
}]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function returnSegment(): BelongsTo
|
public function returnSegment(): BelongsTo
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ class ImportRow extends Model
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'import_id','row_number','sheet_name','record_type','raw_data','mapped_data','status','errors','warnings','entity_type','entity_id','fingerprint'
|
'import_id', 'row_number', 'sheet_name', 'record_type', 'raw_data', 'mapped_data', 'status', 'errors', 'warnings', 'entity_type', 'entity_id', 'fingerprint', 'raw_sha1',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
|
|
|
||||||
|
|
@ -198,15 +198,26 @@ public function process(Import $import, ?Authenticatable $user = null): array
|
||||||
// If mapping contains contract.reference, we require each row to successfully resolve/create a contract
|
// If mapping contains contract.reference, we require each row to successfully resolve/create a contract
|
||||||
$requireContract = $this->mappingIncludes($mappings, 'contract.reference');
|
$requireContract = $this->mappingIncludes($mappings, 'contract.reference');
|
||||||
|
|
||||||
|
$isPg = DB::connection()->getDriverName() === 'pgsql';
|
||||||
|
$failedRows = [];
|
||||||
while (($row = fgetcsv($fh, 0, $delimiter)) !== false) {
|
while (($row = fgetcsv($fh, 0, $delimiter)) !== false) {
|
||||||
$rowNum++;
|
$rowNum++;
|
||||||
$total++;
|
$total++;
|
||||||
|
|
||||||
|
if ($isPg) {
|
||||||
|
// Establish a savepoint so a failing row does not poison the whole transaction
|
||||||
|
DB::statement('SAVEPOINT import_row_'.$rowNum);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scope variables per row so they aren't reused after exception
|
||||||
|
$importRow = null;
|
||||||
|
try {
|
||||||
$rawAssoc = $this->buildRowAssoc($row, $header);
|
$rawAssoc = $this->buildRowAssoc($row, $header);
|
||||||
[$recordType, $mapped] = $this->applyMappings($rawAssoc, $mappings);
|
[$recordType, $mapped] = $this->applyMappings($rawAssoc, $mappings);
|
||||||
|
|
||||||
// Do not auto-derive or fallback values; only use explicitly mapped fields
|
// Do not auto-derive or fallback values; only use explicitly mapped fields
|
||||||
|
|
||||||
|
$rawSha1 = sha1(json_encode($rawAssoc));
|
||||||
$importRow = ImportRow::create([
|
$importRow = ImportRow::create([
|
||||||
'import_id' => $import->id,
|
'import_id' => $import->id,
|
||||||
'row_number' => $rowNum,
|
'row_number' => $rowNum,
|
||||||
|
|
@ -214,6 +225,7 @@ public function process(Import $import, ?Authenticatable $user = null): array
|
||||||
'raw_data' => $rawAssoc,
|
'raw_data' => $rawAssoc,
|
||||||
'mapped_data' => $mapped,
|
'mapped_data' => $mapped,
|
||||||
'status' => 'valid',
|
'status' => 'valid',
|
||||||
|
'raw_sha1' => $rawSha1,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Contracts
|
// Contracts
|
||||||
|
|
@ -288,14 +300,18 @@ public function process(Import $import, ?Authenticatable $user = null): array
|
||||||
'entity_type' => Contract::class,
|
'entity_type' => Contract::class,
|
||||||
'entity_id' => $contractResult['contract']->id,
|
'entity_id' => $contractResult['contract']->id,
|
||||||
]);
|
]);
|
||||||
|
$contractFieldsStr = '';
|
||||||
|
if (! empty($contractResult['applied_fields'] ?? [])) {
|
||||||
|
$contractFieldsStr = $this->formatAppliedFieldMessage('contract', $contractResult['applied_fields']);
|
||||||
|
}
|
||||||
ImportEvent::create([
|
ImportEvent::create([
|
||||||
'import_id' => $import->id,
|
'import_id' => $import->id,
|
||||||
'user_id' => $user?->getAuthIdentifier(),
|
'user_id' => $user?->getAuthIdentifier(),
|
||||||
'import_row_id' => $importRow->id,
|
'import_row_id' => $importRow->id,
|
||||||
'event' => 'row_imported',
|
'event' => 'row_imported',
|
||||||
'level' => 'info',
|
'level' => 'info',
|
||||||
'message' => ucfirst($contractResult['action']).' contract',
|
'message' => ucfirst($contractResult['action']).' contract'.($contractFieldsStr ? ' '.$contractFieldsStr : ''),
|
||||||
'context' => ['id' => $contractResult['contract']->id],
|
'context' => ['id' => $contractResult['contract']->id, 'fields' => $contractResult['applied_fields'] ?? []],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Post-contract actions from template/import meta
|
// Post-contract actions from template/import meta
|
||||||
|
|
@ -375,14 +391,18 @@ public function process(Import $import, ?Authenticatable $user = null): array
|
||||||
'entity_type' => Account::class,
|
'entity_type' => Account::class,
|
||||||
'entity_id' => $accountResult['account']->id,
|
'entity_id' => $accountResult['account']->id,
|
||||||
]);
|
]);
|
||||||
|
$accountFieldsStr = '';
|
||||||
|
if (! empty($accountResult['applied_fields'] ?? [])) {
|
||||||
|
$accountFieldsStr = $this->formatAppliedFieldMessage('account', $accountResult['applied_fields']);
|
||||||
|
}
|
||||||
ImportEvent::create([
|
ImportEvent::create([
|
||||||
'import_id' => $import->id,
|
'import_id' => $import->id,
|
||||||
'user_id' => $user?->getAuthIdentifier(),
|
'user_id' => $user?->getAuthIdentifier(),
|
||||||
'import_row_id' => $importRow->id,
|
'import_row_id' => $importRow->id,
|
||||||
'event' => 'row_imported',
|
'event' => 'row_imported',
|
||||||
'level' => 'info',
|
'level' => 'info',
|
||||||
'message' => ucfirst($accountResult['action']).' account',
|
'message' => ucfirst($accountResult['action']).' account'.($accountFieldsStr ? ' '.$accountFieldsStr : ''),
|
||||||
'context' => ['id' => $accountResult['account']->id],
|
'context' => ['id' => $accountResult['account']->id, 'fields' => $accountResult['applied_fields'] ?? []],
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
$invalid++;
|
$invalid++;
|
||||||
|
|
@ -614,14 +634,16 @@ public function process(Import $import, ?Authenticatable $user = null): array
|
||||||
'entity_type' => Payment::class,
|
'entity_type' => Payment::class,
|
||||||
'entity_id' => $payment->id,
|
'entity_id' => $payment->id,
|
||||||
]);
|
]);
|
||||||
|
$paymentFields = $this->collectPaymentAppliedFields($payload, $payment);
|
||||||
|
$paymentFieldsStr = $this->formatAppliedFieldMessage('payment', $paymentFields);
|
||||||
ImportEvent::create([
|
ImportEvent::create([
|
||||||
'import_id' => $import->id,
|
'import_id' => $import->id,
|
||||||
'user_id' => $user?->getAuthIdentifier(),
|
'user_id' => $user?->getAuthIdentifier(),
|
||||||
'import_row_id' => $importRow->id,
|
'import_row_id' => $importRow->id,
|
||||||
'event' => 'row_imported',
|
'event' => 'row_imported',
|
||||||
'level' => 'info',
|
'level' => 'info',
|
||||||
'message' => 'Inserted payment',
|
'message' => 'Inserted payment'.($paymentFieldsStr ? ' '.$paymentFieldsStr : ''),
|
||||||
'context' => ['id' => $payment->id],
|
'context' => ['id' => $payment->id, 'fields' => $paymentFields],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
// If we have a contract reference, resolve existing contract for this client and derive person
|
// If we have a contract reference, resolve existing contract for this client and derive person
|
||||||
|
|
@ -765,10 +787,77 @@ public function process(Import $import, ?Authenticatable $user = null): array
|
||||||
$importRow->update(['status' => 'skipped']);
|
$importRow->update(['status' => 'skipped']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
if ($isPg) {
|
||||||
|
// Roll back only this row's work
|
||||||
|
try {
|
||||||
|
DB::statement('ROLLBACK TO SAVEPOINT import_row_'.$rowNum);
|
||||||
|
} catch (\Throwable $ignored) { /* noop */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ensure importRow exists for logging if failure happened before its creation
|
||||||
|
if (! $importRow) {
|
||||||
|
try {
|
||||||
|
$msg = $this->safeErrorMessage($e->getMessage());
|
||||||
|
$rawPreviewSha1 = isset($rawAssoc) ? sha1(json_encode($rawAssoc)) : null;
|
||||||
|
$importRow = ImportRow::create([
|
||||||
|
'import_id' => $import->id,
|
||||||
|
'row_number' => $rowNum,
|
||||||
|
'record_type' => null,
|
||||||
|
'raw_data' => isset($rawAssoc) ? $rawAssoc : [],
|
||||||
|
'mapped_data' => [],
|
||||||
|
'status' => 'invalid',
|
||||||
|
'errors' => [$msg],
|
||||||
|
'raw_sha1' => $rawPreviewSha1,
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $inner) {
|
||||||
|
// Last resort: cannot persist row; log only event
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Mark existing row as invalid (avoid double increment if already invalid)
|
||||||
|
if ($importRow->status !== 'invalid') {
|
||||||
|
$importRow->update(['status' => 'invalid', 'errors' => [$this->safeErrorMessage($e->getMessage())]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$failedRows[] = $rowNum;
|
||||||
|
$invalid++;
|
||||||
|
ImportEvent::create([
|
||||||
|
'import_id' => $import->id,
|
||||||
|
'user_id' => $user?->getAuthIdentifier(),
|
||||||
|
'import_row_id' => $importRow?->id,
|
||||||
|
'event' => 'row_exception',
|
||||||
|
'level' => 'error',
|
||||||
|
'message' => $this->safeErrorMessage($e->getMessage()),
|
||||||
|
'context' => [
|
||||||
|
'classification' => $this->classifyRowException($e),
|
||||||
|
'driver' => DB::connection()->getDriverName(),
|
||||||
|
'row_number' => $rowNum,
|
||||||
|
'raw_sha1' => isset($rawAssoc) ? sha1(json_encode($rawAssoc)) : null,
|
||||||
|
'raw_data_preview' => isset($rawAssoc) ? $this->buildRawDataPreview($rawAssoc) : [],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Skip to next row without aborting whole import
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fclose($fh);
|
fclose($fh);
|
||||||
|
|
||||||
|
if (! empty($failedRows)) {
|
||||||
|
ImportEvent::create([
|
||||||
|
'import_id' => $import->id,
|
||||||
|
'user_id' => $user?->getAuthIdentifier(),
|
||||||
|
'event' => 'row_exceptions_summary',
|
||||||
|
'level' => 'warning',
|
||||||
|
'message' => 'Rows failed: '.(count($failedRows) > 30 ? (implode(',', array_slice($failedRows, 0, 30)).' (+'.(count($failedRows) - 30).' more)') : implode(',', $failedRows)),
|
||||||
|
'context' => [
|
||||||
|
'failed_count' => count($failedRows),
|
||||||
|
'rows' => count($failedRows) > 200 ? array_slice($failedRows, 0, 200) : $failedRows,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
$import->update([
|
$import->update([
|
||||||
'status' => 'completed',
|
'status' => 'completed',
|
||||||
'finished_at' => now(),
|
'finished_at' => now(),
|
||||||
|
|
@ -1048,15 +1137,22 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array
|
||||||
$afterStr = number_format($newBalance, 2, ',', '.').' '.$currency;
|
$afterStr = number_format($newBalance, 2, ',', '.').' '.$currency;
|
||||||
$note = 'Sprememba stanja (Stanje pred: '.$beforeStr.', Stanje po: '.$afterStr.'; Izvor: sprememba)';
|
$note = 'Sprememba stanja (Stanje pred: '.$beforeStr.', Stanje po: '.$afterStr.'; Izvor: sprememba)';
|
||||||
if ($clientCaseId) {
|
if ($clientCaseId) {
|
||||||
|
// Use action_id from import meta if available to satisfy NOT NULL constraint on activities.action_id
|
||||||
|
$metaActionId = (int) ($import->meta['action_id'] ?? 0);
|
||||||
|
|
||||||
|
if ($metaActionId > 0) {
|
||||||
Activity::create([
|
Activity::create([
|
||||||
'due_date' => null,
|
'due_date' => null,
|
||||||
'amount' => null,
|
'amount' => null,
|
||||||
'note' => $note,
|
'note' => $note,
|
||||||
'action_id' => null,
|
'action_id' => $metaActionId,
|
||||||
'decision_id' => null,
|
'decision_id' => $import->meta['decision_id'] ?? null,
|
||||||
'client_case_id' => $clientCaseId,
|
'client_case_id' => $clientCaseId,
|
||||||
'contract_id' => $contractId,
|
'contract_id' => $contractId,
|
||||||
]);
|
]);
|
||||||
|
} else {
|
||||||
|
// If no action id is provided, skip creating the activity to avoid NOT NULL violation
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
// Non-fatal: ignore activity creation failures
|
// Non-fatal: ignore activity creation failures
|
||||||
|
|
@ -1065,7 +1161,7 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array
|
||||||
}
|
}
|
||||||
|
|
||||||
// also include contract hints for downstream contact resolution
|
// also include contract hints for downstream contact resolution
|
||||||
return ['action' => 'updated', 'account' => $existing, 'contract_id' => $contractId];
|
return ['action' => 'updated', 'account' => $existing, 'contract_id' => $contractId, 'applied_fields' => $changes];
|
||||||
} else {
|
} else {
|
||||||
// On insert: if initial_amount is not provided but balance_amount is, allow defaulting
|
// On insert: if initial_amount is not provided but balance_amount is, allow defaulting
|
||||||
// Only when the mapping for initial_amount is 'insert' or 'both', or unmapped (null).
|
// Only when the mapping for initial_amount is 'insert' or 'both', or unmapped (null).
|
||||||
|
|
@ -1089,7 +1185,7 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array
|
||||||
}
|
}
|
||||||
$created = Account::create($data);
|
$created = Account::create($data);
|
||||||
|
|
||||||
return ['action' => 'inserted', 'account' => $created, 'contract_id' => $contractId];
|
return ['action' => 'inserted', 'account' => $created, 'contract_id' => $contractId, 'applied_fields' => $data];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1258,7 +1354,7 @@ private function upsertContractChain(Import $import, array $mapped, $mappings):
|
||||||
$existing->fill($changes);
|
$existing->fill($changes);
|
||||||
$existing->save();
|
$existing->save();
|
||||||
|
|
||||||
return ['action' => 'updated', 'contract' => $existing];
|
return ['action' => 'updated', 'contract' => $existing, 'applied_fields' => $changes];
|
||||||
} else {
|
} else {
|
||||||
if (empty($applyInsert)) {
|
if (empty($applyInsert)) {
|
||||||
return ['action' => 'skipped', 'message' => 'No contract fields marked for insert'];
|
return ['action' => 'skipped', 'message' => 'No contract fields marked for insert'];
|
||||||
|
|
@ -1271,7 +1367,7 @@ private function upsertContractChain(Import $import, array $mapped, $mappings):
|
||||||
$data['type_id'] = $data['type_id'] ?? $this->getDefaultContractTypeId();
|
$data['type_id'] = $data['type_id'] ?? $this->getDefaultContractTypeId();
|
||||||
$created = Contract::create($data);
|
$created = Contract::create($data);
|
||||||
|
|
||||||
return ['action' => 'inserted', 'contract' => $created];
|
return ['action' => 'inserted', 'contract' => $created, 'applied_fields' => $data];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1355,6 +1451,120 @@ private function normalizeDecimal(string $raw): string
|
||||||
return $s;
|
return $s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify a row-level exception into a coarse category for diagnostics.
|
||||||
|
* duplicate|constraint|integrity|validation|db|unknown
|
||||||
|
*/
|
||||||
|
private function classifyRowException(\Throwable $e): string
|
||||||
|
{
|
||||||
|
$msg = strtolower($e->getMessage());
|
||||||
|
if (str_contains($msg, 'duplicate') || str_contains($msg, 'unique') || str_contains($msg, 'already exists')) {
|
||||||
|
return 'duplicate';
|
||||||
|
}
|
||||||
|
if (str_contains($msg, 'foreign key') || str_contains($msg, 'not-null') || str_contains($msg, 'violates') || str_contains($msg, 'constraint')) {
|
||||||
|
return 'constraint';
|
||||||
|
}
|
||||||
|
if (str_contains($msg, 'integrity')) {
|
||||||
|
return 'integrity';
|
||||||
|
}
|
||||||
|
if (str_contains($msg, 'missing') || str_contains($msg, 'required')) {
|
||||||
|
return 'validation';
|
||||||
|
}
|
||||||
|
if (str_contains($msg, 'sqlstate') || str_contains($msg, 'syntax error') || str_contains($msg, 'invalid input')) {
|
||||||
|
return 'db';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure error message is valid UTF-8 and safely truncated.
|
||||||
|
*/
|
||||||
|
private function safeErrorMessage(string $msg): string
|
||||||
|
{
|
||||||
|
// Convert to UTF-8, dropping invalid sequences
|
||||||
|
if (! mb_detect_encoding($msg, 'UTF-8', true)) {
|
||||||
|
$msg = mb_convert_encoding($msg, 'UTF-8', 'UTF-8');
|
||||||
|
}
|
||||||
|
// Fallback strip invalid bytes
|
||||||
|
$msg = @iconv('UTF-8', 'UTF-8//IGNORE', $msg) ?: $msg;
|
||||||
|
if (strlen($msg) > 500) {
|
||||||
|
$msg = substr($msg, 0, 497).'...';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a trimmed raw data preview (first 8 columns, truncated values) for logging.
|
||||||
|
*/
|
||||||
|
private function buildRawDataPreview(array $raw): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
$i = 0;
|
||||||
|
foreach ($raw as $k => $v) {
|
||||||
|
if ($i >= 8) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$val = is_scalar($v) || is_null($v) ? (string) $v : json_encode($v);
|
||||||
|
if (mb_strlen($val) > 80) {
|
||||||
|
$val = mb_substr($val, 0, 77).'...';
|
||||||
|
}
|
||||||
|
$out[$k] = $val;
|
||||||
|
$i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a concise human-readable field=value list for logging.
|
||||||
|
* Example: [account] reference=ACC123 balance_amount=100.00
|
||||||
|
*/
|
||||||
|
private function formatAppliedFieldMessage(string $root, array $fields): string
|
||||||
|
{
|
||||||
|
if (empty($fields)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$parts = [];
|
||||||
|
foreach ($fields as $k => $v) {
|
||||||
|
if (is_scalar($v) || is_null($v)) {
|
||||||
|
$disp = is_null($v) ? 'NULL' : (string) $v;
|
||||||
|
} elseif (is_array($v)) {
|
||||||
|
$disp = json_encode($v);
|
||||||
|
} else {
|
||||||
|
$disp = method_exists($v, '__toString') ? (string) $v : gettype($v);
|
||||||
|
}
|
||||||
|
// Truncate very long values for log safety
|
||||||
|
if (strlen($disp) > 60) {
|
||||||
|
$disp = substr($disp, 0, 57).'...';
|
||||||
|
}
|
||||||
|
$parts[] = $k.'='.$disp;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '['.$root.'] '.implode(' ', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect persisted payment fields (sanitized) for event logging.
|
||||||
|
*/
|
||||||
|
private function collectPaymentAppliedFields(array $payload, \App\Models\Payment $payment): array
|
||||||
|
{
|
||||||
|
$fields = [];
|
||||||
|
foreach (['account_id', 'reference', 'amount', 'paid_at', 'currency'] as $f) {
|
||||||
|
if (array_key_exists($f, $payload)) {
|
||||||
|
$fields[$f] = $payload[$f];
|
||||||
|
} elseif (isset($payment->$f)) {
|
||||||
|
$fields[$f] = $payment->$f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isset($payload['meta'])) {
|
||||||
|
$fields['meta'] = $payload['meta'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $fields;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure mapping roots are recognized; fail fast if unknown roots found.
|
* Ensure mapping roots are recognized; fail fast if unknown roots found.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
1200
app/Services/ImportSimulationService.php
Normal file
1200
app/Services/ImportSimulationService.php
Normal file
File diff suppressed because it is too large
Load Diff
27
database/factories/EmailFactory.php
Normal file
27
database/factories/EmailFactory.php
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Email;
|
||||||
|
use App\Models\Person\Person;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<Email>
|
||||||
|
*/
|
||||||
|
class EmailFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = Email::class;
|
||||||
|
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'person_id' => Person::factory(),
|
||||||
|
'value' => $this->faker->unique()->safeEmail(),
|
||||||
|
'label' => null,
|
||||||
|
'is_primary' => false,
|
||||||
|
'is_active' => true,
|
||||||
|
'valid' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
32
database/factories/ImportFactory.php
Normal file
32
database/factories/ImportFactory.php
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Import;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class ImportFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = Import::class;
|
||||||
|
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'uuid' => (string) Str::uuid(),
|
||||||
|
'user_id' => null,
|
||||||
|
'import_template_id' => null,
|
||||||
|
'client_id' => null,
|
||||||
|
'source_type' => 'csv',
|
||||||
|
'file_name' => 'test.csv',
|
||||||
|
'original_name' => 'test.csv',
|
||||||
|
'disk' => 'local',
|
||||||
|
'path' => 'imports/test.csv',
|
||||||
|
'status' => 'uploaded',
|
||||||
|
'meta' => [
|
||||||
|
'has_header' => true,
|
||||||
|
'forced_delimiter' => ',',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,7 +17,8 @@ class AddressTypeFactory extends Factory
|
||||||
public function definition(): array
|
public function definition(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
//
|
'name' => $this->faker->randomElement(['Home', 'Work', 'Other']),
|
||||||
|
'description' => $this->faker->optional()->sentence(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
namespace Database\Factories\Person;
|
namespace Database\Factories\Person;
|
||||||
|
|
||||||
|
use App\Models\Person\AddressType;
|
||||||
|
use App\Models\User;
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -17,7 +19,10 @@ class PersonAddressFactory extends Factory
|
||||||
public function definition(): array
|
public function definition(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
//
|
'address' => $this->faker->streetAddress(),
|
||||||
|
'country' => 'SI',
|
||||||
|
'type_id' => AddressType::factory(),
|
||||||
|
'user_id' => User::factory(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
use App\Models\Person\PersonGroup;
|
use App\Models\Person\PersonGroup;
|
||||||
use App\Models\Person\PersonType;
|
use App\Models\Person\PersonType;
|
||||||
|
use App\Models\User;
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -29,6 +30,7 @@ public function definition(): array
|
||||||
'description' => $this->faker->optional()->sentence(),
|
'description' => $this->faker->optional()->sentence(),
|
||||||
'group_id' => PersonGroup::factory(),
|
'group_id' => PersonGroup::factory(),
|
||||||
'type_id' => PersonType::factory(),
|
'type_id' => PersonType::factory(),
|
||||||
|
'user_id' => User::factory(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
namespace Database\Factories\Person;
|
namespace Database\Factories\Person;
|
||||||
|
|
||||||
|
use App\Models\Person\PhoneType;
|
||||||
|
use App\Models\User;
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -17,7 +19,9 @@ class PersonPhoneFactory extends Factory
|
||||||
public function definition(): array
|
public function definition(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
//
|
'nu' => $this->faker->numerify('06########'),
|
||||||
|
'type_id' => PhoneType::factory(),
|
||||||
|
'user_id' => User::factory(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,8 @@ class PhoneTypeFactory extends Factory
|
||||||
public function definition(): array
|
public function definition(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
//
|
'name' => $this->faker->randomElement(['GSM', 'Home', 'Work']),
|
||||||
|
'description' => $this->faker->optional()->sentence(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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::table('field_job_settings', function (Blueprint $table): void {
|
||||||
|
if (! Schema::hasColumn('field_job_settings', 'action_id')) {
|
||||||
|
$table->foreignId('action_id')->nullable()->constrained('actions')->nullOnDelete()->after('segment_id');
|
||||||
|
$table->index('action_id');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('field_job_settings', function (Blueprint $table): void {
|
||||||
|
if (Schema::hasColumn('field_job_settings', 'action_id')) {
|
||||||
|
$table->dropConstrainedForeignId('action_id');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('import_rows', function (Blueprint $table) {
|
||||||
|
if (! Schema::hasColumn('import_rows', 'raw_sha1')) {
|
||||||
|
$table->string('raw_sha1', 40)->nullable()->after('fingerprint')->index();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('import_rows', function (Blueprint $table) {
|
||||||
|
if (Schema::hasColumn('import_rows', 'raw_sha1')) {
|
||||||
|
$table->dropColumn('raw_sha1');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -2,4 +2,4 @@
|
||||||
"5362030581","P-2025-0001","2025-09-01","120.50","REF-53620-A"
|
"5362030581","P-2025-0001","2025-09-01","120.50","REF-53620-A"
|
||||||
"5362017358","P-2025-0002","2025-09-15","80.00","REF-53620-B"
|
"5362017358","P-2025-0002","2025-09-15","80.00","REF-53620-B"
|
||||||
"5362011838","P-2025-0201","2025-09-03","300.00","REF-53413-A"
|
"5362011838","P-2025-0201","2025-09-03","300.00","REF-53413-A"
|
||||||
"5362017783","P-2025-0202","2025-09-20","150.00","REF-53413-B"
|
"5341384934","P-2025-0202","2025-09-20","150.00","REF-53413-B"
|
||||||
|
|
|
@ -1,129 +1,260 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { FwbTable, FwbTableHead, FwbTableHeadCell, FwbTableBody, FwbTableRow, FwbTableCell, FwbBadge } from 'flowbite-vue'
|
import {
|
||||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
FwbTable,
|
||||||
import { faFilePdf, faFileWord, faFileExcel, faFileLines, faFileImage, faFile, faCircleInfo, faEllipsisVertical, faDownload } from '@fortawesome/free-solid-svg-icons'
|
FwbTableHead,
|
||||||
import { ref } from 'vue'
|
FwbTableHeadCell,
|
||||||
import Dropdown from '@/Components/Dropdown.vue'
|
FwbTableBody,
|
||||||
|
FwbTableRow,
|
||||||
|
FwbTableCell,
|
||||||
|
FwbBadge,
|
||||||
|
} from "flowbite-vue";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||||
|
import {
|
||||||
|
faFilePdf,
|
||||||
|
faFileWord,
|
||||||
|
faFileExcel,
|
||||||
|
faFileLines,
|
||||||
|
faFileImage,
|
||||||
|
faFile,
|
||||||
|
faCircleInfo,
|
||||||
|
faEllipsisVertical,
|
||||||
|
faDownload,
|
||||||
|
faTrash,
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { router } from "@inertiajs/vue3";
|
||||||
|
import Dropdown from "@/Components/Dropdown.vue";
|
||||||
|
import ConfirmationModal from "@/Components/ConfirmationModal.vue";
|
||||||
|
import SecondaryButton from "./SecondaryButton.vue";
|
||||||
|
import DangerButton from "./DangerButton.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
documents: { type: Array, default: () => [] },
|
documents: { type: Array, default: () => [] },
|
||||||
viewUrlBuilder: { type: Function, default: null },
|
viewUrlBuilder: { type: Function, default: null },
|
||||||
// Optional: build a direct download URL for a document; if not provided, a 'download' event will be emitted
|
// Optional: direct download URL builder; if absent we emit 'download'
|
||||||
downloadUrlBuilder: { type: Function, default: null },
|
downloadUrlBuilder: { type: Function, default: null },
|
||||||
})
|
// Optional: direct delete URL builder; if absent we emit 'delete'
|
||||||
|
deleteUrlBuilder: { type: Function, default: null },
|
||||||
|
});
|
||||||
// Derive a human-friendly source for a document: Case or Contract reference
|
// Derive a human-friendly source for a document: Case or Contract reference
|
||||||
const sourceLabel = (doc) => {
|
const sourceLabel = (doc) => {
|
||||||
// Server can include optional documentable meta; fall back to type
|
// Server can include optional documentable meta; fall back to type
|
||||||
if (doc.documentable_type?.toLowerCase?.().includes('contract')) {
|
if (doc.documentable_type?.toLowerCase?.().includes("contract")) {
|
||||||
return doc.contract_reference ? `Pogodba ${doc.contract_reference}` : 'Pogodba'
|
return doc.contract_reference ? `Pogodba ${doc.contract_reference}` : "Pogodba";
|
||||||
}
|
}
|
||||||
return 'Primer'
|
return "Primer";
|
||||||
}
|
};
|
||||||
|
|
||||||
const emit = defineEmits(['view', 'download'])
|
const emit = defineEmits(["view", "download", "delete"]);
|
||||||
|
|
||||||
const formatSize = (bytes) => {
|
const formatSize = (bytes) => {
|
||||||
if (bytes == null) return '-'
|
if (bytes == null) return "-";
|
||||||
const thresh = 1024
|
const thresh = 1024;
|
||||||
if (Math.abs(bytes) < thresh) return bytes + ' B'
|
if (Math.abs(bytes) < thresh) return bytes + " B";
|
||||||
const units = ['KB', 'MB', 'GB', 'TB']
|
const units = ["KB", "MB", "GB", "TB"];
|
||||||
let u = -1
|
let u = -1;
|
||||||
do { bytes /= thresh; ++u } while (Math.abs(bytes) >= thresh && u < units.length - 1)
|
do {
|
||||||
return bytes.toFixed(1) + ' ' + units[u]
|
bytes /= thresh;
|
||||||
}
|
++u;
|
||||||
|
} while (Math.abs(bytes) >= thresh && u < units.length - 1);
|
||||||
|
return bytes.toFixed(1) + " " + units[u];
|
||||||
|
};
|
||||||
|
|
||||||
const extFrom = (doc) => {
|
const extFrom = (doc) => {
|
||||||
let ext = (doc?.extension || '').toLowerCase()
|
let ext = (doc?.extension || "").toLowerCase();
|
||||||
if (!ext && doc?.original_name) {
|
if (!ext && doc?.original_name) {
|
||||||
const parts = String(doc.original_name).toLowerCase().split('.')
|
const parts = String(doc.original_name).toLowerCase().split(".");
|
||||||
if (parts.length > 1) ext = parts.pop()
|
if (parts.length > 1) ext = parts.pop();
|
||||||
}
|
}
|
||||||
// derive from mime
|
// derive from mime
|
||||||
if (!ext && doc?.mime_type) {
|
if (!ext && doc?.mime_type) {
|
||||||
const mime = String(doc.mime_type).toLowerCase()
|
const mime = String(doc.mime_type).toLowerCase();
|
||||||
if (mime.includes('pdf')) ext = 'pdf'
|
if (mime.includes("pdf")) ext = "pdf";
|
||||||
else if (mime.includes('word') || mime.includes('msword') || mime.includes('doc')) ext = 'docx'
|
else if (mime.includes("word") || mime.includes("msword") || mime.includes("doc"))
|
||||||
else if (mime.includes('excel') || mime.includes('sheet')) ext = 'xlsx'
|
ext = "docx";
|
||||||
else if (mime.includes('csv')) ext = 'csv'
|
else if (mime.includes("excel") || mime.includes("sheet")) ext = "xlsx";
|
||||||
else if (mime.startsWith('image/')) ext = 'img'
|
else if (mime.includes("csv")) ext = "csv";
|
||||||
else if (mime.includes('text')) ext = 'txt'
|
else if (mime.startsWith("image/")) ext = "img";
|
||||||
|
else if (mime.includes("text")) ext = "txt";
|
||||||
}
|
}
|
||||||
return ext
|
return ext;
|
||||||
}
|
};
|
||||||
|
|
||||||
const fileTypeInfo = (doc) => {
|
const fileTypeInfo = (doc) => {
|
||||||
const ext = extFrom(doc)
|
const ext = extFrom(doc);
|
||||||
const mime = (doc?.mime_type || '').toLowerCase()
|
const mime = (doc?.mime_type || "").toLowerCase();
|
||||||
switch (ext) {
|
switch (ext) {
|
||||||
case 'pdf':
|
case "pdf":
|
||||||
return { icon: faFilePdf, color: 'text-red-600', label: 'PDF' }
|
return { icon: faFilePdf, color: "text-red-600", label: "PDF" };
|
||||||
case 'doc':
|
case "doc":
|
||||||
case 'docx':
|
case "docx":
|
||||||
return { icon: faFileWord, color: 'text-blue-600', label: (ext || 'DOCX').toUpperCase() }
|
return {
|
||||||
case 'xls':
|
icon: faFileWord,
|
||||||
case 'xlsx':
|
color: "text-blue-600",
|
||||||
return { icon: faFileExcel, color: 'text-green-600', label: (ext || 'XLSX').toUpperCase() }
|
label: (ext || "DOCX").toUpperCase(),
|
||||||
case 'csv':
|
};
|
||||||
|
case "xls":
|
||||||
|
case "xlsx":
|
||||||
|
return {
|
||||||
|
icon: faFileExcel,
|
||||||
|
color: "text-green-600",
|
||||||
|
label: (ext || "XLSX").toUpperCase(),
|
||||||
|
};
|
||||||
|
case "csv":
|
||||||
// treat CSV as spreadsheet-like
|
// treat CSV as spreadsheet-like
|
||||||
return { icon: faFileExcel, color: 'text-emerald-600', label: 'CSV' }
|
return { icon: faFileExcel, color: "text-emerald-600", label: "CSV" };
|
||||||
case 'txt':
|
case "txt":
|
||||||
return { icon: faFileLines, color: 'text-slate-600', label: 'TXT' }
|
return { icon: faFileLines, color: "text-slate-600", label: "TXT" };
|
||||||
case 'jpg':
|
case "jpg":
|
||||||
case 'jpeg':
|
case "jpeg":
|
||||||
case 'png':
|
case "png":
|
||||||
case 'img':
|
case "img":
|
||||||
return { icon: faFileImage, color: 'text-fuchsia-600', label: (ext === 'img' ? 'IMG' : (ext || 'IMG').toUpperCase()) }
|
return {
|
||||||
|
icon: faFileImage,
|
||||||
|
color: "text-fuchsia-600",
|
||||||
|
label: ext === "img" ? "IMG" : (ext || "IMG").toUpperCase(),
|
||||||
|
};
|
||||||
default:
|
default:
|
||||||
if (mime.startsWith('image/')) return { icon: faFileImage, color: 'text-fuchsia-600', label: 'IMG' }
|
if (mime.startsWith("image/"))
|
||||||
return { icon: faFile, color: 'text-gray-600', label: (ext || 'FILE').toUpperCase() }
|
return { icon: faFileImage, color: "text-fuchsia-600", label: "IMG" };
|
||||||
|
return {
|
||||||
|
icon: faFile,
|
||||||
|
color: "text-gray-600",
|
||||||
|
label: (ext || "FILE").toUpperCase(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const hasDesc = (doc) => {
|
const hasDesc = (doc) => {
|
||||||
const d = doc?.description
|
const d = doc?.description;
|
||||||
return typeof d === 'string' && d.trim().length > 0
|
return typeof d === "string" && d.trim().length > 0;
|
||||||
}
|
};
|
||||||
|
|
||||||
const expandedDescKey = ref(null)
|
const expandedDescKey = ref(null);
|
||||||
const rowKey = (doc, i) => doc?.uuid ?? i
|
const rowKey = (doc, i) => doc?.uuid ?? i;
|
||||||
const toggleDesc = (doc, i) => {
|
const toggleDesc = (doc, i) => {
|
||||||
const key = rowKey(doc, i)
|
const key = rowKey(doc, i);
|
||||||
expandedDescKey.value = expandedDescKey.value === key ? null : key
|
expandedDescKey.value = expandedDescKey.value === key ? null : key;
|
||||||
}
|
};
|
||||||
|
|
||||||
|
|
||||||
const resolveDownloadUrl = (doc) => {
|
const resolveDownloadUrl = (doc) => {
|
||||||
if (typeof props.downloadUrlBuilder === 'function') return props.downloadUrlBuilder(doc)
|
if (typeof props.downloadUrlBuilder === "function")
|
||||||
|
return props.downloadUrlBuilder(doc);
|
||||||
// If no builder provided, parent can handle via emitted event
|
// If no builder provided, parent can handle via emitted event
|
||||||
return null
|
return null;
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleDownload = (doc) => {
|
const handleDownload = (doc) => {
|
||||||
const url = resolveDownloadUrl(doc)
|
const url = resolveDownloadUrl(doc);
|
||||||
if (url) {
|
if (url) {
|
||||||
// Trigger a navigation to a direct-download endpoint; server should set Content-Disposition: attachment
|
// Trigger a navigation to a direct-download endpoint; server should set Content-Disposition: attachment
|
||||||
const a = document.createElement('a')
|
const a = document.createElement("a");
|
||||||
a.href = url
|
a.href = url;
|
||||||
a.target = '_self'
|
a.target = "_self";
|
||||||
a.rel = 'noopener'
|
a.rel = "noopener";
|
||||||
// In many browsers, simply setting href is enough
|
// In many browsers, simply setting href is enough
|
||||||
a.click()
|
a.click();
|
||||||
} else {
|
} else {
|
||||||
emit('download', doc)
|
emit("download", doc);
|
||||||
}
|
}
|
||||||
closeActions()
|
closeActions();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------- Delete logic ----------------
|
||||||
|
const confirmDelete = ref(false);
|
||||||
|
const deleting = ref(false);
|
||||||
|
const docToDelete = ref(null);
|
||||||
|
|
||||||
|
const resolveDeleteUrl = (doc) => {
|
||||||
|
// 1. Explicit builder via prop takes precedence
|
||||||
|
if (typeof props.deleteUrlBuilder === "function") {
|
||||||
|
return props.deleteUrlBuilder(doc);
|
||||||
|
}
|
||||||
|
// 2. Attempt automatic route resolution (requires Ziggy's global `route` helper)
|
||||||
|
try {
|
||||||
|
const type = (doc?.documentable_type || "").toLowerCase();
|
||||||
|
// Contract document
|
||||||
|
if (type.includes("contract") && doc?.contract_uuid && doc?.uuid) {
|
||||||
|
if (typeof route === "function") {
|
||||||
|
return route("contract.document.delete", {
|
||||||
|
contract: doc.contract_uuid,
|
||||||
|
document: doc.uuid,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Case document
|
||||||
|
if (doc?.client_case_uuid && doc?.uuid) {
|
||||||
|
if (typeof route === "function") {
|
||||||
|
return route("clientCase.document.delete", {
|
||||||
|
client_case: doc.client_case_uuid,
|
||||||
|
document: doc.uuid,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// swallow – fallback to emit path
|
||||||
|
}
|
||||||
|
// 3. Fallback: no URL, caller must handle emitted event
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestDelete = async () => {
|
||||||
|
if (!docToDelete.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = resolveDeleteUrl(docToDelete.value);
|
||||||
|
deleting.value = true;
|
||||||
|
try {
|
||||||
|
if (url) {
|
||||||
|
await router.delete(url, { preserveScroll: true });
|
||||||
|
} else {
|
||||||
|
emit("delete", docToDelete.value);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
deleting.value = false;
|
||||||
|
confirmDelete.value = false;
|
||||||
|
docToDelete.value = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const askDelete = (doc) => {
|
||||||
|
docToDelete.value = doc;
|
||||||
|
confirmDelete.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
function closeActions() {
|
||||||
|
/* noop placeholder for symmetry; Dropdown auto-closes */
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="relative overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm">
|
<div
|
||||||
|
class="relative overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm"
|
||||||
|
>
|
||||||
<FwbTable hoverable striped class="text-sm">
|
<FwbTable hoverable striped class="text-sm">
|
||||||
<FwbTableHead class="sticky top-0 z-10 bg-gray-50/90 backdrop-blur border-b border-gray-200 shadow-sm">
|
<FwbTableHead
|
||||||
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Naziv</FwbTableHeadCell>
|
class="sticky top-0 z-10 bg-gray-50/90 backdrop-blur border-b border-gray-200 shadow-sm"
|
||||||
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Vrsta</FwbTableHeadCell>
|
>
|
||||||
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Velikost</FwbTableHeadCell>
|
<FwbTableHeadCell
|
||||||
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Dodano</FwbTableHeadCell>
|
class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3"
|
||||||
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Vir</FwbTableHeadCell>
|
>Naziv</FwbTableHeadCell
|
||||||
|
>
|
||||||
|
<FwbTableHeadCell
|
||||||
|
class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3"
|
||||||
|
>Vrsta</FwbTableHeadCell
|
||||||
|
>
|
||||||
|
<FwbTableHeadCell
|
||||||
|
class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3"
|
||||||
|
>Velikost</FwbTableHeadCell
|
||||||
|
>
|
||||||
|
<FwbTableHeadCell
|
||||||
|
class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3"
|
||||||
|
>Dodano</FwbTableHeadCell
|
||||||
|
>
|
||||||
|
<FwbTableHeadCell
|
||||||
|
class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3"
|
||||||
|
>Vir</FwbTableHeadCell
|
||||||
|
>
|
||||||
<FwbTableHeadCell class="w-px" />
|
<FwbTableHeadCell class="w-px" />
|
||||||
</FwbTableHead>
|
</FwbTableHead>
|
||||||
<FwbTableBody>
|
<FwbTableBody>
|
||||||
|
|
@ -131,13 +262,22 @@ const handleDownload = (doc) => {
|
||||||
<FwbTableRow>
|
<FwbTableRow>
|
||||||
<FwbTableCell>
|
<FwbTableCell>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button type="button" class="text-indigo-600 hover:underline" @click="$emit('view', doc)">{{ doc.name }}</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-indigo-600 hover:underline"
|
||||||
|
@click="$emit('view', doc)"
|
||||||
|
>
|
||||||
|
{{ doc.name }}
|
||||||
|
</button>
|
||||||
<FwbBadge v-if="doc.is_public" type="green">Public</FwbBadge>
|
<FwbBadge v-if="doc.is_public" type="green">Public</FwbBadge>
|
||||||
</div>
|
</div>
|
||||||
</FwbTableCell>
|
</FwbTableCell>
|
||||||
<FwbTableCell>
|
<FwbTableCell>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<FontAwesomeIcon :icon="fileTypeInfo(doc).icon" :class="['h-5 w-5', fileTypeInfo(doc).color]" />
|
<FontAwesomeIcon
|
||||||
|
:icon="fileTypeInfo(doc).icon"
|
||||||
|
:class="['h-5 w-5', fileTypeInfo(doc).color]"
|
||||||
|
/>
|
||||||
<span class="text-gray-700">{{ fileTypeInfo(doc).label }}</span>
|
<span class="text-gray-700">{{ fileTypeInfo(doc).label }}</span>
|
||||||
</div>
|
</div>
|
||||||
</FwbTableCell>
|
</FwbTableCell>
|
||||||
|
|
@ -165,7 +305,10 @@ const handleDownload = (doc) => {
|
||||||
class="inline-flex items-center justify-center h-8 w-8 rounded-full hover:bg-gray-100 focus:outline-none"
|
class="inline-flex items-center justify-center h-8 w-8 rounded-full hover:bg-gray-100 focus:outline-none"
|
||||||
:title="'Actions'"
|
:title="'Actions'"
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon :icon="faEllipsisVertical" class="h-4 w-4 text-gray-700" />
|
<FontAwesomeIcon
|
||||||
|
:icon="faEllipsisVertical"
|
||||||
|
class="h-4 w-4 text-gray-700"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
<template #content>
|
<template #content>
|
||||||
|
|
@ -177,15 +320,28 @@ const handleDownload = (doc) => {
|
||||||
<FontAwesomeIcon :icon="faDownload" class="h-4 w-4 text-gray-600" />
|
<FontAwesomeIcon :icon="faDownload" class="h-4 w-4 text-gray-600" />
|
||||||
<span>Download file</span>
|
<span>Download file</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full px-3 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center gap-2"
|
||||||
|
@click="askDelete(doc)"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon :icon="faTrash" class="h-4 w-4" />
|
||||||
|
<span>Delete</span>
|
||||||
|
</button>
|
||||||
<!-- future actions can be slotted here -->
|
<!-- future actions can be slotted here -->
|
||||||
</template>
|
</template>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</FwbTableCell>
|
</FwbTableCell>
|
||||||
</FwbTableRow>
|
</FwbTableRow>
|
||||||
<!-- Expanded description row directly below the item -->
|
<!-- Expanded description row directly below the item -->
|
||||||
<FwbTableRow :key="'desc-' + (doc.uuid || i)" v-if="expandedDescKey === rowKey(doc, i)">
|
<FwbTableRow
|
||||||
|
:key="'desc-' + (doc.uuid || i)"
|
||||||
|
v-if="expandedDescKey === rowKey(doc, i)"
|
||||||
|
>
|
||||||
<FwbTableCell :colspan="6" class="bg-gray-50">
|
<FwbTableCell :colspan="6" class="bg-gray-50">
|
||||||
<div class="px-4 py-3 text-sm text-gray-700 whitespace-pre-wrap border-l-2 border-indigo-400">
|
<div
|
||||||
|
class="px-4 py-3 text-sm text-gray-700 whitespace-pre-wrap border-l-2 border-indigo-400"
|
||||||
|
>
|
||||||
{{ doc.description }}
|
{{ doc.description }}
|
||||||
</div>
|
</div>
|
||||||
</FwbTableCell>
|
</FwbTableCell>
|
||||||
|
|
@ -193,6 +349,46 @@ const handleDownload = (doc) => {
|
||||||
</template>
|
</template>
|
||||||
</FwbTableBody>
|
</FwbTableBody>
|
||||||
</FwbTable>
|
</FwbTable>
|
||||||
<div v-if="!documents || documents.length === 0" class="p-6 text-center text-sm text-gray-500">No documents.</div>
|
<div
|
||||||
|
v-if="!documents || documents.length === 0"
|
||||||
|
class="p-6 text-center text-sm text-gray-500"
|
||||||
|
>
|
||||||
|
No documents.
|
||||||
|
</div>
|
||||||
|
<!-- Delete confirmation modal using shared component -->
|
||||||
|
<ConfirmationModal
|
||||||
|
:show="confirmDelete"
|
||||||
|
:closeable="!deleting"
|
||||||
|
max-width="md"
|
||||||
|
@close="
|
||||||
|
confirmDelete = false;
|
||||||
|
docToDelete = null;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<template #title>Potrditev</template>
|
||||||
|
<template #content>
|
||||||
|
Ali res želite izbrisati dokument
|
||||||
|
<span class="font-medium">{{ docToDelete?.name }}</span
|
||||||
|
>? Tega dejanja ni mogoče razveljaviti.
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<SecondaryButton
|
||||||
|
type="button"
|
||||||
|
@click="
|
||||||
|
confirmDelete = false;
|
||||||
|
docToDelete = null;
|
||||||
|
"
|
||||||
|
:disabled="deleting"
|
||||||
|
>Prekliči</SecondaryButton
|
||||||
|
>
|
||||||
|
<DangerButton
|
||||||
|
:disabled="deleting"
|
||||||
|
type="button"
|
||||||
|
class="ml-2"
|
||||||
|
@click="requestDelete"
|
||||||
|
>{{ deleting ? "Brisanje…" : "Izbriši" }}</DangerButton
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
</ConfirmationModal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,10 @@ const props = defineProps({
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => ['py-1', 'bg-white'],
|
default: () => ['py-1', 'bg-white'],
|
||||||
},
|
},
|
||||||
|
closeOnContentClick: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
let open = ref(false);
|
let open = ref(false);
|
||||||
|
|
@ -77,9 +81,16 @@ onUnmounted(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const widthClass = computed(() => {
|
const widthClass = computed(() => {
|
||||||
return {
|
const map = {
|
||||||
'48': 'w-48',
|
'48': 'w-48', // 12rem
|
||||||
}[props.width.toString()];
|
'64': 'w-64', // 16rem
|
||||||
|
'72': 'w-72', // 18rem
|
||||||
|
'80': 'w-80', // 20rem
|
||||||
|
'96': 'w-96', // 24rem
|
||||||
|
'wide': 'w-[34rem] max-w-[90vw]',
|
||||||
|
'auto': '',
|
||||||
|
};
|
||||||
|
return map[props.width.toString()] ?? '';
|
||||||
});
|
});
|
||||||
|
|
||||||
const alignmentClasses = computed(() => {
|
const alignmentClasses = computed(() => {
|
||||||
|
|
@ -93,6 +104,11 @@ const alignmentClasses = computed(() => {
|
||||||
|
|
||||||
return 'origin-top';
|
return 'origin-top';
|
||||||
});
|
});
|
||||||
|
const onContentClick = () => {
|
||||||
|
if (props.closeOnContentClick) {
|
||||||
|
open.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -120,7 +136,7 @@ const alignmentClasses = computed(() => {
|
||||||
:class="[widthClass]"
|
:class="[widthClass]"
|
||||||
:style="[panelStyle]"
|
:style="[panelStyle]"
|
||||||
>
|
>
|
||||||
<div class="rounded-md ring-1 ring-black ring-opacity-5" :class="contentClasses" @click="open = false">
|
<div class="rounded-md ring-1 ring-black ring-opacity-5" :class="contentClasses" @click="onContentClick">
|
||||||
<slot name="content" />
|
<slot name="content" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
show: {
|
show: {
|
||||||
|
|
@ -8,7 +8,7 @@ const props = defineProps({
|
||||||
},
|
},
|
||||||
maxWidth: {
|
maxWidth: {
|
||||||
type: String,
|
type: String,
|
||||||
default: '2xl',
|
default: "2xl",
|
||||||
},
|
},
|
||||||
closeable: {
|
closeable: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
|
|
@ -16,13 +16,15 @@ const props = defineProps({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['close']);
|
const emit = defineEmits(["close"]);
|
||||||
const dialog = ref();
|
const dialog = ref();
|
||||||
const showSlot = ref(props.show);
|
const showSlot = ref(props.show);
|
||||||
|
|
||||||
watch(() => props.show, () => {
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
() => {
|
||||||
if (props.show) {
|
if (props.show) {
|
||||||
document.body.style.overflow = 'hidden';
|
document.body.style.overflow = "hidden";
|
||||||
showSlot.value = true;
|
showSlot.value = true;
|
||||||
dialog.value?.showModal();
|
dialog.value?.showModal();
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -32,16 +34,17 @@ watch(() => props.show, () => {
|
||||||
showSlot.value = false;
|
showSlot.value = false;
|
||||||
}, 200);
|
}, 200);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
if (props.closeable) {
|
if (props.closeable) {
|
||||||
emit('close');
|
emit("close");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeOnEscape = (e) => {
|
const closeOnEscape = (e) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === "Escape") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (props.show) {
|
if (props.show) {
|
||||||
|
|
@ -50,26 +53,30 @@ const closeOnEscape = (e) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => document.addEventListener('keydown', closeOnEscape));
|
onMounted(() => document.addEventListener("keydown", closeOnEscape));
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener('keydown', closeOnEscape);
|
document.removeEventListener("keydown", closeOnEscape);
|
||||||
document.body.style.overflow = null;
|
document.body.style.overflow = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
const maxWidthClass = computed(() => {
|
const maxWidthClass = computed(() => {
|
||||||
return {
|
return {
|
||||||
'sm': 'sm:max-w-sm',
|
sm: "sm:max-w-sm",
|
||||||
'md': 'sm:max-w-md',
|
md: "sm:max-w-md",
|
||||||
'lg': 'sm:max-w-lg',
|
lg: "sm:max-w-lg",
|
||||||
'xl': 'sm:max-w-xl',
|
xl: "sm:max-w-xl",
|
||||||
'2xl': 'sm:max-w-2xl',
|
"2xl": "sm:max-w-2xl",
|
||||||
|
wide: "sm:max-w-[1200px] w-full",
|
||||||
}[props.maxWidth];
|
}[props.maxWidth];
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<dialog class="z-50 m-0 min-h-full min-w-full overflow-y-auto bg-transparent backdrop:bg-transparent" ref="dialog">
|
<dialog
|
||||||
|
class="z-50 m-0 min-h-full min-w-full overflow-y-auto bg-transparent backdrop:bg-transparent"
|
||||||
|
ref="dialog"
|
||||||
|
>
|
||||||
<div class="fixed inset-0 overflow-y-auto px-4 py-6 sm:px-0 z-50" scroll-region>
|
<div class="fixed inset-0 overflow-y-auto px-4 py-6 sm:px-0 z-50" scroll-region>
|
||||||
<transition
|
<transition
|
||||||
enter-active-class="ease-out duration-300"
|
enter-active-class="ease-out duration-300"
|
||||||
|
|
@ -92,8 +99,12 @@ const maxWidthClass = computed(() => {
|
||||||
leave-from-class="opacity-100 translate-y-0 sm:scale-100"
|
leave-from-class="opacity-100 translate-y-0 sm:scale-100"
|
||||||
leave-to-class="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
leave-to-class="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
>
|
>
|
||||||
<div v-show="show" class="mb-6 bg-white rounded-lg overflow-visible shadow-xl transform transition-all sm:w-full sm:mx-auto" :class="maxWidthClass">
|
<div
|
||||||
<slot v-if="showSlot"/>
|
v-show="show"
|
||||||
|
class="mb-6 bg-white rounded-lg overflow-visible shadow-xl transform transition-all sm:w-full sm:mx-auto"
|
||||||
|
:class="maxWidthClass"
|
||||||
|
>
|
||||||
|
<slot v-if="showSlot" />
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,6 @@ import Dropdown from "@/Components/Dropdown.vue";
|
||||||
import ConfirmationModal from "@/Components/ConfirmationModal.vue";
|
import ConfirmationModal from "@/Components/ConfirmationModal.vue";
|
||||||
import SecondaryButton from "@/Components/SecondaryButton.vue";
|
import SecondaryButton from "@/Components/SecondaryButton.vue";
|
||||||
import DangerButton from "@/Components/DangerButton.vue";
|
import DangerButton from "@/Components/DangerButton.vue";
|
||||||
import {
|
|
||||||
FwbTable,
|
|
||||||
FwbTableHead,
|
|
||||||
FwbTableHeadCell,
|
|
||||||
FwbTableBody,
|
|
||||||
FwbTableRow,
|
|
||||||
FwbTableCell,
|
|
||||||
} from "flowbite-vue";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||||
import { library } from "@fortawesome/fontawesome-svg-core";
|
import { library } from "@fortawesome/fontawesome-svg-core";
|
||||||
import { faTrash, faEllipsisVertical } from "@fortawesome/free-solid-svg-icons";
|
import { faTrash, faEllipsisVertical } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
|
@ -24,8 +16,6 @@ const props = defineProps({
|
||||||
activities: Object,
|
activities: Object,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Dropdown component manages its own open/close state
|
|
||||||
|
|
||||||
const fmtDate = (d) => {
|
const fmtDate = (d) => {
|
||||||
if (!d) return "";
|
if (!d) return "";
|
||||||
try {
|
try {
|
||||||
|
|
@ -34,6 +24,25 @@ const fmtDate = (d) => {
|
||||||
return String(d);
|
return String(d);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const fmtDateTime = (d) => {
|
||||||
|
if (!d) return "";
|
||||||
|
try {
|
||||||
|
const dt = new Date(d);
|
||||||
|
const datePart = dt.toLocaleDateString("sl-SI", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
});
|
||||||
|
const timePart = dt.toLocaleTimeString("sl-SI", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
return `${datePart} ${timePart}`;
|
||||||
|
} catch (e) {
|
||||||
|
return String(d);
|
||||||
|
}
|
||||||
|
};
|
||||||
const fmtCurrency = (v) => {
|
const fmtCurrency = (v) => {
|
||||||
const n = Number(v ?? 0);
|
const n = Number(v ?? 0);
|
||||||
try {
|
try {
|
||||||
|
|
@ -58,7 +67,7 @@ const deleteActivity = (row) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Confirmation modal state and handlers
|
// Confirmation modal state
|
||||||
const confirmDelete = ref(false);
|
const confirmDelete = ref(false);
|
||||||
const toDeleteRow = ref(null);
|
const toDeleteRow = ref(null);
|
||||||
const openDelete = (row) => {
|
const openDelete = (row) => {
|
||||||
|
|
@ -70,46 +79,107 @@ const cancelDelete = () => {
|
||||||
toDeleteRow.value = null;
|
toDeleteRow.value = null;
|
||||||
};
|
};
|
||||||
const confirmDeleteAction = () => {
|
const confirmDeleteAction = () => {
|
||||||
if (toDeleteRow.value) {
|
if (toDeleteRow.value) deleteActivity(toDeleteRow.value);
|
||||||
deleteActivity(toDeleteRow.value);
|
|
||||||
}
|
|
||||||
confirmDelete.value = false;
|
confirmDelete.value = false;
|
||||||
toDeleteRow.value = null;
|
toDeleteRow.value = null;
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="overflow-x-auto">
|
<div class="relative">
|
||||||
<FwbTable hoverable striped class="min-w-full text-left text-sm">
|
<div class="activity-scroll-wrapper max-h-[32rem] overflow-y-auto overflow-x-auto">
|
||||||
<FwbTableHead>
|
<table
|
||||||
<FwbTableHeadCell class="py-2 pr-4">Pogodba</FwbTableHeadCell>
|
class="activity-basic-table min-w-full table-fixed text-left text-sm border-collapse"
|
||||||
<FwbTableHeadCell class="py-2 pr-4">Datum</FwbTableHeadCell>
|
>
|
||||||
<FwbTableHeadCell class="py-2 pr-4">Akcija</FwbTableHeadCell>
|
<thead>
|
||||||
<FwbTableHeadCell class="py-2 pr-4">Odločitev</FwbTableHeadCell>
|
<tr>
|
||||||
<FwbTableHeadCell class="py-2 pr-4">Opomba</FwbTableHeadCell>
|
<th>Pogodba</th>
|
||||||
<FwbTableHeadCell class="py-2 pr-4">Datum zapadlosti</FwbTableHeadCell>
|
<th>Odločitev</th>
|
||||||
<FwbTableHeadCell class="py-2 pr-4 text-right">Znesek obljube</FwbTableHeadCell>
|
<th>Opomba</th>
|
||||||
<FwbTableHeadCell class="py-2 pr-4">Dodal</FwbTableHeadCell>
|
<th>Obljuba</th>
|
||||||
<FwbTableHeadCell class="py-2 pl-2 pr-2 w-8 text-right"></FwbTableHeadCell>
|
<th>Dodal</th>
|
||||||
</FwbTableHead>
|
<th class="w-8"></th>
|
||||||
|
</tr>
|
||||||
<FwbTableBody>
|
</thead>
|
||||||
<FwbTableRow v-for="row in activities.data" :key="row.id" class="border-b">
|
<tbody>
|
||||||
<FwbTableCell class="py-2 pr-4">{{
|
<tr
|
||||||
row.contract?.reference || ""
|
v-for="row in activities.data"
|
||||||
}}</FwbTableCell>
|
:key="row.id"
|
||||||
<FwbTableCell class="py-2 pr-4">{{ fmtDate(row.created_at) }}</FwbTableCell>
|
class="border-b last:border-b-0"
|
||||||
<FwbTableCell class="py-2 pr-4">{{ row.action?.name || "" }}</FwbTableCell>
|
>
|
||||||
<FwbTableCell class="py-2 pr-4">{{ row.decision?.name || "" }}</FwbTableCell>
|
<td class="py-2 pr-4 align-top">{{ row.contract?.reference || "" }}</td>
|
||||||
<FwbTableCell class="py-2 pr-4">{{ row.note || "" }}</FwbTableCell>
|
<td class="py-2 pr-4 align-top">
|
||||||
<FwbTableCell class="py-2 pr-4">{{ fmtDate(row.due_date) }}</FwbTableCell>
|
<div class="flex flex-col gap-1">
|
||||||
<FwbTableCell class="py-2 pr-4 text-right">{{
|
<span
|
||||||
fmtCurrency(row.amount)
|
v-if="row.action?.name"
|
||||||
}}</FwbTableCell>
|
class="inline-block w-fit px-2 py-0.5 rounded text-[10px] font-medium bg-indigo-100 text-indigo-700 tracking-wide uppercase"
|
||||||
<FwbTableCell class="py-2 pr-4">{{
|
>{{ row.action.name }}</span
|
||||||
row.user?.name || row.user_name || ""
|
>
|
||||||
}}</FwbTableCell>
|
<span class="text-gray-800">{{ row.decision?.name || "" }}</span>
|
||||||
<FwbTableCell class="py-2 pl-2 pr-2 text-right">
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 pr-4 align-top">
|
||||||
|
<div class="max-w-[280px] whitespace-pre-wrap break-words leading-snug">
|
||||||
|
<template v-if="row.note && row.note.length <= 160">{{
|
||||||
|
row.note
|
||||||
|
}}</template>
|
||||||
|
<template v-else-if="row.note">
|
||||||
|
<span>{{ row.note.slice(0, 160) }}… </span>
|
||||||
|
<Dropdown
|
||||||
|
align="left"
|
||||||
|
width="56"
|
||||||
|
:content-classes="['p-2', 'bg-white', 'shadow', 'max-w-xs']"
|
||||||
|
>
|
||||||
|
<template #trigger>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center text-[11px] text-indigo-600 hover:underline focus:outline-none"
|
||||||
|
>
|
||||||
|
Več
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<template #content>
|
||||||
|
<div
|
||||||
|
class="max-h-60 overflow-auto text-[12px] whitespace-pre-wrap break-words"
|
||||||
|
>
|
||||||
|
{{ row.note }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dropdown>
|
||||||
|
</template>
|
||||||
|
<template v-else><span class="text-gray-400">—</span></template>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 pr-4 align-top">
|
||||||
|
<div class="flex flex-col gap-1 text-[12px]">
|
||||||
|
<div v-if="row.amount && Number(row.amount) !== 0" class="leading-tight">
|
||||||
|
<span class="text-gray-500">Z:</span
|
||||||
|
><span class="font-medium ml-1">{{ fmtCurrency(row.amount) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="row.due_date" class="leading-tight">
|
||||||
|
<span class="text-gray-500">D:</span
|
||||||
|
><span class="ml-1">{{ fmtDate(row.due_date) }}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="!row.due_date && (!row.amount || Number(row.amount) === 0)"
|
||||||
|
class="text-gray-400"
|
||||||
|
>
|
||||||
|
—
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 pr-4 align-top">
|
||||||
|
<div class="text-gray-800 font-medium leading-tight">
|
||||||
|
{{ row.user?.name || row.user_name || "" }}
|
||||||
|
</div>
|
||||||
|
<div v-if="row.created_at" class="mt-1">
|
||||||
|
<span
|
||||||
|
class="inline-block px-2 py-0.5 rounded text-[11px] bg-gray-100 text-gray-600 tracking-wide"
|
||||||
|
>{{ fmtDateTime(row.created_at) }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 pl-2 pr-2 align-top text-right">
|
||||||
<Dropdown align="right" width="30" :content-classes="['py-1', 'bg-white']">
|
<Dropdown align="right" width="30" :content-classes="['py-1', 'bg-white']">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<button
|
<button
|
||||||
|
|
@ -134,25 +204,22 @@ const confirmDeleteAction = () => {
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</FwbTableCell>
|
</td>
|
||||||
</FwbTableRow>
|
</tr>
|
||||||
|
<tr v-if="!activities?.data || activities.data.length === 0">
|
||||||
<FwbTableRow v-if="!activities?.data || activities.data.length === 0">
|
<td :colspan="6" class="py-4 text-gray-500">Ni aktivnosti.</td>
|
||||||
<FwbTableCell :colspan="9" class="py-4 text-gray-500"
|
</tr>
|
||||||
>Ni aktivnosti.</FwbTableCell
|
</tbody>
|
||||||
>
|
</table>
|
||||||
</FwbTableRow>
|
</div>
|
||||||
</FwbTableBody>
|
|
||||||
</FwbTable>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Confirm deletion modal -->
|
|
||||||
<ConfirmationModal :show="confirmDelete" @close="cancelDelete">
|
<ConfirmationModal :show="confirmDelete" @close="cancelDelete">
|
||||||
<template #title>Potrditev</template>
|
<template #title>Potrditev</template>
|
||||||
<template #content>
|
<template #content
|
||||||
Ali ste prepričani, da želite izbrisati to aktivnost? Tega dejanja ni mogoče
|
>Ali ste prepričani, da želite izbrisati to aktivnost? Tega dejanja ni mogoče
|
||||||
razveljaviti.
|
razveljaviti.</template
|
||||||
</template>
|
>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<SecondaryButton type="button" @click="cancelDelete">Prekliči</SecondaryButton>
|
<SecondaryButton type="button" @click="cancelDelete">Prekliči</SecondaryButton>
|
||||||
<DangerButton type="button" class="ml-2" @click="confirmDeleteAction"
|
<DangerButton type="button" class="ml-2" @click="confirmDeleteAction"
|
||||||
|
|
@ -161,3 +228,59 @@ const confirmDeleteAction = () => {
|
||||||
</template>
|
</template>
|
||||||
</ConfirmationModal>
|
</ConfirmationModal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.activity-scroll-wrapper {
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
.activity-basic-table thead th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 20;
|
||||||
|
background: #ffffff;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #374151;
|
||||||
|
padding: 0.5rem 1rem; /* unified horizontal padding */
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
.activity-basic-table tbody td {
|
||||||
|
vertical-align: top;
|
||||||
|
padding: 0.625rem 1rem; /* match header horizontal padding */
|
||||||
|
}
|
||||||
|
/* Ensure first column lines up exactly (no extra offset) */
|
||||||
|
.activity-basic-table th:first-child,
|
||||||
|
.activity-basic-table td:first-child {
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
||||||
|
.activity-basic-table tbody tr:hover {
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
/* Column sizing hints (optional fine tuning) */
|
||||||
|
.activity-basic-table th:nth-child(1),
|
||||||
|
.activity-basic-table td:nth-child(1) {
|
||||||
|
width: 14%;
|
||||||
|
}
|
||||||
|
.activity-basic-table th:nth-child(2),
|
||||||
|
.activity-basic-table td:nth-child(2) {
|
||||||
|
width: 16%;
|
||||||
|
}
|
||||||
|
.activity-basic-table th:nth-child(3),
|
||||||
|
.activity-basic-table td:nth-child(3) {
|
||||||
|
width: 26%;
|
||||||
|
}
|
||||||
|
.activity-basic-table th:nth-child(4),
|
||||||
|
.activity-basic-table td:nth-child(4) {
|
||||||
|
width: 14%;
|
||||||
|
}
|
||||||
|
.activity-basic-table th:nth-child(5),
|
||||||
|
.activity-basic-table td:nth-child(5) {
|
||||||
|
width: 20%;
|
||||||
|
}
|
||||||
|
.activity-basic-table th:nth-child(6),
|
||||||
|
.activity-basic-table td:nth-child(6) {
|
||||||
|
width: 10%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,647 +1,268 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||||
import { ref, watch, computed, onMounted } from "vue";
|
|
||||||
import { useForm, router } from "@inertiajs/vue3";
|
import { useForm, router } from "@inertiajs/vue3";
|
||||||
|
import { ref, computed } from "vue";
|
||||||
import Multiselect from "vue-multiselect";
|
import Multiselect from "vue-multiselect";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
|
// Props: provided by controller (clients + templates collections)
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
templates: Array,
|
templates: Array,
|
||||||
clients: Array,
|
clients: Array,
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasHeader = ref(true);
|
// Basic create form (rest of workflow handled on the Continue page)
|
||||||
const detected = ref({ columns: [], delimiter: ",", has_header: true });
|
|
||||||
const importId = ref(null);
|
|
||||||
const templateApplied = ref(false);
|
|
||||||
const processing = ref(false);
|
|
||||||
const processResult = ref(null);
|
|
||||||
const mappingRows = ref([]);
|
|
||||||
const mappingSaved = ref(false);
|
|
||||||
const mappingSavedCount = ref(0);
|
|
||||||
const selectedMappingsCount = computed(
|
|
||||||
() => mappingRows.value.filter((r) => !r.skip && r.entity && r.field).length
|
|
||||||
);
|
|
||||||
const mappingError = ref("");
|
|
||||||
const savingMappings = ref(false);
|
|
||||||
|
|
||||||
// Dynamic entity definitions and suggestions from API
|
|
||||||
const entityDefs = ref([]);
|
|
||||||
const entityOptions = computed(() =>
|
|
||||||
entityDefs.value.map((e) => ({ value: e.key, label: e.label || e.key }))
|
|
||||||
);
|
|
||||||
const fieldOptionsByEntity = computed(() =>
|
|
||||||
Object.fromEntries(
|
|
||||||
entityDefs.value.map((e) => [
|
|
||||||
e.key,
|
|
||||||
(e.fields || []).map((f) => ({ value: f, label: f })),
|
|
||||||
])
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const canonicalRootByKey = computed(() =>
|
|
||||||
Object.fromEntries(entityDefs.value.map((e) => [e.key, e.canonical_root || e.key]))
|
|
||||||
);
|
|
||||||
const keyByCanonicalRoot = computed(() => {
|
|
||||||
const m = {};
|
|
||||||
for (const e of entityDefs.value) {
|
|
||||||
if (e.canonical_root) {
|
|
||||||
m[e.canonical_root] = e.key;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m;
|
|
||||||
});
|
|
||||||
const suggestions = ref({});
|
|
||||||
async function loadEntityDefs() {
|
|
||||||
try {
|
|
||||||
const { data } = await axios.get("/api/import-entities");
|
|
||||||
entityDefs.value = data?.entities || [];
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to load import entity definitions", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async function refreshSuggestions(columns) {
|
|
||||||
const cols = Array.isArray(columns) ? columns : detected.value.columns || [];
|
|
||||||
if (!cols || cols.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
// When a template is chosen and provides meta.entities, limit suggestions to those entities
|
|
||||||
const only = (selectedTemplate.value?.meta?.entities || []);
|
|
||||||
const { data } = await axios.post("/api/import-entities/suggest", { columns: cols, only_entities: only });
|
|
||||||
suggestions.value = { ...suggestions.value, ...(data?.suggestions || {}) };
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to load suggestions", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function applySuggestionToRow(row) {
|
|
||||||
const s = suggestions.value[row.source_column];
|
|
||||||
if (!s) return false;
|
|
||||||
if (!fieldOptionsByEntity.value[s.entity]) return false;
|
|
||||||
row.entity = s.entity;
|
|
||||||
row.field = s.field;
|
|
||||||
// default transform on if missing
|
|
||||||
if (!row.transform) {
|
|
||||||
row.transform = "trim";
|
|
||||||
}
|
|
||||||
if (!row.apply_mode) {
|
|
||||||
row.apply_mode = "both";
|
|
||||||
}
|
|
||||||
row.skip = false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
client_uuid: null,
|
client_uuid: null,
|
||||||
import_template_id: null,
|
import_template_id: null,
|
||||||
source_type: null,
|
|
||||||
sheet_name: null,
|
|
||||||
has_header: true,
|
has_header: true,
|
||||||
file: null,
|
file: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Bridge Multiselect (expects option objects) to our form (stores client_uuid as string)
|
// Multiselect bridge: client
|
||||||
const selectedClientOption = computed({
|
const selectedClientOption = computed({
|
||||||
get() {
|
get() {
|
||||||
const cuuid = form.client_uuid;
|
if (!form.client_uuid) return null;
|
||||||
if (!cuuid) return null;
|
return (props.clients || []).find((c) => c.uuid === form.client_uuid) || null;
|
||||||
return (props.clients || []).find((c) => c.uuid === cuuid) || null;
|
|
||||||
},
|
},
|
||||||
set(val) {
|
set(val) {
|
||||||
form.client_uuid = val ? val.uuid : null;
|
form.client_uuid = val ? val.uuid : null;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Bridge Template Multiselect to store only template id (number) in form
|
// Multiselect bridge: template
|
||||||
const selectedTemplateOption = computed({
|
const selectedTemplateOption = computed({
|
||||||
get() {
|
get() {
|
||||||
const tid = form.import_template_id;
|
if (form.import_template_id == null) return null;
|
||||||
if (tid == null) return null;
|
return (props.templates || []).find((t) => t.id === form.import_template_id) || null;
|
||||||
return (props.templates || []).find((t) => t.id === tid) || null;
|
|
||||||
},
|
},
|
||||||
set(val) {
|
set(val) {
|
||||||
form.import_template_id = val ? val.id : null;
|
form.import_template_id = val ? val.id : null;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper: selected client's numeric id (fallback)
|
// Filter templates: show globals when no client; when client selected show only that client's templates (no mixing to avoid confusion)
|
||||||
const selectedClientId = computed(() => {
|
|
||||||
const cuuid = form.client_uuid;
|
|
||||||
if (!cuuid) return null;
|
|
||||||
const c = (props.clients || []).find((x) => x.uuid === cuuid);
|
|
||||||
return c ? c.id : null;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Show only global templates when no client is selected.
|
|
||||||
// When a client is selected, show only that client's templates (match by client_uuid).
|
|
||||||
const filteredTemplates = computed(() => {
|
const filteredTemplates = computed(() => {
|
||||||
const cuuid = form.client_uuid;
|
const cuuid = form.client_uuid;
|
||||||
const list = props.templates || [];
|
const list = props.templates || [];
|
||||||
if (!cuuid) {
|
if (!cuuid) {
|
||||||
return list.filter((t) => t.client_id == null);
|
return list.filter((t) => t.client_id == null);
|
||||||
}
|
}
|
||||||
// When client is selected, only show that client's templates (no globals)
|
return list.filter((t) => t.client_uuid === cuuid);
|
||||||
return list.filter(
|
|
||||||
(t) => (t.client_uuid && t.client_uuid === cuuid) || t.client_id == null
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const uploading = ref(false);
|
||||||
|
const dragActive = ref(false);
|
||||||
|
const uploadError = ref(null);
|
||||||
|
|
||||||
function onFileChange(e) {
|
function onFileChange(e) {
|
||||||
const files = e.target.files;
|
const files = e.target.files;
|
||||||
if (files && files.length) {
|
if (files && files.length) {
|
||||||
form.file = files[0];
|
form.file = files[0];
|
||||||
|
uploadError.value = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitUpload() {
|
function onFileDrop(e) {
|
||||||
await form.post(route("imports.store"), {
|
const files = e.dataTransfer?.files;
|
||||||
forceFormData: true,
|
if (files && files.length) {
|
||||||
onSuccess: (res) => {
|
form.file = files[0];
|
||||||
const data = res?.props || {};
|
uploadError.value = null;
|
||||||
},
|
|
||||||
onFinish: async () => {
|
|
||||||
// After upload, fetch columns for preview
|
|
||||||
if (!form.recentlySuccessful) return;
|
|
||||||
// Inertia doesn't expose JSON response directly with useForm; fallback to API call using fetch
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append("file", form.file);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchColumns() {
|
|
||||||
if (!importId.value) return;
|
|
||||||
const url = route("imports.columns", { import: importId.value });
|
|
||||||
const { data } = await axios.get(url, {
|
|
||||||
params: { has_header: hasHeader.value ? 1 : 0 },
|
|
||||||
});
|
|
||||||
detected.value = {
|
|
||||||
columns: data.columns || [],
|
|
||||||
delimiter: data.detected_delimiter || ",",
|
|
||||||
has_header: !!data.has_header,
|
|
||||||
};
|
|
||||||
// initialize simple mapping rows with defaults if none exist
|
|
||||||
if (!mappingRows.value.length) {
|
|
||||||
mappingRows.value = (detected.value.columns || []).map((c, idx) => ({
|
|
||||||
source_column: c,
|
|
||||||
entity: "",
|
|
||||||
field: "",
|
|
||||||
skip: false,
|
|
||||||
transform: "trim",
|
|
||||||
apply_mode: "both",
|
|
||||||
position: idx,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
await refreshSuggestions(detected.value.columns);
|
dragActive.value = false;
|
||||||
// If there are mappings already (template applied or saved), load them to auto-assign
|
|
||||||
await loadImportMappings();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadAndPreview() {
|
async function startImport() {
|
||||||
|
uploadError.value = null;
|
||||||
if (!form.file) {
|
if (!form.file) {
|
||||||
// Basic guard: require a file before proceeding
|
uploadError.value = "Najprej izberite datoteko."; // "Select a file first."
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
templateApplied.value = false;
|
uploading.value = true;
|
||||||
processResult.value = null;
|
try {
|
||||||
const fd = new window.FormData();
|
const fd = new FormData();
|
||||||
fd.append("file", form.file);
|
fd.append("file", form.file);
|
||||||
if (
|
if (form.import_template_id != null) {
|
||||||
form.import_template_id !== null &&
|
|
||||||
form.import_template_id !== undefined &&
|
|
||||||
String(form.import_template_id).trim() !== ""
|
|
||||||
) {
|
|
||||||
fd.append("import_template_id", String(form.import_template_id));
|
fd.append("import_template_id", String(form.import_template_id));
|
||||||
}
|
}
|
||||||
if (form.client_uuid) {
|
if (form.client_uuid) {
|
||||||
fd.append("client_uuid", String(form.client_uuid));
|
fd.append("client_uuid", form.client_uuid);
|
||||||
}
|
}
|
||||||
fd.append("has_header", hasHeader.value ? "1" : "0");
|
fd.append("has_header", form.has_header ? "1" : "0");
|
||||||
try {
|
|
||||||
const { data } = await axios.post(route("imports.store"), fd, {
|
const { data } = await axios.post(route("imports.store"), fd, {
|
||||||
headers: { Accept: "application/json" },
|
headers: { Accept: "application/json" },
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
});
|
});
|
||||||
// Redirect immediately to the continue page for this import
|
|
||||||
if (data?.uuid) {
|
if (data?.uuid) {
|
||||||
router.visit(route("imports.continue", { import: data.uuid }));
|
router.visit(route("imports.continue", { import: data.uuid }));
|
||||||
} else if (data?.id) {
|
|
||||||
// Fallback: if uuid not returned for some reason, fetch columns here (legacy)
|
|
||||||
importId.value = data.id;
|
|
||||||
await fetchColumns();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (e.response) {
|
|
||||||
console.error("Upload error", e.response.status, e.response.data);
|
|
||||||
if (e.response.data?.errors) {
|
|
||||||
// Optionally you could surface errors in the UI; for now, log for visibility
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.error("Upload error", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If continuing an existing import, set importId and hydrate columns and mappings
|
|
||||||
// No continuation logic on Create page anymore
|
|
||||||
|
|
||||||
async function applyTemplateToImport() {
|
|
||||||
if (!importId.value || !form.import_template_id) return;
|
|
||||||
try {
|
|
||||||
await axios.post(
|
|
||||||
route("importTemplates.apply", {
|
|
||||||
template: form.import_template_id,
|
|
||||||
import: importId.value,
|
|
||||||
}),
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
headers: { Accept: "application/json" },
|
|
||||||
withCredentials: true,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
templateApplied.value = true;
|
|
||||||
// Load mappings and auto-assign UI rows
|
|
||||||
await loadImportMappings();
|
|
||||||
} catch (e) {
|
|
||||||
templateApplied.value = false;
|
|
||||||
if (e.response) {
|
|
||||||
console.error("Apply template error", e.response.status, e.response.data);
|
|
||||||
} else {
|
|
||||||
console.error("Apply template error", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadImportMappings() {
|
|
||||||
if (!importId.value) return;
|
|
||||||
try {
|
|
||||||
const { data } = await axios.get(
|
|
||||||
route("imports.mappings.get", { import: importId.value }),
|
|
||||||
{
|
|
||||||
headers: { Accept: "application/json" },
|
|
||||||
withCredentials: true,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const rows = Array.isArray(data?.mappings) ? data.mappings : [];
|
|
||||||
if (!rows.length) return;
|
|
||||||
// Build a lookup by source_column
|
|
||||||
const bySource = new Map(rows.map((r) => [r.source_column, r]));
|
|
||||||
// Update mappingRows (detected columns) to reflect applied mappings
|
|
||||||
mappingRows.value = (mappingRows.value || []).map((r, idx) => {
|
|
||||||
const m = bySource.get(r.source_column);
|
|
||||||
if (!m) return r;
|
|
||||||
// Parse target_field like 'person.first_name' into UI entity/field
|
|
||||||
const [record, field] = String(m.target_field || "").split(".", 2);
|
|
||||||
const entity = keyByCanonicalRoot.value[record] || record;
|
|
||||||
return {
|
|
||||||
...r,
|
|
||||||
entity,
|
|
||||||
field: field || "",
|
|
||||||
transform: m.transform || "",
|
|
||||||
apply_mode: m.apply_mode || "both",
|
|
||||||
skip: false,
|
|
||||||
position: idx,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.error(
|
|
||||||
"Load import mappings error",
|
|
||||||
e.response?.status || "",
|
|
||||||
e.response?.data || e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function processImport() {
|
|
||||||
if (!importId.value) return;
|
|
||||||
processing.value = true;
|
|
||||||
processResult.value = null;
|
|
||||||
try {
|
|
||||||
const { data } = await axios.post(
|
|
||||||
route("imports.process", { import: importId.value }),
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
headers: { Accept: "application/json" },
|
|
||||||
withCredentials: true,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
processResult.value = data;
|
|
||||||
} catch (e) {
|
|
||||||
if (e.response) {
|
|
||||||
console.error("Process import error", e.response.status, e.response.data);
|
|
||||||
processResult.value = { error: e.response.data || "Processing failed" };
|
|
||||||
} else {
|
|
||||||
console.error("Process import error", e);
|
|
||||||
processResult.value = { error: "Processing failed" };
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
processing.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// entity options and fields are dynamic from API
|
|
||||||
|
|
||||||
async function saveMappings() {
|
|
||||||
if (!importId.value) return;
|
|
||||||
mappingError.value = "";
|
|
||||||
const mappings = mappingRows.value
|
|
||||||
.filter((r) => !r.skip && r.entity && r.field)
|
|
||||||
.map((r) => ({
|
|
||||||
source_column: r.source_column,
|
|
||||||
target_field: `${canonicalRootByKey.value[r.entity] || r.entity}.${r.field}`,
|
|
||||||
transform: r.transform || null,
|
|
||||||
apply_mode: r.apply_mode || "both",
|
|
||||||
options: null,
|
|
||||||
}));
|
|
||||||
if (!mappings.length) {
|
|
||||||
mappingSaved.value = false;
|
|
||||||
mappingError.value =
|
|
||||||
"Select entity and field for at least one column (or uncheck Skip) before saving.";
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
if (data?.id) {
|
||||||
savingMappings.value = true;
|
// Fallback if only numeric id returned
|
||||||
const url =
|
router.visit(route("imports.continue", { import: data.id }));
|
||||||
typeof route === "function"
|
return;
|
||||||
? route("imports.mappings.save", { import: importId.value })
|
|
||||||
: `/imports/${importId.value}/mappings`;
|
|
||||||
const { data } = await axios.post(
|
|
||||||
url,
|
|
||||||
{ mappings },
|
|
||||||
{
|
|
||||||
headers: { Accept: "application/json" },
|
|
||||||
withCredentials: true,
|
|
||||||
}
|
}
|
||||||
);
|
uploadError.value = "Nepričakovan odgovor strežnika."; // Unexpected server response.
|
||||||
mappingSaved.value = true;
|
|
||||||
mappingSavedCount.value = Number(data?.saved || mappings.length);
|
|
||||||
mappingError.value = "";
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
mappingSaved.value = false;
|
if (e.response?.data?.message) {
|
||||||
if (e.response) {
|
uploadError.value = e.response.data.message;
|
||||||
console.error("Save mappings error", e.response.status, e.response.data);
|
|
||||||
alert(
|
|
||||||
"Failed to save mappings: " + (e.response.data?.message || e.response.status)
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
console.error("Save mappings error", e);
|
uploadError.value = "Nalaganje ni uspelo."; // Upload failed.
|
||||||
alert("Failed to save mappings. See console for details.");
|
|
||||||
}
|
}
|
||||||
|
console.error("Import upload failed", e.response?.status, e.response?.data || e);
|
||||||
} finally {
|
} finally {
|
||||||
savingMappings.value = false;
|
uploading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset saved flag whenever user edits mappings
|
|
||||||
watch(
|
|
||||||
mappingRows,
|
|
||||||
() => {
|
|
||||||
mappingSaved.value = false;
|
|
||||||
mappingSavedCount.value = 0;
|
|
||||||
mappingError.value = "";
|
|
||||||
},
|
|
||||||
{ deep: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await loadEntityDefs();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout title="New Import">
|
<AppLayout title="Nov uvoz">
|
||||||
<template #header>
|
<template #header>
|
||||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">New Import</h2>
|
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Nov uvoz</h2>
|
||||||
</template>
|
</template>
|
||||||
<div class="py-6">
|
<div class="py-6">
|
||||||
<div class="max-w-5xl mx-auto sm:px-6 lg:px-8">
|
<div class="max-w-4xl mx-auto sm:px-6 lg:px-8">
|
||||||
<div class="bg-white shadow sm:rounded-lg p-6 space-y-6">
|
<div class="bg-white shadow sm:rounded-lg p-6 space-y-8">
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<!-- Intro / guidance -->
|
||||||
|
<div class="text-sm text-gray-600 leading-relaxed">
|
||||||
|
<p class="mb-2">
|
||||||
|
1) Izberite stranko (opcijsko) in predlogo (če obstaja), 2) izberite
|
||||||
|
datoteko (CSV, TXT, XLSX*) in 3) kliknite Začni uvoz. Nadaljnje preslikave
|
||||||
|
in simulacija bodo na naslednji strani.
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
* XLSX podpora je odvisna od konfiguracije strežnika.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Client & Template selection -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700">Client</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">Stranka</label>
|
||||||
<Multiselect
|
<Multiselect
|
||||||
v-model="selectedClientOption"
|
v-model="selectedClientOption"
|
||||||
:options="clients"
|
:options="clients"
|
||||||
track-by="uuid"
|
track-by="uuid"
|
||||||
label="name"
|
label="name"
|
||||||
placeholder="Search clients..."
|
placeholder="Poišči stranko..."
|
||||||
:searchable="true"
|
:searchable="true"
|
||||||
:allow-empty="true"
|
:allow-empty="true"
|
||||||
class="mt-1"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700">Template</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">Predloga</label>
|
||||||
<Multiselect
|
<Multiselect
|
||||||
v-model="selectedTemplateOption"
|
v-model="selectedTemplateOption"
|
||||||
:options="filteredTemplates"
|
:options="filteredTemplates"
|
||||||
track-by="id"
|
track-by="id"
|
||||||
label="name"
|
label="name"
|
||||||
placeholder="Search templates..."
|
placeholder="Poišči predlogo..."
|
||||||
:searchable="true"
|
:searchable="true"
|
||||||
:allow-empty="true"
|
:allow-empty="true"
|
||||||
class="mt-1"
|
|
||||||
>
|
>
|
||||||
<template #option="{ option }">
|
<template #option="{ option }">
|
||||||
<div class="flex items-center justify-between w-full">
|
<div class="flex items-center justify-between w-full">
|
||||||
<div class="flex items-center gap-2">
|
<span class="truncate">{{ option.name }}</span>
|
||||||
<span>{{ option.name }}</span>
|
|
||||||
<span class="ml-2 text-xs text-gray-500"
|
|
||||||
>({{ option.source_type }})</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<span
|
<span
|
||||||
class="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600"
|
class="ml-2 text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600"
|
||||||
>{{ option.client_id ? "Client" : "Global" }}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #singleLabel="{ option }">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span>{{ option.name }}</span>
|
|
||||||
<span class="ml-1 text-xs text-gray-500"
|
|
||||||
>({{ option.source_type }})</span
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600"
|
|
||||||
>{{ option.client_id ? "Client" : "Global" }}</span
|
>{{ option.client_id ? "Client" : "Global" }}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Multiselect>
|
</Multiselect>
|
||||||
<p class="text-xs text-gray-500 mt-1" v-if="!form.client_uuid">
|
<p class="text-xs text-gray-500 mt-1" v-if="!form.client_uuid">
|
||||||
Only global templates are shown until a client is selected.
|
Prikazane so samo globalne predloge dokler ne izberete stranke.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<!-- File + Header -->
|
||||||
<div>
|
<div class="grid grid-cols-1 gap-6 items-start">
|
||||||
<label class="block text-sm font-medium text-gray-700">File</label>
|
<div class="md:col-span-2">
|
||||||
<input type="file" @change="onFileChange" class="mt-1 block w-full" />
|
<label class="block text-sm font-medium text-gray-700 mb-1">Datoteka</label>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700"
|
|
||||||
>Has header row</label
|
|
||||||
>
|
|
||||||
<input type="checkbox" v-model="hasHeader" class="mt-2" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<button
|
|
||||||
@click.prevent="uploadAndPreview"
|
|
||||||
class="px-4 py-2 bg-blue-600 text-white rounded"
|
|
||||||
>
|
|
||||||
Upload & Preview Columns
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click.prevent="applyTemplateToImport"
|
|
||||||
:disabled="!importId || !form.import_template_id || templateApplied"
|
|
||||||
class="px-4 py-2 bg-emerald-600 disabled:bg-gray-300 text-white rounded"
|
|
||||||
>
|
|
||||||
{{ templateApplied ? "Template Applied" : "Apply Template" }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click.prevent="saveMappings"
|
|
||||||
:disabled="!importId || processing || savingMappings"
|
|
||||||
class="px-4 py-2 bg-orange-600 disabled:bg-gray-300 text-white rounded flex items-center gap-2"
|
|
||||||
title="Save ad-hoc mappings for this import"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
v-if="savingMappings"
|
|
||||||
class="inline-block h-4 w-4 border-2 border-white/70 border-t-transparent rounded-full animate-spin"
|
|
||||||
></span>
|
|
||||||
<span>Save Mappings</span>
|
|
||||||
<span
|
|
||||||
v-if="selectedMappingsCount"
|
|
||||||
class="ml-1 text-xs bg-white/20 px-1.5 py-0.5 rounded"
|
|
||||||
>{{ selectedMappingsCount }}</span
|
|
||||||
>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click.prevent="processImport"
|
|
||||||
:disabled="!importId || processing || (!templateApplied && !mappingSaved)"
|
|
||||||
class="px-4 py-2 bg-purple-600 disabled:bg-gray-300 text-white rounded"
|
|
||||||
>
|
|
||||||
{{ processing ? "Processing…" : "Process Import" }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="mt-2 text-xs text-gray-600" v-if="!importId">
|
|
||||||
Upload a file first to enable saving mappings.
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
class="mt-2 text-xs text-gray-600"
|
class="border-2 border-dashed rounded-md p-6 text-center cursor-pointer transition-colors"
|
||||||
v-else-if="importId && !selectedMappingsCount"
|
:class="{
|
||||||
|
'border-indigo-400 bg-indigo-50': dragActive,
|
||||||
|
'border-gray-300 hover:border-gray-400': !dragActive,
|
||||||
|
}"
|
||||||
|
@dragover.prevent="dragActive = true"
|
||||||
|
@dragleave.prevent="dragActive = false"
|
||||||
|
@drop.prevent="onFileDrop"
|
||||||
>
|
>
|
||||||
Select an Entity and Field for at least one detected column (or uncheck Skip)
|
<input
|
||||||
and then click Save Mappings.
|
type="file"
|
||||||
|
class="hidden"
|
||||||
|
id="import-file-input"
|
||||||
|
@change="onFileChange"
|
||||||
|
/>
|
||||||
|
<label for="import-file-input" class="block cursor-pointer select-none">
|
||||||
|
<div v-if="!form.file" class="text-sm text-gray-600">
|
||||||
|
Povlecite datoteko sem ali
|
||||||
|
<span class="text-indigo-600 underline">kliknite za izbiro</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-sm text-gray-800 flex flex-col gap-1">
|
||||||
|
<span class="font-medium">{{ form.file.name }}</span>
|
||||||
|
<span class="text-xs text-gray-500"
|
||||||
|
>{{ (form.file.size / 1024).toFixed(1) }} kB</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-[10px] inline-block bg-gray-100 px-1.5 py-0.5 rounded"
|
||||||
|
>Zamenjaj</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="flex items-center gap-2 text-sm font-medium text-gray-700">
|
||||||
|
<input type="checkbox" v-model="form.has_header" class="rounded" />
|
||||||
|
<span>Prva vrstica je glava</span>
|
||||||
|
</label>
|
||||||
|
<div class="text-xs text-gray-500 leading-relaxed">
|
||||||
|
Če ni označeno, bodo stolpci poimenovani po zaporedju (A, B, C ...).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="detected.columns.length" class="pt-4">
|
<!-- Errors -->
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div v-if="uploadError" class="text-sm text-red-600">
|
||||||
<h3 class="font-semibold">
|
{{ uploadError }}
|
||||||
Detected Columns ({{ detected.has_header ? "header" : "positional" }})
|
</div>
|
||||||
</h3>
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex flex-wrap gap-3 pt-2">
|
||||||
<button
|
<button
|
||||||
class="px-3 py-1.5 border rounded text-sm"
|
type="button"
|
||||||
@click.prevent="
|
@click="startImport"
|
||||||
(async () => {
|
:disabled="uploading"
|
||||||
await refreshSuggestions(detected.columns);
|
class="inline-flex items-center gap-2 px-5 py-2.5 rounded bg-indigo-600 disabled:bg-indigo-300 text-white text-sm font-medium shadow-sm"
|
||||||
mappingRows.forEach((r) => applySuggestionToRow(r));
|
>
|
||||||
})()
|
<span
|
||||||
|
v-if="uploading"
|
||||||
|
class="h-4 w-4 border-2 border-white/60 border-t-transparent rounded-full animate-spin"
|
||||||
|
></span>
|
||||||
|
<span>{{ uploading ? "Nalagam..." : "Začni uvoz" }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="
|
||||||
|
() => {
|
||||||
|
form.file = null;
|
||||||
|
uploadError = null;
|
||||||
|
}
|
||||||
"
|
"
|
||||||
|
:disabled="uploading || !form.file"
|
||||||
|
class="px-4 py-2 text-sm rounded border bg-white disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Auto map suggestions
|
Počisti
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="min-w-full border bg-white">
|
|
||||||
<thead>
|
|
||||||
<tr class="bg-gray-50 text-left text-xs uppercase text-gray-600">
|
|
||||||
<th class="p-2 border">Source column</th>
|
|
||||||
<th class="p-2 border">Entity</th>
|
|
||||||
<th class="p-2 border">Field</th>
|
|
||||||
<th class="p-2 border">Transform</th>
|
|
||||||
<th class="p-2 border">Apply mode</th>
|
|
||||||
<th class="p-2 border">Skip</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="(row, idx) in mappingRows" :key="idx" class="border-t">
|
|
||||||
<td class="p-2 border text-sm">
|
|
||||||
<div>{{ row.source_column }}</div>
|
|
||||||
<div class="text-xs mt-1" v-if="suggestions[row.source_column]">
|
|
||||||
<span class="text-gray-500">Suggest:</span>
|
|
||||||
<button
|
|
||||||
class="ml-1 underline text-indigo-700 hover:text-indigo-900"
|
|
||||||
@click.prevent="applySuggestionToRow(row)"
|
|
||||||
>
|
|
||||||
{{ suggestions[row.source_column].entity }}.{{
|
|
||||||
suggestions[row.source_column].field
|
|
||||||
}}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="p-2 border">
|
|
||||||
<select v-model="row.entity" class="border rounded p-1 w-full">
|
|
||||||
<option value="">—</option>
|
|
||||||
<option
|
|
||||||
v-for="opt in entityOptions"
|
|
||||||
:key="opt.value"
|
|
||||||
:value="opt.value"
|
|
||||||
>
|
|
||||||
{{ opt.label }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td class="p-2 border">
|
|
||||||
<select v-model="row.field" class="border rounded p-1 w-full">
|
|
||||||
<option value="">—</option>
|
|
||||||
<option
|
|
||||||
v-for="f in fieldOptionsByEntity[row.entity] || []"
|
|
||||||
:key="f.value"
|
|
||||||
:value="f.value"
|
|
||||||
>
|
|
||||||
{{ f.label }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td class="p-2 border">
|
|
||||||
<select v-model="row.transform" class="border rounded p-1 w-full">
|
|
||||||
<option value="">None</option>
|
|
||||||
<option value="trim">Trim</option>
|
|
||||||
<option value="upper">Uppercase</option>
|
|
||||||
<option value="lower">Lowercase</option>
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td class="p-2 border">
|
|
||||||
<select v-model="row.apply_mode" class="border rounded p-1 w-full">
|
|
||||||
<option value="both">Both</option>
|
|
||||||
<option value="insert">Insert only</option>
|
|
||||||
<option value="update">Update only</option>
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td class="p-2 border text-center">
|
|
||||||
<input type="checkbox" v-model="row.skip" />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div v-if="mappingSaved" class="text-sm text-emerald-700 mt-2">
|
|
||||||
Mappings saved ({{ mappingSavedCount }}).
|
|
||||||
</div>
|
|
||||||
<div v-else-if="mappingError" class="text-sm text-red-600 mt-2">
|
|
||||||
{{ mappingError }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="processResult" class="pt-4">
|
<div class="text-xs text-gray-400 pt-4 border-t">
|
||||||
<h3 class="font-semibold mb-2">Import Result</h3>
|
Po nalaganju boste preusmerjeni na nadaljevanje uvoza, kjer lahko izvedete
|
||||||
<pre class="bg-gray-50 border rounded p-3 text-sm overflow-x-auto">{{
|
preslikave, simulacijo in končno obdelavo.
|
||||||
processResult
|
|
||||||
}}</pre>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,21 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||||
|
import TemplateControls from "./Partials/TemplateControls.vue";
|
||||||
|
import ChecklistSteps from "./Partials/ChecklistSteps.vue";
|
||||||
|
import MappingTable from "./Partials/MappingTable.vue";
|
||||||
|
import ActionsBar from "./Partials/ActionsBar.vue";
|
||||||
|
import SavedMappingsTable from "./Partials/SavedMappingsTable.vue";
|
||||||
|
import LogsTable from "./Partials/LogsTable.vue";
|
||||||
|
import ProcessResult from "./Partials/ProcessResult.vue";
|
||||||
import { ref, computed, onMounted, watch } from "vue";
|
import { ref, computed, onMounted, watch } from "vue";
|
||||||
import Multiselect from "vue-multiselect";
|
import Multiselect from "vue-multiselect";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import Modal from "@/Components/Modal.vue"; // still potentially used elsewhere
|
||||||
|
import CsvPreviewModal from "./Partials/CsvPreviewModal.vue";
|
||||||
|
import SimulationModal from "./Partials/SimulationModal.vue";
|
||||||
|
import { useCurrencyFormat } from "./useCurrencyFormat.js";
|
||||||
|
|
||||||
|
// Reintroduce props definition lost during earlier edits
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
import: Object,
|
import: Object,
|
||||||
templates: Array,
|
templates: Array,
|
||||||
|
|
@ -11,6 +23,7 @@ const props = defineProps({
|
||||||
client: Object,
|
client: Object,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Core reactive state (restored)
|
||||||
const importId = ref(props.import?.id || null);
|
const importId = ref(props.import?.id || null);
|
||||||
const hasHeader = ref(Boolean(props.import?.meta?.has_header ?? true));
|
const hasHeader = ref(Boolean(props.import?.meta?.has_header ?? true));
|
||||||
const detected = ref({
|
const detected = ref({
|
||||||
|
|
@ -26,15 +39,16 @@ const mappingSaved = ref(false);
|
||||||
const mappingSavedCount = ref(0);
|
const mappingSavedCount = ref(0);
|
||||||
const mappingError = ref("");
|
const mappingError = ref("");
|
||||||
const savingMappings = ref(false);
|
const savingMappings = ref(false);
|
||||||
// Persisted mappings from backend (raw view regardless of detected columns)
|
const persistedMappings = ref([]); // raw persisted
|
||||||
const persistedMappings = ref([]);
|
let suppressMappingWatch = false; // guard to avoid resetting saved flag on programmatic updates
|
||||||
|
const persistedSignature = ref(""); // signature of last persisted mapping set
|
||||||
|
// (Reverted) We no longer fetch template-specific source columns; coverage uses detected columns
|
||||||
const detectedNote = ref("");
|
const detectedNote = ref("");
|
||||||
// Delimiter selection (auto by default, can be overridden by template or user)
|
|
||||||
const delimiterState = ref({ mode: "auto", custom: "" });
|
const delimiterState = ref({ mode: "auto", custom: "" });
|
||||||
const effectiveDelimiter = computed(() => {
|
const effectiveDelimiter = computed(() => {
|
||||||
switch (delimiterState.value.mode) {
|
switch (delimiterState.value.mode) {
|
||||||
case "auto":
|
case "auto":
|
||||||
return null; // let backend detect
|
return null;
|
||||||
case "comma":
|
case "comma":
|
||||||
return ",";
|
return ",";
|
||||||
case "semicolon":
|
case "semicolon":
|
||||||
|
|
@ -51,7 +65,6 @@ const effectiveDelimiter = computed(() => {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// Initialize delimiter from import meta if previously chosen
|
|
||||||
const initForced = props.import?.meta?.forced_delimiter || null;
|
const initForced = props.import?.meta?.forced_delimiter || null;
|
||||||
if (initForced) {
|
if (initForced) {
|
||||||
const map = { ",": "comma", ";": "semicolon", "\t": "tab", "|": "pipe", " ": "space" };
|
const map = { ",": "comma", ";": "semicolon", "\t": "tab", "|": "pipe", " ": "space" };
|
||||||
|
|
@ -59,15 +72,82 @@ if (initForced) {
|
||||||
delimiterState.value.mode = mode;
|
delimiterState.value.mode = mode;
|
||||||
if (mode === "custom") delimiterState.value.custom = initForced;
|
if (mode === "custom") delimiterState.value.custom = initForced;
|
||||||
}
|
}
|
||||||
// Logs
|
|
||||||
const events = ref([]);
|
const events = ref([]);
|
||||||
const eventsLimit = ref(200);
|
const eventsLimit = ref(200);
|
||||||
const loadingEvents = ref(false);
|
const loadingEvents = ref(false);
|
||||||
|
const showPreview = ref(false);
|
||||||
|
const previewLoading = ref(false);
|
||||||
|
const previewRows = ref([]);
|
||||||
|
const previewColumns = ref([]);
|
||||||
|
const previewTruncated = ref(false);
|
||||||
|
const previewLimit = ref(200);
|
||||||
|
|
||||||
// Completed status helper
|
// Determine if all detected columns are mapped with entity+field
|
||||||
|
function evaluateMappingSaved() {
|
||||||
|
console.log("here the evaluation happen of mapping save!");
|
||||||
|
const hasTemplate =
|
||||||
|
!!props.import?.import_template_id || !!form.value.import_template_id;
|
||||||
|
if (!hasTemplate) return;
|
||||||
|
// We only require coverage of template-defined source columns when a template is present.
|
||||||
|
// Template source columns are derived from persistedMappings (these reflect the template's mapping set)
|
||||||
|
// NOT every detected column (there may be extra columns in the uploaded file that the template intentionally ignores).
|
||||||
|
const detectedColsNorm = Array.isArray(detected.value.columns)
|
||||||
|
? detected.value.columns.map((c) => normalizeSource(c)).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Determine required source columns:
|
||||||
|
// - If we have any persisted mappings (template applied or saved), use their source columns as the required set.
|
||||||
|
// - Otherwise (edge case: template id present but no persisted mappings yet), fall back to detected columns.
|
||||||
|
const templateSourceCols = Array.from(
|
||||||
|
new Set(
|
||||||
|
persistedMappings.value.map((m) => normalizeSource(m.source_column)).filter(Boolean)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const requiredSources = templateSourceCols.length
|
||||||
|
? templateSourceCols
|
||||||
|
: detectedColsNorm;
|
||||||
|
if (!requiredSources.length) return;
|
||||||
|
|
||||||
|
// A source column is considered covered if there exists a persisted mapping for it.
|
||||||
|
const mappedSources = new Set(
|
||||||
|
persistedMappings.value.map((m) => normalizeSource(m.source_column)).filter(Boolean)
|
||||||
|
);
|
||||||
|
if (!requiredSources.every((c) => mappedSources.has(c))) return; // incomplete coverage
|
||||||
|
|
||||||
|
// Now ensure that every required source column has an entity+field selected (unless the row is explicitly skipped).
|
||||||
|
const allHaveTargets = mappingRows.value.every((r) => {
|
||||||
|
const src = normalizeSource(r.source_column || "");
|
||||||
|
if (!src || !requiredSources.includes(src)) return true; // ignore non-required / extra columns
|
||||||
|
if (r.skip) return true; // skipped rows do not block completion
|
||||||
|
return !!(r.entity && r.field);
|
||||||
|
});
|
||||||
|
if (!allHaveTargets) return;
|
||||||
|
|
||||||
|
mappingSaved.value = true;
|
||||||
|
mappingSavedCount.value = mappingRows.value.filter(
|
||||||
|
(r) => r.entity && r.field && !r.skip
|
||||||
|
).length;
|
||||||
|
persistedSignature.value = computeMappingSignature(mappingRows.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeMappingSignature(rows) {
|
||||||
|
return rows
|
||||||
|
.filter((r) => r && r.source_column)
|
||||||
|
.map((r) => {
|
||||||
|
const src = normalizeSource(r.source_column || "");
|
||||||
|
const tgt = r.entity && r.field ? `${entityKeyToRecord(r.entity)}.${r.field}` : "";
|
||||||
|
return `${src}=>${tgt}`;
|
||||||
|
})
|
||||||
|
.sort()
|
||||||
|
.join("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entity definitions & state (restored)
|
||||||
|
const entityDefs = ref([]); // [{ key, label, canonical_root, fields: [] }]
|
||||||
|
const usingEntityFallback = ref(false);
|
||||||
|
|
||||||
|
// Completion & gating
|
||||||
const isCompleted = computed(() => (props.import?.status || "") === "completed");
|
const isCompleted = computed(() => (props.import?.status || "") === "completed");
|
||||||
|
|
||||||
// Whether backend has any saved mappings for this import
|
|
||||||
const hasPersistedMappings = computed(() => (persistedMappings.value?.length || 0) > 0);
|
const hasPersistedMappings = computed(() => (persistedMappings.value?.length || 0) > 0);
|
||||||
const canProcess = computed(
|
const canProcess = computed(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -77,9 +157,37 @@ const canProcess = computed(
|
||||||
!isCompleted.value
|
!isCompleted.value
|
||||||
);
|
);
|
||||||
|
|
||||||
// Dynamic entity definitions and options fetched from API
|
// Preview helpers
|
||||||
const entityDefs = ref([]); // [{ key, label, canonical_root, fields: [] }]
|
async function openPreview() {
|
||||||
const usingEntityFallback = ref(false);
|
if (!importId.value) return;
|
||||||
|
showPreview.value = true;
|
||||||
|
await fetchPreview();
|
||||||
|
}
|
||||||
|
async function fetchPreview() {
|
||||||
|
if (!importId.value) return;
|
||||||
|
previewLoading.value = true;
|
||||||
|
try {
|
||||||
|
const { data } = await axios.get(
|
||||||
|
route("imports.preview", { import: importId.value }),
|
||||||
|
{
|
||||||
|
params: { limit: previewLimit.value },
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
withCredentials: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
previewColumns.value = Array.isArray(data?.columns) ? data.columns : [];
|
||||||
|
previewRows.value = Array.isArray(data?.rows) ? data.rows : [];
|
||||||
|
previewTruncated.value = !!data?.truncated;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
"Preview fetch failed",
|
||||||
|
e.response?.status || "",
|
||||||
|
e.response?.data || e
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
previewLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
const entityOptions = computed(() =>
|
const entityOptions = computed(() =>
|
||||||
entityDefs.value.map((e) => ({ value: e.key, label: e.label || e.key }))
|
entityDefs.value.map((e) => ({ value: e.key, label: e.label || e.key }))
|
||||||
);
|
);
|
||||||
|
|
@ -201,6 +309,7 @@ async function loadEntityDefs() {
|
||||||
}
|
}
|
||||||
// Normalize any existing mapping row entity values to UI keys if they are canonical roots
|
// Normalize any existing mapping row entity values to UI keys if they are canonical roots
|
||||||
if (Array.isArray(mappingRows.value) && mappingRows.value.length) {
|
if (Array.isArray(mappingRows.value) && mappingRows.value.length) {
|
||||||
|
suppressMappingWatch = true;
|
||||||
const mapCanonToKey = keyByCanonicalRoot.value;
|
const mapCanonToKey = keyByCanonicalRoot.value;
|
||||||
mappingRows.value = mappingRows.value.map((r) => {
|
mappingRows.value = mappingRows.value.map((r) => {
|
||||||
const current = r.entity;
|
const current = r.entity;
|
||||||
|
|
@ -212,6 +321,7 @@ async function loadEntityDefs() {
|
||||||
}
|
}
|
||||||
return r;
|
return r;
|
||||||
});
|
});
|
||||||
|
suppressMappingWatch = false;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to load import entity definitions", e);
|
console.error("Failed to load import entity definitions", e);
|
||||||
|
|
@ -219,6 +329,7 @@ async function loadEntityDefs() {
|
||||||
entityDefs.value = defaultEntityDefs();
|
entityDefs.value = defaultEntityDefs();
|
||||||
// Also normalize with fallback
|
// Also normalize with fallback
|
||||||
if (Array.isArray(mappingRows.value) && mappingRows.value.length) {
|
if (Array.isArray(mappingRows.value) && mappingRows.value.length) {
|
||||||
|
suppressMappingWatch = true;
|
||||||
const mapCanonToKey = keyByCanonicalRoot.value;
|
const mapCanonToKey = keyByCanonicalRoot.value;
|
||||||
mappingRows.value = mappingRows.value.map((r) => {
|
mappingRows.value = mappingRows.value.map((r) => {
|
||||||
const current = r.entity;
|
const current = r.entity;
|
||||||
|
|
@ -230,6 +341,7 @@ async function loadEntityDefs() {
|
||||||
}
|
}
|
||||||
return r;
|
return r;
|
||||||
});
|
});
|
||||||
|
suppressMappingWatch = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -332,6 +444,95 @@ const selectedMappingsCount = computed(
|
||||||
() => mappingRows.value.filter((r) => !r.skip && r.entity && r.field).length
|
() => mappingRows.value.filter((r) => !r.skip && r.entity && r.field).length
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// --- UI Enhancements: Status badge, inline validation, checklist ---
|
||||||
|
const statusInfo = computed(() => {
|
||||||
|
const raw = (props.import?.status || "").toLowerCase();
|
||||||
|
const map = {
|
||||||
|
completed: {
|
||||||
|
label: "Zaključeno",
|
||||||
|
classes: "bg-emerald-100 text-emerald-700 border border-emerald-300",
|
||||||
|
},
|
||||||
|
processing: {
|
||||||
|
label: "Obdelava",
|
||||||
|
classes: "bg-indigo-100 text-indigo-700 border border-indigo-300",
|
||||||
|
},
|
||||||
|
validating: {
|
||||||
|
label: "Preverjanje",
|
||||||
|
classes: "bg-indigo-100 text-indigo-700 border border-indigo-300",
|
||||||
|
},
|
||||||
|
failed: {
|
||||||
|
label: "Neuspešno",
|
||||||
|
classes: "bg-red-100 text-red-700 border border-red-300",
|
||||||
|
},
|
||||||
|
parsed: {
|
||||||
|
label: "Razčlenjeno",
|
||||||
|
classes: "bg-slate-100 text-slate-700 border border-slate-300",
|
||||||
|
},
|
||||||
|
uploaded: {
|
||||||
|
label: "Naloženo",
|
||||||
|
classes: "bg-slate-100 text-slate-700 border border-slate-300",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
map[raw] || {
|
||||||
|
label: raw || "Status",
|
||||||
|
classes: "bg-gray-100 text-gray-700 border border-gray-300",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Duplicate target (entity+field) detection for inline validation
|
||||||
|
const duplicateTargets = computed(() => {
|
||||||
|
const counts = new Map();
|
||||||
|
for (const r of mappingRows.value) {
|
||||||
|
if (!r.skip && r.entity && r.field) {
|
||||||
|
const key = entityKeyToRecord(r.entity) + "." + r.field;
|
||||||
|
counts.set(key, (counts.get(key) || 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const dups = new Set();
|
||||||
|
counts.forEach((v, k) => {
|
||||||
|
if (v > 1) dups.add(k);
|
||||||
|
});
|
||||||
|
return dups;
|
||||||
|
});
|
||||||
|
function duplicateTarget(row) {
|
||||||
|
if (!row || !row.entity || !row.field) return false;
|
||||||
|
const key = entityKeyToRecord(row.entity) + "." + row.field;
|
||||||
|
return duplicateTargets.value.has(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Critical fields heuristic (extend as needed)
|
||||||
|
const criticalFields = computed(() => {
|
||||||
|
const base = ["contract.reference"];
|
||||||
|
const paymentsImport = !!selectedTemplateOption.value?.meta?.payments_import;
|
||||||
|
if (paymentsImport) {
|
||||||
|
base.push("payment.amount", "payment.payment_date");
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
});
|
||||||
|
const providedTargets = computed(() => {
|
||||||
|
const set = new Set();
|
||||||
|
for (const r of mappingRows.value) {
|
||||||
|
if (!r.skip && r.entity && r.field) {
|
||||||
|
set.add(entityKeyToRecord(r.entity) + "." + r.field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
});
|
||||||
|
const missingCritical = computed(() =>
|
||||||
|
criticalFields.value.filter((f) => !providedTargets.value.has(f))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Checklist steps
|
||||||
|
const stepStates = computed(() => [
|
||||||
|
{ label: "1) Izberi predlogo", done: !!form.value.import_template_id },
|
||||||
|
{ label: "2) Preglej stolpce", done: (detected.value.columns || []).length > 0 },
|
||||||
|
{ label: "3) Preslikaj", done: selectedMappingsCount.value > 0 },
|
||||||
|
{ label: "4) Shrani", done: mappingSaved.value },
|
||||||
|
{ label: "5) Obdelaj", done: isCompleted.value || !!processResult.value },
|
||||||
|
]);
|
||||||
|
|
||||||
async function fetchColumns() {
|
async function fetchColumns() {
|
||||||
if (!importId.value) return;
|
if (!importId.value) return;
|
||||||
const url = route("imports.columns", { import: importId.value });
|
const url = route("imports.columns", { import: importId.value });
|
||||||
|
|
@ -361,6 +562,7 @@ async function fetchColumns() {
|
||||||
detectedNote.value = data.note || "";
|
detectedNote.value = data.note || "";
|
||||||
// initialize mapping rows if empty
|
// initialize mapping rows if empty
|
||||||
if (!mappingRows.value.length && detected.value.columns.length) {
|
if (!mappingRows.value.length && detected.value.columns.length) {
|
||||||
|
suppressMappingWatch = true;
|
||||||
mappingRows.value = detected.value.columns.map((c, idx) => ({
|
mappingRows.value = detected.value.columns.map((c, idx) => ({
|
||||||
source_column: c,
|
source_column: c,
|
||||||
entity: "",
|
entity: "",
|
||||||
|
|
@ -370,6 +572,8 @@ async function fetchColumns() {
|
||||||
apply_mode: "both",
|
apply_mode: "both",
|
||||||
position: idx,
|
position: idx,
|
||||||
}));
|
}));
|
||||||
|
suppressMappingWatch = false;
|
||||||
|
evaluateMappingSaved();
|
||||||
}
|
}
|
||||||
await loadImportMappings();
|
await loadImportMappings();
|
||||||
// Fallback: if no detected columns were found, but persisted mappings exist, use them to render the grid
|
// Fallback: if no detected columns were found, but persisted mappings exist, use them to render the grid
|
||||||
|
|
@ -399,7 +603,7 @@ async function applyTemplateToImport() {
|
||||||
try {
|
try {
|
||||||
if (templateApplied.value) {
|
if (templateApplied.value) {
|
||||||
const ok = window.confirm(
|
const ok = window.confirm(
|
||||||
'Re-apply this template? This will overwrite current mappings for this import.'
|
"Re-apply this template? This will overwrite current mappings for this import."
|
||||||
);
|
);
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -462,6 +666,7 @@ async function loadImportMappings() {
|
||||||
persistedMappings.value = rows.slice();
|
persistedMappings.value = rows.slice();
|
||||||
if (!rows.length) return;
|
if (!rows.length) return;
|
||||||
const bySource = new Map(rows.map((r) => [normalizeSource(r.source_column), r]));
|
const bySource = new Map(rows.map((r) => [normalizeSource(r.source_column), r]));
|
||||||
|
suppressMappingWatch = true;
|
||||||
mappingRows.value = (mappingRows.value || []).map((r, idx) => {
|
mappingRows.value = (mappingRows.value || []).map((r, idx) => {
|
||||||
const m = bySource.get(normalizeSource(r.source_column));
|
const m = bySource.get(normalizeSource(r.source_column));
|
||||||
if (!m) return r;
|
if (!m) return r;
|
||||||
|
|
@ -484,12 +689,41 @@ async function loadImportMappings() {
|
||||||
position: idx,
|
position: idx,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Auto-evaluate mappingSaved when a template is already bound to the import.
|
||||||
|
// Previous logic required ALL detected columns. Updated: if a template is bound, only require template (persisted) source columns.
|
||||||
|
if (props.import?.import_template_id) {
|
||||||
|
const templateSources = Array.from(
|
||||||
|
new Set(
|
||||||
|
persistedMappings.value
|
||||||
|
.map((m) => normalizeSource(m.source_column))
|
||||||
|
.filter(Boolean)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (templateSources.length) {
|
||||||
|
const allHaveTargets = mappingRows.value.every((r) => {
|
||||||
|
const src = normalizeSource(r.source_column || "");
|
||||||
|
if (!src || !templateSources.includes(src)) return true; // ignore extras
|
||||||
|
if (r.skip) return true;
|
||||||
|
return !!(r.entity && r.field);
|
||||||
|
});
|
||||||
|
if (allHaveTargets) {
|
||||||
|
mappingSaved.value = true;
|
||||||
|
mappingSavedCount.value = mappingRows.value.filter(
|
||||||
|
(r) => r.entity && r.field && !r.skip
|
||||||
|
).length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
suppressMappingWatch = false;
|
||||||
|
evaluateMappingSaved();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(
|
console.error(
|
||||||
"Load import mappings error",
|
"Load import mappings error",
|
||||||
e.response?.status || "",
|
e.response?.status || "",
|
||||||
e.response?.data || e
|
e.response?.data || e
|
||||||
);
|
);
|
||||||
|
suppressMappingWatch = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -602,18 +836,28 @@ onMounted(async () => {
|
||||||
await applyTemplateToImport();
|
await applyTemplateToImport();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Auto apply template failed', e);
|
console.warn("Auto apply template failed", e);
|
||||||
}
|
}
|
||||||
// Load recent events (logs)
|
// Load recent events (logs)
|
||||||
await fetchEvents();
|
await fetchEvents();
|
||||||
|
// If template already bound when opening page, load template mapping columns
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reset saved flag whenever user edits mappings
|
// Detect user changes (vs programmatic) using signature diff
|
||||||
watch(
|
watch(
|
||||||
mappingRows,
|
mappingRows,
|
||||||
() => {
|
() => {
|
||||||
|
if (suppressMappingWatch) return;
|
||||||
|
const currentSig = computeMappingSignature(mappingRows.value);
|
||||||
|
if (persistedSignature.value && currentSig === persistedSignature.value) {
|
||||||
|
// No semantic change compared to persisted state
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Real change -> unsaved
|
||||||
mappingSaved.value = false;
|
mappingSaved.value = false;
|
||||||
mappingSavedCount.value = 0;
|
mappingSavedCount.value = mappingRows.value.filter(
|
||||||
|
(r) => r.entity && r.field && !r.skip
|
||||||
|
).length;
|
||||||
mappingError.value = "";
|
mappingError.value = "";
|
||||||
},
|
},
|
||||||
{ deep: true }
|
{ deep: true }
|
||||||
|
|
@ -624,6 +868,7 @@ watch(
|
||||||
() => detected.value.columns,
|
() => detected.value.columns,
|
||||||
(cols) => {
|
(cols) => {
|
||||||
if (Array.isArray(cols) && cols.length > 0 && mappingRows.value.length === 0) {
|
if (Array.isArray(cols) && cols.length > 0 && mappingRows.value.length === 0) {
|
||||||
|
suppressMappingWatch = true;
|
||||||
mappingRows.value = cols.map((c, idx) => {
|
mappingRows.value = cols.map((c, idx) => {
|
||||||
return {
|
return {
|
||||||
source_column: c,
|
source_column: c,
|
||||||
|
|
@ -635,6 +880,8 @@ watch(
|
||||||
position: idx,
|
position: idx,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
suppressMappingWatch = false;
|
||||||
|
evaluateMappingSaved();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
@ -673,21 +920,83 @@ async function fetchEvents() {
|
||||||
loadingEvents.value = false;
|
loadingEvents.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Simulation (generic or payments) state
|
||||||
|
const showPaymentSim = ref(false);
|
||||||
|
const paymentSimLoading = ref(false);
|
||||||
|
const paymentSimLimit = ref(100);
|
||||||
|
const paymentSimRows = ref([]);
|
||||||
|
// summary (raw machine) + localized (povzetki.payment or others)
|
||||||
|
const paymentSimSummary = ref(null); // machine summary (if needed)
|
||||||
|
const paymentSimSummarySl = ref(null); // localized Slovenian summary
|
||||||
|
const paymentSimEntities = ref([]);
|
||||||
|
const paymentSimVerbose = ref(false); // "Podrobni pogled" toggle
|
||||||
|
const paymentsImport = computed(
|
||||||
|
() => !!selectedTemplateOption.value?.meta?.payments_import
|
||||||
|
);
|
||||||
|
|
||||||
|
// Currency formatter with fallback (client currency -> EUR)
|
||||||
|
const clientCurrency = props.client?.currency || "EUR";
|
||||||
|
const { formatMoney } = useCurrencyFormat({
|
||||||
|
primary: clientCurrency,
|
||||||
|
fallbacks: ["EUR"],
|
||||||
|
});
|
||||||
|
|
||||||
|
async function openSimulation() {
|
||||||
|
if (!importId.value) return;
|
||||||
|
showPaymentSim.value = true;
|
||||||
|
await fetchSimulation();
|
||||||
|
}
|
||||||
|
async function fetchSimulation() {
|
||||||
|
if (!importId.value) return;
|
||||||
|
paymentSimLoading.value = true;
|
||||||
|
try {
|
||||||
|
const routeName = paymentsImport.value
|
||||||
|
? "imports.simulatePayments" // legacy payments specific name
|
||||||
|
: "imports.simulate"; // new generic simulation
|
||||||
|
const { data } = await axios.get(route(routeName, { import: importId.value }), {
|
||||||
|
params: {
|
||||||
|
limit: paymentSimLimit.value,
|
||||||
|
verbose: paymentSimVerbose.value ? 1 : 0,
|
||||||
|
},
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
withCredentials: true,
|
||||||
|
});
|
||||||
|
paymentSimRows.value = Array.isArray(data?.rows) ? data.rows : [];
|
||||||
|
paymentSimEntities.value = Array.isArray(data?.entities) ? data.entities : [];
|
||||||
|
// Summaries keys vary (payment, contract, account, etc.). Keep existing behaviour for payment summary exposure.
|
||||||
|
paymentSimSummary.value = data?.summaries?.payment || null;
|
||||||
|
paymentSimSummarySl.value = data?.povzetki?.payment || null;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Simulation failed", e.response?.status || "", e.response?.data || e);
|
||||||
|
} finally {
|
||||||
|
paymentSimLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :title="`Import ${props.import?.uuid || ''}`">
|
<AppLayout :title="`Import ${props.import?.uuid || ''}`">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-1">
|
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-1">
|
||||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Continue Import</h2>
|
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Nadaljuj uvoz</h2>
|
||||||
<div class="text-sm text-gray-600">
|
<div class="text-sm text-gray-600 flex flex-wrap items-center gap-2">
|
||||||
<span class="mr-4">Client:
|
<span class="mr-2"
|
||||||
<strong>{{ selectedClientOption?.name || selectedClientOption?.uuid || "—" }}</strong>
|
>Stranka:
|
||||||
|
<strong>{{
|
||||||
|
selectedClientOption?.name || selectedClientOption?.uuid || "—"
|
||||||
|
}}</strong>
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="templateApplied"
|
v-if="templateApplied"
|
||||||
class="ml-1 text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600 align-middle"
|
class="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600 align-middle"
|
||||||
>applied</span>
|
>uporabljena</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="props.import?.status"
|
||||||
|
:class="['px-2 py-0.5 rounded-full text-xs font-medium', statusInfo.classes]"
|
||||||
|
>{{ statusInfo.label }}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -726,145 +1035,51 @@ async function fetchEvents() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<div>
|
<TemplateControls
|
||||||
<label class="block text-sm font-medium text-gray-700">Client</label>
|
:is-completed="isCompleted"
|
||||||
<Multiselect
|
:has-header="hasHeader"
|
||||||
v-model="selectedClientOption"
|
:delimiter-state="delimiterState"
|
||||||
:options="clients"
|
:selected-template-option="selectedTemplateOption"
|
||||||
track-by="uuid"
|
:filtered-templates="filteredTemplates"
|
||||||
label="name"
|
:template-applied="templateApplied"
|
||||||
placeholder="Search clients..."
|
:form="form"
|
||||||
:searchable="true"
|
@preview="openPreview"
|
||||||
:allow-empty="true"
|
@update:hasHeader="
|
||||||
class="mt-1"
|
(val) => {
|
||||||
disabled
|
hasHeader = val;
|
||||||
|
fetchColumns();
|
||||||
|
}
|
||||||
|
"
|
||||||
|
@update:delimiterMode="
|
||||||
|
(val) => {
|
||||||
|
delimiterState.mode = val;
|
||||||
|
fetchColumns();
|
||||||
|
}
|
||||||
|
"
|
||||||
|
@update:delimiterCustom="
|
||||||
|
(val) => {
|
||||||
|
delimiterState.custom = val;
|
||||||
|
fetchColumns();
|
||||||
|
}
|
||||||
|
"
|
||||||
|
@apply-template="applyTemplateToImport"
|
||||||
/>
|
/>
|
||||||
<p class="text-xs text-gray-500 mt-1">Client is set during upload.</p>
|
<ChecklistSteps :steps="stepStates" :missing-critical="missingCritical" />
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700">Template</label>
|
|
||||||
<Multiselect
|
|
||||||
v-model="selectedTemplateOption"
|
|
||||||
:options="filteredTemplates"
|
|
||||||
track-by="id"
|
|
||||||
label="name"
|
|
||||||
placeholder="Search templates..."
|
|
||||||
:searchable="true"
|
|
||||||
:allow-empty="true"
|
|
||||||
class="mt-1"
|
|
||||||
:disabled="false"
|
|
||||||
>
|
|
||||||
<template #option="{ option }">
|
|
||||||
<div class="flex items-center justify-between w-full">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span>{{ option.name }}</span>
|
|
||||||
<span class="ml-2 text-xs text-gray-500"
|
|
||||||
>({{ option.source_type }})</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
class="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600"
|
|
||||||
>{{ option.client_id ? "Client" : "Global" }}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #singleLabel="{ option }">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span>{{ option.name }}</span>
|
|
||||||
<span class="ml-1 text-xs text-gray-500"
|
|
||||||
>({{ option.source_type }})</span
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600"
|
|
||||||
>{{ option.client_id ? "Client" : "Global" }}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</Multiselect>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Parsing options -->
|
<ActionsBar
|
||||||
<div
|
:import-id="importId"
|
||||||
class="grid grid-cols-1 sm:grid-cols-3 gap-4 items-end"
|
:is-completed="isCompleted"
|
||||||
v-if="!isCompleted"
|
:processing="processing"
|
||||||
>
|
:saving-mappings="savingMappings"
|
||||||
<div>
|
:can-process="canProcess"
|
||||||
<label class="block text-sm font-medium text-gray-700">Header row</label>
|
:selected-mappings-count="selectedMappingsCount"
|
||||||
<select
|
@preview="openPreview"
|
||||||
v-model="hasHeader"
|
@save-mappings="saveMappings"
|
||||||
class="mt-1 block w-full border rounded p-2"
|
@process-import="processImport"
|
||||||
@change="fetchColumns"
|
@simulate="openSimulation"
|
||||||
>
|
|
||||||
<option :value="true">Has header</option>
|
|
||||||
<option :value="false">No header (positional)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700">Delimiter</label>
|
|
||||||
<select
|
|
||||||
v-model="delimiterState.mode"
|
|
||||||
class="mt-1 block w-full border rounded p-2"
|
|
||||||
>
|
|
||||||
<option value="auto">Auto-detect</option>
|
|
||||||
<option value="comma">Comma ,</option>
|
|
||||||
<option value="semicolon">Semicolon ;</option>
|
|
||||||
<option value="tab">Tab \t</option>
|
|
||||||
<option value="pipe">Pipe |</option>
|
|
||||||
<option value="space">Space ␠</option>
|
|
||||||
<option value="custom">Custom…</option>
|
|
||||||
</select>
|
|
||||||
<p class="text-xs text-gray-500 mt-1">
|
|
||||||
Template default: {{ selectedTemplateOption?.meta?.delimiter || "auto" }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div v-if="delimiterState.mode === 'custom'">
|
|
||||||
<label class="block text-sm font-medium text-gray-700"
|
|
||||||
>Custom delimiter</label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
v-model="delimiterState.custom"
|
|
||||||
maxlength="4"
|
|
||||||
placeholder=","
|
|
||||||
class="mt-1 block w-full border rounded p-2"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-3" v-if="!isCompleted">
|
|
||||||
<button
|
|
||||||
@click.prevent="applyTemplateToImport"
|
|
||||||
:disabled="!importId || !form.import_template_id"
|
|
||||||
class="px-4 py-2 bg-emerald-600 disabled:bg-gray-300 text-white rounded"
|
|
||||||
>
|
|
||||||
{{ templateApplied ? 'Re-apply Template' : 'Apply Template' }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click.prevent="saveMappings"
|
|
||||||
:disabled="!importId || processing || savingMappings || isCompleted"
|
|
||||||
class="px-4 py-2 bg-orange-600 disabled:bg-gray-300 text-white rounded flex items-center gap-2"
|
|
||||||
title="Save ad-hoc mappings for this import"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
v-if="savingMappings"
|
|
||||||
class="inline-block h-4 w-4 border-2 border-white/70 border-t-transparent rounded-full animate-spin"
|
|
||||||
></span>
|
|
||||||
<span>Save Mappings</span>
|
|
||||||
<span
|
|
||||||
v-if="selectedMappingsCount"
|
|
||||||
class="ml-1 text-xs bg-white/20 px-1.5 py-0.5 rounded"
|
|
||||||
>{{ selectedMappingsCount }}</span
|
|
||||||
>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click.prevent="processImport"
|
|
||||||
:disabled="!canProcess"
|
|
||||||
class="px-4 py-2 bg-purple-600 disabled:bg-gray-300 text-white rounded"
|
|
||||||
>
|
|
||||||
{{ processing ? "Processing…" : "Process Import" }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="mt-2 text-xs text-gray-600" v-if="!importId">Import not found.</div>
|
<div class="mt-2 text-xs text-gray-600" v-if="!importId">Import not found.</div>
|
||||||
<div
|
<div
|
||||||
class="mt-2 text-xs text-gray-600"
|
class="mt-2 text-xs text-gray-600"
|
||||||
|
|
@ -874,129 +1089,22 @@ async function fetchEvents() {
|
||||||
click Save Mappings to enable processing.
|
click Save Mappings to enable processing.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="persistedMappings.length" class="pt-4">
|
<SavedMappingsTable :mappings="persistedMappings" />
|
||||||
<h3 class="font-semibold mb-2">Current Saved Mappings</h3>
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="min-w-full border bg-white text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr class="bg-gray-50 text-left text-xs uppercase text-gray-600">
|
|
||||||
<th class="p-2 border">Source column</th>
|
|
||||||
<th class="p-2 border">Target field</th>
|
|
||||||
<th class="p-2 border">Transform</th>
|
|
||||||
<th class="p-2 border">Mode</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="m in persistedMappings" :key="m.id" class="border-t">
|
|
||||||
<td class="p-2 border">{{ m.source_column }}</td>
|
|
||||||
<td class="p-2 border">{{ m.target_field }}</td>
|
|
||||||
<td class="p-2 border">{{ m.transform || "—" }}</td>
|
|
||||||
<td class="p-2 border">{{ m.apply_mode || "both" }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="!isCompleted && displayRows.length" class="pt-4">
|
<MappingTable
|
||||||
<h3 class="font-semibold mb-2">
|
v-if="!isCompleted && displayRows.length"
|
||||||
<template v-if="!isCompleted"
|
:rows="displayRows"
|
||||||
>Detected Columns ({{ detected.has_header ? "header" : "positional" }})
|
:entity-options="entityOptions"
|
||||||
<span class="ml-2 text-xs text-gray-500"
|
:is-completed="isCompleted"
|
||||||
>detected: {{ detected.columns.length }}, rows:
|
:detected="detected"
|
||||||
{{ displayRows.length }}, delimiter:
|
:detected-note="detectedNote"
|
||||||
{{ detected.delimiter || "auto" }}</span
|
:duplicate-targets="duplicateTargets"
|
||||||
>
|
:missing-critical="missingCritical"
|
||||||
</template>
|
:mapping-saved="mappingSaved"
|
||||||
<template v-else>Detected Columns</template>
|
:mapping-saved-count="mappingSavedCount"
|
||||||
</h3>
|
:mapping-error="mappingError"
|
||||||
<p v-if="detectedNote" class="text-xs text-gray-500 mb-2">
|
:fields-for-entity="fieldsForEntity"
|
||||||
{{ detectedNote }}
|
/>
|
||||||
</p>
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="min-w-full border bg-white">
|
|
||||||
<thead>
|
|
||||||
<tr class="bg-gray-50 text-left text-xs uppercase text-gray-600">
|
|
||||||
<th class="p-2 border">Source column</th>
|
|
||||||
<th class="p-2 border">Entity</th>
|
|
||||||
<th class="p-2 border">Field</th>
|
|
||||||
<th class="p-2 border">Transform</th>
|
|
||||||
<th class="p-2 border">Apply mode</th>
|
|
||||||
<th class="p-2 border">Skip</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="(row, idx) in displayRows" :key="idx" class="border-t">
|
|
||||||
<td class="p-2 border text-sm">{{ row.source_column }}</td>
|
|
||||||
<td class="p-2 border">
|
|
||||||
<select
|
|
||||||
v-model="row.entity"
|
|
||||||
class="border rounded p-1 w-full"
|
|
||||||
:disabled="isCompleted"
|
|
||||||
>
|
|
||||||
<option value="">—</option>
|
|
||||||
<option
|
|
||||||
v-for="opt in entityOptions"
|
|
||||||
:key="opt.value"
|
|
||||||
:value="opt.value"
|
|
||||||
>
|
|
||||||
{{ opt.label }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td class="p-2 border">
|
|
||||||
<select
|
|
||||||
v-model="row.field"
|
|
||||||
class="border rounded p-1 w-full"
|
|
||||||
:disabled="isCompleted"
|
|
||||||
>
|
|
||||||
<option value="">—</option>
|
|
||||||
<option
|
|
||||||
v-for="f in fieldsForEntity(row.entity)"
|
|
||||||
:key="f"
|
|
||||||
:value="f"
|
|
||||||
>
|
|
||||||
{{ f }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td class="p-2 border">
|
|
||||||
<select
|
|
||||||
v-model="row.transform"
|
|
||||||
class="border rounded p-1 w-full"
|
|
||||||
:disabled="isCompleted"
|
|
||||||
>
|
|
||||||
<option value="">None</option>
|
|
||||||
<option value="trim">Trim</option>
|
|
||||||
<option value="upper">Uppercase</option>
|
|
||||||
<option value="lower">Lowercase</option>
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td class="p-2 border">
|
|
||||||
<select
|
|
||||||
v-model="row.apply_mode"
|
|
||||||
class="border rounded p-1 w-full"
|
|
||||||
:disabled="isCompleted"
|
|
||||||
>
|
|
||||||
<option value="both">Both</option>
|
|
||||||
<option value="insert">Insert only</option>
|
|
||||||
<option value="update">Update only</option>
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td class="p-2 border text-center">
|
|
||||||
<input type="checkbox" v-model="row.skip" :disabled="isCompleted" />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div v-if="mappingSaved" class="text-sm text-emerald-700 mt-2">
|
|
||||||
Mappings saved ({{ mappingSavedCount }}).
|
|
||||||
</div>
|
|
||||||
<div v-else-if="mappingError" class="text-sm text-red-600 mt-2">
|
|
||||||
{{ mappingError }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="!isCompleted" class="pt-4">
|
<div v-else-if="!isCompleted" class="pt-4">
|
||||||
<h3 class="font-semibold mb-2">Detected Columns</h3>
|
<h3 class="font-semibold mb-2">Detected Columns</h3>
|
||||||
|
|
@ -1009,88 +1117,55 @@ async function fetchEvents() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="processResult" class="pt-4">
|
<ProcessResult :result="processResult" />
|
||||||
<h3 class="font-semibold mb-2">Import Result</h3>
|
|
||||||
<pre class="bg-gray-50 border rounded p-3 text-sm overflow-x-auto">{{
|
|
||||||
processResult
|
|
||||||
}}</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="pt-4">
|
<LogsTable
|
||||||
<div class="flex items-center justify-between mb-2">
|
:events="events"
|
||||||
<h3 class="font-semibold">Logs</h3>
|
:loading="loadingEvents"
|
||||||
<div class="flex items-center gap-2 text-sm">
|
:limit="eventsLimit"
|
||||||
<label class="text-gray-600">Show</label>
|
@update:limit="(val) => (eventsLimit = val)"
|
||||||
<select
|
@refresh="fetchEvents"
|
||||||
v-model.number="eventsLimit"
|
/>
|
||||||
class="border rounded p-1"
|
|
||||||
@change="fetchEvents"
|
|
||||||
>
|
|
||||||
<option :value="50">50</option>
|
|
||||||
<option :value="100">100</option>
|
|
||||||
<option :value="200">200</option>
|
|
||||||
<option :value="500">500</option>
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
@click.prevent="fetchEvents"
|
|
||||||
class="px-2 py-1 border rounded text-sm"
|
|
||||||
:disabled="loadingEvents"
|
|
||||||
>
|
|
||||||
{{ loadingEvents ? "Refreshing…" : "Refresh" }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="min-w-full border bg-white text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr class="bg-gray-50 text-left text-xs uppercase text-gray-600">
|
|
||||||
<th class="p-2 border">Time</th>
|
|
||||||
<th class="p-2 border">Level</th>
|
|
||||||
<th class="p-2 border">Event</th>
|
|
||||||
<th class="p-2 border">Message</th>
|
|
||||||
<th class="p-2 border">Row</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="ev in events" :key="ev.id" class="border-t">
|
|
||||||
<td class="p-2 border whitespace-nowrap">
|
|
||||||
{{ new Date(ev.created_at).toLocaleString() }}
|
|
||||||
</td>
|
|
||||||
<td class="p-2 border">
|
|
||||||
<span
|
|
||||||
:class="[
|
|
||||||
'px-2 py-0.5 rounded text-xs',
|
|
||||||
ev.level === 'error'
|
|
||||||
? 'bg-red-100 text-red-800'
|
|
||||||
: ev.level === 'warning'
|
|
||||||
? 'bg-amber-100 text-amber-800'
|
|
||||||
: 'bg-gray-100 text-gray-700',
|
|
||||||
]"
|
|
||||||
>{{ ev.level }}</span
|
|
||||||
>
|
|
||||||
</td>
|
|
||||||
<td class="p-2 border">{{ ev.event }}</td>
|
|
||||||
<td class="p-2 border">
|
|
||||||
<div>{{ ev.message }}</div>
|
|
||||||
<div v-if="ev.context" class="text-xs text-gray-500">
|
|
||||||
{{ ev.context }}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="p-2 border">{{ ev.import_row_id ?? "—" }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-if="!events.length">
|
|
||||||
<td class="p-3 text-center text-gray-500" colspan="5">
|
|
||||||
No events yet
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
|
<CsvPreviewModal
|
||||||
|
:show="showPreview"
|
||||||
|
:columns="previewColumns"
|
||||||
|
:rows="previewRows"
|
||||||
|
:limit="previewLimit"
|
||||||
|
:loading="previewLoading"
|
||||||
|
:truncated="previewTruncated"
|
||||||
|
:has-header="detected.has_header"
|
||||||
|
@close="showPreview = false"
|
||||||
|
@change-limit="(val) => (previewLimit = val)"
|
||||||
|
@refresh="fetchPreview"
|
||||||
|
/>
|
||||||
|
<SimulationModal
|
||||||
|
:show="showPaymentSim"
|
||||||
|
:rows="paymentSimRows"
|
||||||
|
:limit="paymentSimLimit"
|
||||||
|
:loading="paymentSimLoading"
|
||||||
|
:summary="paymentSimSummary"
|
||||||
|
:summary-sl="paymentSimSummarySl"
|
||||||
|
:verbose="paymentSimVerbose"
|
||||||
|
:entities="paymentSimEntities"
|
||||||
|
:money-formatter="formatMoney"
|
||||||
|
@close="showPaymentSim = false"
|
||||||
|
@change-limit="
|
||||||
|
(val) => {
|
||||||
|
paymentSimLimit = val;
|
||||||
|
}
|
||||||
|
"
|
||||||
|
@toggle-verbose="
|
||||||
|
async () => {
|
||||||
|
paymentSimVerbose = !paymentSimVerbose;
|
||||||
|
await fetchSimulation();
|
||||||
|
}
|
||||||
|
"
|
||||||
|
@refresh="fetchSimulation"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,51 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||||
import { Link } from '@inertiajs/vue3';
|
import { Link, router } from "@inertiajs/vue3";
|
||||||
|
import { ref } from "vue";
|
||||||
|
import ConfirmationModal from "@/Components/ConfirmationModal.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
imports: Object,
|
imports: Object,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const deletingId = ref(null);
|
||||||
|
const confirming = ref(false);
|
||||||
|
const errorMsg = ref(null);
|
||||||
|
|
||||||
|
function canDelete(status) {
|
||||||
|
return !["completed", "processing"].includes(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(imp) {
|
||||||
|
if (!canDelete(imp.status)) return;
|
||||||
|
deletingId.value = imp.id;
|
||||||
|
confirming.value = true;
|
||||||
|
errorMsg.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function performDelete() {
|
||||||
|
if (!deletingId.value) return;
|
||||||
|
router.delete(route("imports.destroy", { import: deletingId.value }), {
|
||||||
|
preserveScroll: true,
|
||||||
|
onFinish: () => {
|
||||||
|
confirming.value = false;
|
||||||
|
deletingId.value = null;
|
||||||
|
},
|
||||||
|
onError: (errs) => {
|
||||||
|
errorMsg.value = errs?.message || "Brisanje ni uspelo.";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function statusBadge(status) {
|
function statusBadge(status) {
|
||||||
const map = {
|
const map = {
|
||||||
uploaded: 'bg-gray-200 text-gray-700',
|
uploaded: "bg-gray-200 text-gray-700",
|
||||||
parsed: 'bg-blue-100 text-blue-800',
|
parsed: "bg-blue-100 text-blue-800",
|
||||||
validating: 'bg-amber-100 text-amber-800',
|
validating: "bg-amber-100 text-amber-800",
|
||||||
completed: 'bg-emerald-100 text-emerald-800',
|
completed: "bg-emerald-100 text-emerald-800",
|
||||||
failed: 'bg-red-100 text-red-800',
|
failed: "bg-red-100 text-red-800",
|
||||||
};
|
};
|
||||||
return map[status] || 'bg-gray-100 text-gray-800';
|
return map[status] || "bg-gray-100 text-gray-800";
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -23,7 +54,11 @@ function statusBadge(status) {
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Uvozi</h2>
|
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Uvozi</h2>
|
||||||
<Link :href="route('imports.create')" class="px-3 py-2 rounded bg-blue-600 text-white text-sm">Novi uvoz</Link>
|
<Link
|
||||||
|
:href="route('imports.create')"
|
||||||
|
class="px-3 py-2 rounded bg-blue-600 text-white text-sm"
|
||||||
|
>Novi uvoz</Link
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -44,14 +79,37 @@ function statusBadge(status) {
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="imp in imports.data" :key="imp.uuid" class="border-b">
|
<tr v-for="imp in imports.data" :key="imp.uuid" class="border-b">
|
||||||
<td class="p-2 whitespace-nowrap">{{ new Date(imp.created_at).toLocaleString() }}</td>
|
<td class="p-2 whitespace-nowrap">
|
||||||
|
{{ new Date(imp.created_at).toLocaleString() }}
|
||||||
|
</td>
|
||||||
<td class="p-2">{{ imp.original_name }}</td>
|
<td class="p-2">{{ imp.original_name }}</td>
|
||||||
<td class="p-2"><span :class="['px-2 py-0.5 rounded text-xs', statusBadge(imp.status)]">{{ imp.status }}</span></td>
|
<td class="p-2">
|
||||||
<td class="p-2">{{ imp.client?.person?.full_name ?? '—' }}</td>
|
<span
|
||||||
<td class="p-2">{{ imp.template?.name ?? '—' }}</td>
|
:class="['px-2 py-0.5 rounded text-xs', statusBadge(imp.status)]"
|
||||||
|
>{{ imp.status }}</span
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
<td class="p-2">{{ imp.client?.person?.full_name ?? "—" }}</td>
|
||||||
|
<td class="p-2">{{ imp.template?.name ?? "—" }}</td>
|
||||||
<td class="p-2 space-x-2">
|
<td class="p-2 space-x-2">
|
||||||
<Link :href="route('imports.continue', { import: imp.uuid })" class="px-2 py-1 rounded bg-gray-200 text-gray-800 text-xs">Poglej</Link>
|
<Link
|
||||||
<Link v-if="imp.status !== 'completed'" :href="route('imports.continue', { import: imp.uuid })" class="px-2 py-1 rounded bg-amber-600 text-white text-xs">Nadaljuj</Link>
|
:href="route('imports.continue', { import: imp.uuid })"
|
||||||
|
class="px-2 py-1 rounded bg-gray-200 text-gray-800 text-xs"
|
||||||
|
>Poglej</Link
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
v-if="imp.status !== 'completed'"
|
||||||
|
:href="route('imports.continue', { import: imp.uuid })"
|
||||||
|
class="px-2 py-1 rounded bg-amber-600 text-white text-xs"
|
||||||
|
>Nadaljuj</Link
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-if="canDelete(imp.status)"
|
||||||
|
class="px-2 py-1 rounded bg-red-600 text-white text-xs"
|
||||||
|
@click="confirmDelete(imp)"
|
||||||
|
>
|
||||||
|
Izbriši
|
||||||
|
</button>
|
||||||
<span v-else class="text-xs text-gray-400">Zaključen</span>
|
<span v-else class="text-xs text-gray-400">Zaključen</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -61,13 +119,57 @@ function statusBadge(status) {
|
||||||
|
|
||||||
<div class="flex items-center justify-between mt-4 text-sm text-gray-600">
|
<div class="flex items-center justify-between mt-4 text-sm text-gray-600">
|
||||||
<div>
|
<div>
|
||||||
Prikaz {{ imports.meta.from }}–{{ imports.meta.to }} od {{ imports.meta.total }}
|
Prikaz {{ imports.meta.from }}–{{ imports.meta.to }} od
|
||||||
|
{{ imports.meta.total }}
|
||||||
</div>
|
</div>
|
||||||
<div class="space-x-2">
|
<div class="space-x-2">
|
||||||
<Link v-if="imports.links.prev" :href="imports.links.prev" class="px-2 py-1 border rounded">Nazaj</Link>
|
<Link
|
||||||
<Link v-if="imports.links.next" :href="imports.links.next" class="px-2 py-1 border rounded">Naprej</Link>
|
v-if="imports.links.prev"
|
||||||
|
:href="imports.links.prev"
|
||||||
|
class="px-2 py-1 border rounded"
|
||||||
|
>Nazaj</Link
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
v-if="imports.links.next"
|
||||||
|
:href="imports.links.next"
|
||||||
|
class="px-2 py-1 border rounded"
|
||||||
|
>Naprej</Link
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<ConfirmationModal
|
||||||
|
:show="confirming"
|
||||||
|
@close="
|
||||||
|
confirming = false;
|
||||||
|
deletingId = null;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<template #title>Potrditev brisanja</template>
|
||||||
|
<template #content>
|
||||||
|
<p class="text-sm">
|
||||||
|
Ste prepričani, da želite izbrisati ta uvoz? Datoteka bo odstranjena iz
|
||||||
|
shrambe, če je še prisotna.
|
||||||
|
</p>
|
||||||
|
<p v-if="errorMsg" class="text-sm text-red-600 mt-2">{{ errorMsg }}</p>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<button
|
||||||
|
class="px-3 py-1.5 text-sm border rounded me-2"
|
||||||
|
@click="
|
||||||
|
confirming = false;
|
||||||
|
deletingId = null;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Prekliči
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-3 py-1.5 text-sm rounded bg-red-600 text-white"
|
||||||
|
@click="performDelete"
|
||||||
|
>
|
||||||
|
Izbriši
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</ConfirmationModal>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
63
resources/js/Pages/Imports/Partials/ActionsBar.vue
Normal file
63
resources/js/Pages/Imports/Partials/ActionsBar.vue
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
EyeIcon,
|
||||||
|
ArrowPathIcon,
|
||||||
|
BeakerIcon,
|
||||||
|
ArrowDownOnSquareIcon,
|
||||||
|
} from "@heroicons/vue/24/outline";
|
||||||
|
const props = defineProps({
|
||||||
|
importId: [Number, String],
|
||||||
|
isCompleted: Boolean,
|
||||||
|
processing: Boolean,
|
||||||
|
savingMappings: Boolean,
|
||||||
|
canProcess: Boolean,
|
||||||
|
selectedMappingsCount: Number,
|
||||||
|
});
|
||||||
|
const emits = defineEmits(["preview", "save-mappings", "process-import", "simulate"]);
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-wrap gap-2 items-center" v-if="!isCompleted">
|
||||||
|
<button
|
||||||
|
@click.prevent="$emit('preview')"
|
||||||
|
:disabled="!importId"
|
||||||
|
class="px-4 py-2 bg-gray-600 disabled:bg-gray-300 text-white rounded flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<EyeIcon class="h-4 w-4" />
|
||||||
|
Predogled vrstic
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click.prevent="$emit('save-mappings')"
|
||||||
|
:disabled="!importId || processing || savingMappings || isCompleted"
|
||||||
|
class="px-4 py-2 bg-orange-600 disabled:bg-gray-300 text-white rounded flex items-center gap-2"
|
||||||
|
title="Shrani preslikave za ta uvoz"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="savingMappings"
|
||||||
|
class="inline-block h-4 w-4 border-2 border-white/70 border-t-transparent rounded-full animate-spin"
|
||||||
|
></span>
|
||||||
|
<ArrowPathIcon v-else class="h-4 w-4" />
|
||||||
|
<span>Shrani preslikave</span>
|
||||||
|
<span
|
||||||
|
v-if="selectedMappingsCount"
|
||||||
|
class="ml-1 text-xs bg-white/20 px-1.5 py-0.5 rounded"
|
||||||
|
>{{ selectedMappingsCount }}</span
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click.prevent="$emit('process-import')"
|
||||||
|
:disabled="!canProcess"
|
||||||
|
class="px-4 py-2 bg-purple-600 disabled:bg-gray-300 text-white rounded flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<BeakerIcon class="h-4 w-4" />
|
||||||
|
{{ processing ? "Obdelava…" : "Obdelaj uvoz" }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click.prevent="$emit('simulate')"
|
||||||
|
:disabled="!importId || processing"
|
||||||
|
class="px-4 py-2 bg-blue-600 disabled:bg-gray-300 text-white rounded flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<ArrowDownOnSquareIcon class="h-4 w-4" />
|
||||||
|
Simulacija vnosa
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
16
resources/js/Pages/Imports/Partials/ChecklistSteps.vue
Normal file
16
resources/js/Pages/Imports/Partials/ChecklistSteps.vue
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<script setup>
|
||||||
|
import { CheckCircleIcon } from '@heroicons/vue/24/solid'
|
||||||
|
const props = defineProps({ steps: Array, missingCritical: Array })
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="bg-gray-50 border rounded p-3 text-xs flex flex-col gap-1 h-fit">
|
||||||
|
<div class="font-semibold text-gray-700 mb-1">Kontrolni seznam</div>
|
||||||
|
<div v-for="s in steps" :key="s.label" class="flex items-center gap-2" :class="s.done ? 'text-emerald-700' : 'text-gray-500'">
|
||||||
|
<CheckCircleIcon v-if="s.done" class="h-4 w-4 text-emerald-600" />
|
||||||
|
<span v-else class="h-4 w-4 rounded-full border border-gray-300 inline-block"></span>
|
||||||
|
<span>{{ s.label }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="missingCritical?.length" class="mt-2 text-red-600 font-medium">Manjkajo kritične: {{ missingCritical.join(', ') }}</div>
|
||||||
|
<div v-else class="mt-2 text-emerald-600">Kritične preslikave prisotne</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
61
resources/js/Pages/Imports/Partials/CsvPreviewModal.vue
Normal file
61
resources/js/Pages/Imports/Partials/CsvPreviewModal.vue
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
<script setup>
|
||||||
|
import Modal from '@/Components/Modal.vue'
|
||||||
|
const props = defineProps({
|
||||||
|
show: Boolean,
|
||||||
|
limit: Number,
|
||||||
|
rows: Array,
|
||||||
|
columns: Array,
|
||||||
|
loading: Boolean,
|
||||||
|
truncated: Boolean,
|
||||||
|
hasHeader: Boolean,
|
||||||
|
})
|
||||||
|
const emits = defineEmits(['close','change-limit','refresh'])
|
||||||
|
function onLimit(e){ emits('change-limit', Number(e.target.value)); emits('refresh') }
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<Modal :show="show" max-width="wide" @close="$emit('close')">
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h3 class="font-semibold text-lg">CSV Preview ({{ rows.length }} / {{ limit }})</h3>
|
||||||
|
<button class="text-sm px-2 py-1 rounded border" @click="$emit('close')">Close</button>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2 flex items-center gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<label class="mr-1 text-gray-600">Limit:</label>
|
||||||
|
<select :value="limit" class="border rounded p-1" @change="onLimit">
|
||||||
|
<option :value="50">50</option>
|
||||||
|
<option :value="100">100</option>
|
||||||
|
<option :value="200">200</option>
|
||||||
|
<option :value="300">300</option>
|
||||||
|
<option :value="500">500</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button @click="$emit('refresh')" class="px-2 py-1 border rounded" :disabled="loading">{{ loading ? 'Loading…' : 'Refresh' }}</button>
|
||||||
|
<span v-if="truncated" class="text-xs text-amber-600">Truncated at limit</span>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-auto max-h-[60vh] border rounded">
|
||||||
|
<table class="min-w-full text-xs">
|
||||||
|
<thead class="bg-gray-50 sticky top-0">
|
||||||
|
<tr>
|
||||||
|
<th class="p-2 border bg-white">#</th>
|
||||||
|
<th v-for="col in columns" :key="col" class="p-2 border text-left">{{ col }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-if="loading">
|
||||||
|
<td :colspan="columns.length + 1" class="p-4 text-center text-gray-500">Loading…</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="(r, idx) in rows" :key="idx" class="border-t hover:bg-gray-50">
|
||||||
|
<td class="p-2 border text-gray-500">{{ idx + 1 }}</td>
|
||||||
|
<td v-for="col in columns" :key="col" class="p-2 border whitespace-pre-wrap">{{ r[col] }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!loading && !rows.length">
|
||||||
|
<td :colspan="columns.length + 1" class="p-4 text-center text-gray-500">No rows</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-xs text-gray-500">Showing up to {{ limit }} rows from source file. Header detection: {{ hasHeader ? 'header present' : 'no header' }}.</p>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
268
resources/js/Pages/Imports/Partials/LogsTable.vue
Normal file
268
resources/js/Pages/Imports/Partials/LogsTable.vue
Normal file
|
|
@ -0,0 +1,268 @@
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from "vue";
|
||||||
|
import Dropdown from "@/Components/Dropdown.vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
events: Array,
|
||||||
|
loading: Boolean,
|
||||||
|
limit: Number,
|
||||||
|
});
|
||||||
|
const emits = defineEmits(["update:limit", "refresh"]);
|
||||||
|
function onLimit(e) {
|
||||||
|
emits("update:limit", Number(e.target.value));
|
||||||
|
emits("refresh");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Level filter (all | error | warning | info/other)
|
||||||
|
const levelFilter = ref("all");
|
||||||
|
const levelOptions = [
|
||||||
|
{ value: "all", label: "All" },
|
||||||
|
{ value: "error", label: "Error" },
|
||||||
|
{ value: "warning", label: "Warning" },
|
||||||
|
{ value: "info", label: "Info / Other" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const filteredEvents = computed(() => {
|
||||||
|
if (levelFilter.value === "all") return props.events || [];
|
||||||
|
if (levelFilter.value === "info") {
|
||||||
|
return (props.events || []).filter(
|
||||||
|
(e) => e.level !== "error" && e.level !== "warning"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (props.events || []).filter((e) => e.level === levelFilter.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Expanded state per event id
|
||||||
|
const expanded = ref(new Set());
|
||||||
|
function isExpanded(id) {
|
||||||
|
return expanded.value.has(id);
|
||||||
|
}
|
||||||
|
function toggleExpand(id) {
|
||||||
|
if (expanded.value.has(id)) {
|
||||||
|
expanded.value.delete(id);
|
||||||
|
} else {
|
||||||
|
expanded.value.add(id);
|
||||||
|
}
|
||||||
|
expanded.value = new Set(expanded.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLong(msg) {
|
||||||
|
return msg && String(msg).length > 160;
|
||||||
|
}
|
||||||
|
function shortMsg(msg) {
|
||||||
|
if (!msg) return "";
|
||||||
|
const s = String(msg);
|
||||||
|
return s.length <= 160 ? s : s.slice(0, 160) + "…";
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryJson(val) {
|
||||||
|
if (val == null) return null;
|
||||||
|
if (typeof val === "object") return val;
|
||||||
|
if (typeof val === "string") {
|
||||||
|
const t = val.trim();
|
||||||
|
if (
|
||||||
|
(t.startsWith("{") && t.endsWith("}")) ||
|
||||||
|
(t.startsWith("[") && t.endsWith("]"))
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(t);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contextPreview(ctx) {
|
||||||
|
if (!ctx) return "";
|
||||||
|
const obj = tryJson(ctx) || ctx;
|
||||||
|
let str = typeof obj === "string" ? obj : JSON.stringify(obj);
|
||||||
|
if (str.length > 60) str = str.slice(0, 60) + "…";
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON formatting & lightweight syntax highlight
|
||||||
|
function htmlEscape(s) {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function prettyJson(val) {
|
||||||
|
const obj = tryJson(val);
|
||||||
|
if (!obj) {
|
||||||
|
return htmlEscape(typeof val === "string" ? val : String(val ?? ""));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return htmlEscape(JSON.stringify(obj, null, 2));
|
||||||
|
} catch {
|
||||||
|
return htmlEscape(String(val ?? ""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightJson(val) {
|
||||||
|
const src = prettyJson(val);
|
||||||
|
return src.replace(
|
||||||
|
/(\"([^"\\]|\\.)*\"\s*:)|(\"([^"\\]|\\.)*\")|\b(true|false|null)\b|-?\b\d+(?:\.\d+)?(?:[eE][+\-]?\d+)?/g,
|
||||||
|
(match) => {
|
||||||
|
if (/^\"([^"\\]|\\.)*\"\s*:/.test(match)) {
|
||||||
|
return `<span class=\"text-indigo-600\">${match}</span>`; // key
|
||||||
|
}
|
||||||
|
if (/^\"/.test(match)) {
|
||||||
|
return `<span class=\"text-emerald-700\">${match}</span>`; // string
|
||||||
|
}
|
||||||
|
if (/true|false/.test(match)) {
|
||||||
|
return `<span class=\"text-orange-600 font-medium\">${match}</span>`; // boolean
|
||||||
|
}
|
||||||
|
if (/null/.test(match)) {
|
||||||
|
return `<span class=\"text-gray-500 italic\">${match}</span>`; // null
|
||||||
|
}
|
||||||
|
if (/^-?\d/.test(match)) {
|
||||||
|
return `<span class=\"text-fuchsia-700\">${match}</span>`; // number
|
||||||
|
}
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formattedContext(ctx) {
|
||||||
|
return highlightJson(ctx);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="pt-4">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<h3 class="font-semibold">Logs</h3>
|
||||||
|
<div class="flex items-center flex-wrap gap-2 text-sm">
|
||||||
|
<label class="text-gray-600">Show</label>
|
||||||
|
<select :value="limit" class="border rounded p-1" @change="onLimit">
|
||||||
|
<option :value="50">50</option>
|
||||||
|
<option :value="100">100</option>
|
||||||
|
<option :value="200">200</option>
|
||||||
|
<option :value="500">500</option>
|
||||||
|
</select>
|
||||||
|
<label class="text-gray-600 ml-2">Level</label>
|
||||||
|
<select v-model="levelFilter" class="border rounded p-1">
|
||||||
|
<option v-for="opt in levelOptions" :key="opt.value" :value="opt.value">
|
||||||
|
{{ opt.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
@click.prevent="$emit('refresh')"
|
||||||
|
class="px-2 py-1 border rounded text-sm"
|
||||||
|
:disabled="loading"
|
||||||
|
>
|
||||||
|
{{ loading ? "Refreshing…" : "Refresh" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto max-h-[30rem] overflow-y-auto rounded border">
|
||||||
|
<table class="min-w-full bg-white text-sm table-fixed">
|
||||||
|
<colgroup>
|
||||||
|
<col class="w-40" />
|
||||||
|
<col class="w-20" />
|
||||||
|
<col class="w-40" />
|
||||||
|
<col />
|
||||||
|
<col class="w-16" />
|
||||||
|
</colgroup>
|
||||||
|
<thead class="bg-gray-50 sticky top-0 z-10 shadow">
|
||||||
|
<tr class="text-left text-xs uppercase text-gray-600">
|
||||||
|
<th class="p-2 border">Time</th>
|
||||||
|
<th class="p-2 border">Level</th>
|
||||||
|
<th class="p-2 border">Event</th>
|
||||||
|
<th class="p-2 border">Message</th>
|
||||||
|
<th class="p-2 border">Row</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="ev in filteredEvents" :key="ev.id" class="border-t align-top">
|
||||||
|
<td class="p-2 border whitespace-nowrap">
|
||||||
|
{{ new Date(ev.created_at).toLocaleString() }}
|
||||||
|
</td>
|
||||||
|
<td class="p-2 border">
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'px-2 py-0.5 rounded text-xs',
|
||||||
|
ev.level === 'error'
|
||||||
|
? 'bg-red-100 text-red-800'
|
||||||
|
: ev.level === 'warning'
|
||||||
|
? 'bg-amber-100 text-amber-800'
|
||||||
|
: 'bg-gray-100 text-gray-700',
|
||||||
|
]"
|
||||||
|
>{{ ev.level }}</span
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
<td class="p-2 border break-words max-w-[9rem]">
|
||||||
|
<span class="block truncate" :title="ev.event">{{ ev.event }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="p-2 border align-top max-w-[28rem]">
|
||||||
|
<div class="space-y-1 break-words">
|
||||||
|
<div class="leading-snug whitespace-pre-wrap">
|
||||||
|
<span v-if="!isLong(ev.message)">{{ ev.message }}</span>
|
||||||
|
<span v-else>
|
||||||
|
<span v-if="!isExpanded(ev.id)">{{ shortMsg(ev.message) }}</span>
|
||||||
|
<span v-else>{{ ev.message }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ml-2 inline-flex items-center gap-0.5 text-xs text-indigo-600 hover:underline"
|
||||||
|
@click="toggleExpand(ev.id)"
|
||||||
|
>
|
||||||
|
{{ isExpanded(ev.id) ? "Show less" : "Read more" }}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="ev.context" class="text-xs text-gray-600">
|
||||||
|
<Dropdown
|
||||||
|
align="left"
|
||||||
|
width="wide"
|
||||||
|
:content-classes="[
|
||||||
|
'p-3',
|
||||||
|
'bg-white',
|
||||||
|
'text-xs',
|
||||||
|
'break-words',
|
||||||
|
'space-y-2',
|
||||||
|
'max-h-[28rem]',
|
||||||
|
'overflow-auto',
|
||||||
|
'max-w-[34rem]',
|
||||||
|
]"
|
||||||
|
:close-on-content-click="false"
|
||||||
|
>
|
||||||
|
<template #trigger>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-1.5 py-0.5 rounded border border-gray-300 bg-gray-50 hover:bg-gray-100 text-gray-700 transition text-[11px] font-medium"
|
||||||
|
>
|
||||||
|
Context: {{ contextPreview(ev.context) }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<template #content>
|
||||||
|
<div
|
||||||
|
class="font-medium text-gray-700 mb-1 flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<span>Context JSON</span>
|
||||||
|
<span class="text-[10px] text-gray-400">ID: {{ ev.id }}</span>
|
||||||
|
</div>
|
||||||
|
<pre
|
||||||
|
class="whitespace-pre break-words text-gray-800 text-[11px] leading-snug"
|
||||||
|
>
|
||||||
|
<code v-html="formattedContext(ev.context)"></code>
|
||||||
|
</pre>
|
||||||
|
</template>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="p-2 border">{{ ev.import_row_id ?? "—" }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!filteredEvents.length">
|
||||||
|
<td class="p-3 text-center text-gray-500" colspan="5">No events yet</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
85
resources/js/Pages/Imports/Partials/MappingTable.vue
Normal file
85
resources/js/Pages/Imports/Partials/MappingTable.vue
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
rows: Array,
|
||||||
|
entityOptions: Array,
|
||||||
|
isCompleted: Boolean,
|
||||||
|
detected: Object,
|
||||||
|
detectedNote: String,
|
||||||
|
duplicateTargets: Object,
|
||||||
|
missingCritical: Array,
|
||||||
|
mappingSaved: Boolean,
|
||||||
|
mappingSavedCount: Number,
|
||||||
|
mappingError: String,
|
||||||
|
show: { type: Boolean, default: true },
|
||||||
|
fieldsForEntity: Function,
|
||||||
|
})
|
||||||
|
const emits = defineEmits(['update:rows','save'])
|
||||||
|
|
||||||
|
function duplicateTarget(row){
|
||||||
|
if(!row || !row.entity || !row.field) return false
|
||||||
|
// parent already marks duplicates in duplicateTargets set keyed as record.field
|
||||||
|
return props.duplicateTargets?.has?.(row.entity + '.' + row.field) || false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div v-if="show && rows?.length" class="pt-4">
|
||||||
|
<h3 class="font-semibold mb-2">
|
||||||
|
Detected Columns ({{ detected?.has_header ? 'header' : 'positional' }})
|
||||||
|
<span class="ml-2 text-xs text-gray-500">detected: {{ detected?.columns?.length || 0 }}, rows: {{ rows.length }}, delimiter: {{ detected?.delimiter || 'auto' }}</span>
|
||||||
|
</h3>
|
||||||
|
<p v-if="detectedNote" class="text-xs text-gray-500 mb-2">{{ detectedNote }}</p>
|
||||||
|
<div class="relative border rounded overflow-auto max-h-[420px]">
|
||||||
|
<table class="min-w-full bg-white">
|
||||||
|
<thead class="sticky top-0 z-10">
|
||||||
|
<tr class="bg-gray-50/95 backdrop-blur text-left text-xs uppercase text-gray-600">
|
||||||
|
<th class="p-2 border">Source column</th>
|
||||||
|
<th class="p-2 border">Entity</th>
|
||||||
|
<th class="p-2 border">Field</th>
|
||||||
|
<th class="p-2 border">Transform</th>
|
||||||
|
<th class="p-2 border">Apply mode</th>
|
||||||
|
<th class="p-2 border">Skip</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(row, idx) in rows" :key="idx" class="border-t" :class="duplicateTarget(row) ? 'bg-red-50' : ''">
|
||||||
|
<td class="p-2 border text-sm">{{ row.source_column }}</td>
|
||||||
|
<td class="p-2 border">
|
||||||
|
<select v-model="row.entity" class="border rounded p-1 w-full" :disabled="isCompleted">
|
||||||
|
<option value="">—</option>
|
||||||
|
<option v-for="opt in entityOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td class="p-2 border">
|
||||||
|
<select v-model="row.field" :class="['border rounded p-1 w-full', duplicateTarget(row) ? 'border-red-500 bg-red-50' : '']" :disabled="isCompleted">
|
||||||
|
<option value="">—</option>
|
||||||
|
<option v-for="f in fieldsForEntity(row.entity)" :key="f" :value="f">{{ f }}</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td class="p-2 border">
|
||||||
|
<select v-model="row.transform" class="border rounded p-1 w-full" :disabled="isCompleted">
|
||||||
|
<option value="">None</option>
|
||||||
|
<option value="trim">Trim</option>
|
||||||
|
<option value="upper">Uppercase</option>
|
||||||
|
<option value="lower">Lowercase</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td class="p-2 border">
|
||||||
|
<select v-model="row.apply_mode" class="border rounded p-1 w-full" :disabled="isCompleted">
|
||||||
|
<option value="keyref">Keyref</option>
|
||||||
|
<option value="both">Both</option>
|
||||||
|
<option value="insert">Insert only</option>
|
||||||
|
<option value="update">Update only</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td class="p-2 border text-center">
|
||||||
|
<input type="checkbox" v-model="row.skip" :disabled="isCompleted" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div v-if="mappingSaved" class="text-sm text-emerald-700 mt-2">Mappings saved ({{ mappingSavedCount }}).</div>
|
||||||
|
<div v-else-if="mappingError" class="text-sm text-red-600 mt-2">{{ mappingError }}</div>
|
||||||
|
<div v-if="missingCritical?.length" class="text-xs text-amber-600 mt-1">Missing critical: {{ missingCritical.join(', ') }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
9
resources/js/Pages/Imports/Partials/ProcessResult.vue
Normal file
9
resources/js/Pages/Imports/Partials/ProcessResult.vue
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<script setup>
|
||||||
|
const props = defineProps({ result: [String, Object] })
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div v-if="result" class="pt-4">
|
||||||
|
<h3 class="font-semibold mb-2">Import Result</h3>
|
||||||
|
<pre class="bg-gray-50 border rounded p-3 text-sm overflow-x-auto">{{ result }}</pre>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
28
resources/js/Pages/Imports/Partials/SavedMappingsTable.vue
Normal file
28
resources/js/Pages/Imports/Partials/SavedMappingsTable.vue
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<script setup>
|
||||||
|
const props = defineProps({ mappings: Array })
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div v-if="mappings?.length" class="pt-4">
|
||||||
|
<h3 class="font-semibold mb-2">Current Saved Mappings</h3>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full border bg-white text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-gray-50 text-left text-xs uppercase text-gray-600">
|
||||||
|
<th class="p-2 border">Source column</th>
|
||||||
|
<th class="p-2 border">Target field</th>
|
||||||
|
<th class="p-2 border">Transform</th>
|
||||||
|
<th class="p-2 border">Mode</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="m in mappings" :key="m.id || (m.source_column + m.target_field)" class="border-t">
|
||||||
|
<td class="p-2 border">{{ m.source_column }}</td>
|
||||||
|
<td class="p-2 border">{{ m.target_field }}</td>
|
||||||
|
<td class="p-2 border">{{ m.transform || '—' }}</td>
|
||||||
|
<td class="p-2 border">{{ m.apply_mode || 'both' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
793
resources/js/Pages/Imports/Partials/SimulationModal.vue
Normal file
793
resources/js/Pages/Imports/Partials/SimulationModal.vue
Normal file
|
|
@ -0,0 +1,793 @@
|
||||||
|
<script setup>
|
||||||
|
import Modal from "@/Components/Modal.vue";
|
||||||
|
import { useEurFormat } from "../useEurFormat.js";
|
||||||
|
import { ArrowRightIcon, ArrowDownIcon, ArrowUpIcon } from "@heroicons/vue/24/solid";
|
||||||
|
import { computed, ref, watch } from "vue";
|
||||||
|
|
||||||
|
// Props expected by the template
|
||||||
|
const props = defineProps({
|
||||||
|
show: { type: Boolean, default: false },
|
||||||
|
rows: { type: Array, default: () => [] },
|
||||||
|
limit: { type: Number, default: 50 },
|
||||||
|
loading: { type: Boolean, default: false },
|
||||||
|
entities: { type: Array, default: () => [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emits
|
||||||
|
const emit = defineEmits(["close", "update:limit"]);
|
||||||
|
|
||||||
|
// Map technical entity keys to localized labels
|
||||||
|
const entityLabelMap = {
|
||||||
|
account: "računi",
|
||||||
|
payment: "plačila",
|
||||||
|
contract: "pogodbe",
|
||||||
|
person: "osebe",
|
||||||
|
client_case: "primeri",
|
||||||
|
address: "naslovi",
|
||||||
|
email: "emaili",
|
||||||
|
phone: "telefoni",
|
||||||
|
booking: "knjižbe",
|
||||||
|
activity: "aktivnosti",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Formatting helpers
|
||||||
|
const { formatEur } = useEurFormat();
|
||||||
|
const fmt = (v) => formatEur(v);
|
||||||
|
function formatDate(val) {
|
||||||
|
if (!val) return "—";
|
||||||
|
try {
|
||||||
|
const d = val instanceof Date ? val : new Date(val);
|
||||||
|
if (isNaN(d.getTime())) return String(val);
|
||||||
|
return d.toLocaleDateString("sl-SI", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
return String(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Localized list for header
|
||||||
|
const localizedEntities = computed(() =>
|
||||||
|
Array.isArray(props.entities) && props.entities.length
|
||||||
|
? props.entities.map((e) => entityLabelMap[e] ?? e).join(", ")
|
||||||
|
: ""
|
||||||
|
);
|
||||||
|
|
||||||
|
const entitiesWithRows = computed(() => {
|
||||||
|
if (!props.rows?.length || !props.entities?.length) return [];
|
||||||
|
const present = new Set();
|
||||||
|
for (const r of props.rows) {
|
||||||
|
if (!r.entities) continue;
|
||||||
|
for (const k of Object.keys(r.entities)) {
|
||||||
|
if (props.entities.includes(k)) present.add(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return props.entities.filter((e) => present.has(e));
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeEntity = ref(null);
|
||||||
|
const hideChain = ref(false);
|
||||||
|
const showOnlyChanged = ref(false);
|
||||||
|
watch(
|
||||||
|
entitiesWithRows,
|
||||||
|
(val) => {
|
||||||
|
if (!val.length) {
|
||||||
|
activeEntity.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!activeEntity.value || !val.includes(activeEntity.value))
|
||||||
|
activeEntity.value = val[0];
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const entityStats = computed(() => {
|
||||||
|
const stats = {};
|
||||||
|
for (const e of entitiesWithRows.value)
|
||||||
|
stats[e] = {
|
||||||
|
total_rows: 0,
|
||||||
|
create: 0,
|
||||||
|
update: 0,
|
||||||
|
missing_ref: 0,
|
||||||
|
invalid: 0,
|
||||||
|
duplicate: 0,
|
||||||
|
duplicate_db: 0,
|
||||||
|
};
|
||||||
|
for (const r of props.rows || []) {
|
||||||
|
if (!r.entities) continue;
|
||||||
|
for (const [k, ent] of Object.entries(r.entities)) {
|
||||||
|
if (!stats[k]) continue;
|
||||||
|
stats[k].total_rows++;
|
||||||
|
switch (ent.action) {
|
||||||
|
case "create":
|
||||||
|
stats[k].create++;
|
||||||
|
break;
|
||||||
|
case "update":
|
||||||
|
stats[k].update++;
|
||||||
|
break;
|
||||||
|
case "missing_ref":
|
||||||
|
stats[k].missing_ref++;
|
||||||
|
break;
|
||||||
|
case "invalid":
|
||||||
|
stats[k].invalid++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (ent.duplicate) stats[k].duplicate++;
|
||||||
|
if (ent.duplicate_db) stats[k].duplicate_db++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return stats;
|
||||||
|
});
|
||||||
|
const activeSummary = computed(() =>
|
||||||
|
activeEntity.value ? entityStats.value[activeEntity.value] : null
|
||||||
|
);
|
||||||
|
const entityHasDuplicates = (e) => {
|
||||||
|
const s = entityStats.value[e];
|
||||||
|
return s ? s.duplicate + s.duplicate_db > 0 : false;
|
||||||
|
};
|
||||||
|
const visibleRows = computed(() => {
|
||||||
|
if (!props.rows || !activeEntity.value) return [];
|
||||||
|
const eps = 0.0000001;
|
||||||
|
return props.rows
|
||||||
|
.filter((r) => {
|
||||||
|
if (!r.entities || !r.entities[activeEntity.value]) return false;
|
||||||
|
const ent = r.entities[activeEntity.value];
|
||||||
|
if (hideChain.value && ent.existing_chain) return false;
|
||||||
|
if (showOnlyChanged.value) {
|
||||||
|
// Define change criteria per entity
|
||||||
|
if (activeEntity.value === "account") {
|
||||||
|
if (ent.delta !== undefined && Math.abs(ent.delta) > eps) return true;
|
||||||
|
// new account creation counts as change
|
||||||
|
if (ent.action === "create") return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (activeEntity.value === "payment") {
|
||||||
|
// payment with valid amount considered change
|
||||||
|
return ent.amount !== null && ent.amount !== undefined;
|
||||||
|
}
|
||||||
|
// Generic entities: any create/update considered change
|
||||||
|
if (ent.action === "create" || ent.action === "update") return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.slice(0, props.limit || props.rows.length);
|
||||||
|
});
|
||||||
|
function referenceOf(entityName, ent) {
|
||||||
|
if (!ent || typeof ent !== "object") return "—";
|
||||||
|
|
||||||
|
const pick = (val) => {
|
||||||
|
if (val === undefined || val === null) return null;
|
||||||
|
if (typeof val === "object") {
|
||||||
|
if (
|
||||||
|
val.normalized !== undefined &&
|
||||||
|
val.normalized !== null &&
|
||||||
|
String(val.normalized).trim() !== ""
|
||||||
|
)
|
||||||
|
return val.normalized;
|
||||||
|
if (
|
||||||
|
val.value !== undefined &&
|
||||||
|
val.value !== null &&
|
||||||
|
String(val.value).trim() !== ""
|
||||||
|
)
|
||||||
|
return val.value;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const s = String(val).trim();
|
||||||
|
return s === "" ? null : val;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. direct reference
|
||||||
|
const direct = pick(ent.reference);
|
||||||
|
if (direct !== null) return direct;
|
||||||
|
|
||||||
|
// 2. other plausible keys
|
||||||
|
const candidates = [
|
||||||
|
"ref",
|
||||||
|
"code",
|
||||||
|
"number",
|
||||||
|
"identifier",
|
||||||
|
"external_id",
|
||||||
|
`${entityName}_reference`,
|
||||||
|
`${entityName}Reference`,
|
||||||
|
];
|
||||||
|
for (const k of candidates) {
|
||||||
|
if (k in ent) {
|
||||||
|
const v = pick(ent[k]);
|
||||||
|
if (v !== null) return v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. any property containing 'reference'
|
||||||
|
for (const [k, v] of Object.entries(ent)) {
|
||||||
|
if (k.toLowerCase().includes("reference")) {
|
||||||
|
const pv = pick(v);
|
||||||
|
if (pv !== null) return pv;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. sources map
|
||||||
|
const sources = ent.sources;
|
||||||
|
if (sources && typeof sources === "object") {
|
||||||
|
const priority = [`${entityName}.reference`, "reference"];
|
||||||
|
for (const k of priority) {
|
||||||
|
if (k in sources) {
|
||||||
|
const pv = pick(sources[k]);
|
||||||
|
if (pv !== null) return pv;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [k, v] of Object.entries(sources)) {
|
||||||
|
if (k.toLowerCase().includes("reference")) {
|
||||||
|
const pv = pick(v);
|
||||||
|
if (pv !== null) return pv;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal :show="show" max-width="wide" @close="emit('close')">
|
||||||
|
<div class="p-4 space-y-4">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800">Simulacija uvoza</h2>
|
||||||
|
<p v-if="localizedEntities" class="text-[12px] text-gray-500">
|
||||||
|
Entitete: {{ localizedEntities }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="text-[11px] text-gray-600 flex items-center gap-1"
|
||||||
|
>Prikaži:
|
||||||
|
<select
|
||||||
|
class="border rounded px-1 py-0.5 text-[11px]"
|
||||||
|
:value="limit"
|
||||||
|
@change="onLimit"
|
||||||
|
>
|
||||||
|
<option :value="25">25</option>
|
||||||
|
<option :value="50">50</option>
|
||||||
|
<option :value="100">100</option>
|
||||||
|
<option :value="250">250</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-[11px] px-2 py-1 rounded border bg-white hover:bg-gray-50"
|
||||||
|
@click="toggleVerbose"
|
||||||
|
>
|
||||||
|
{{ verbose ? "Manj" : "Več" }} podrobnosti
|
||||||
|
</button>
|
||||||
|
<label class="flex items-center gap-1 text-[11px] text-gray-600">
|
||||||
|
<input type="checkbox" v-model="hideChain" class="rounded border-gray-300" />
|
||||||
|
Skrij verižne
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-1 text-[11px] text-gray-600">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
v-model="showOnlyChanged"
|
||||||
|
class="rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
Samo spremenjeni
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-[11px] px-2 py-1 rounded bg-gray-800 text-white hover:bg-gray-700"
|
||||||
|
@click="emit('close')"
|
||||||
|
>
|
||||||
|
Zapri
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="entitiesWithRows.length" class="flex flex-wrap gap-1 border-b pb-1">
|
||||||
|
<button
|
||||||
|
v-for="e in entitiesWithRows"
|
||||||
|
:key="e"
|
||||||
|
type="button"
|
||||||
|
@click="activeEntity = e"
|
||||||
|
class="relative px-2 py-1 rounded-t text-[11px] font-medium border"
|
||||||
|
:class="
|
||||||
|
activeEntity === e
|
||||||
|
? 'bg-white border-b-white text-gray-900'
|
||||||
|
: 'bg-gray-100 hover:bg-gray-200 text-gray-600'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span class="uppercase tracking-wide">{{ e }}</span>
|
||||||
|
<span
|
||||||
|
v-if="entityHasDuplicates(e)"
|
||||||
|
class="absolute -top-1 -right-1 inline-block w-3 h-3 rounded-full bg-amber-500 ring-2 ring-white"
|
||||||
|
title="Duplikati"
|
||||||
|
></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="activeSummary"
|
||||||
|
class="text-[11px] flex flex-wrap items-center gap-3 bg-gray-50 border rounded px-2 py-1"
|
||||||
|
>
|
||||||
|
<div class="font-semibold uppercase tracking-wide text-gray-600">
|
||||||
|
{{ activeEntity }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-gray-600"
|
||||||
|
>Vrstic:
|
||||||
|
<span class="font-medium text-gray-800">{{
|
||||||
|
activeSummary.total_rows
|
||||||
|
}}</span></span
|
||||||
|
>
|
||||||
|
<span v-if="activeSummary.create" class="text-emerald-700"
|
||||||
|
>+{{ activeSummary.create }} novo</span
|
||||||
|
>
|
||||||
|
<span v-if="activeSummary.update" class="text-blue-700"
|
||||||
|
>{{ activeSummary.update }} posodobitev</span
|
||||||
|
>
|
||||||
|
<span v-if="activeSummary.duplicate" class="text-amber-600"
|
||||||
|
>{{ activeSummary.duplicate }} duplikat</span
|
||||||
|
>
|
||||||
|
<span v-if="activeSummary.duplicate_db" class="text-amber-700"
|
||||||
|
>{{ activeSummary.duplicate_db }} obstaja</span
|
||||||
|
>
|
||||||
|
<span v-if="activeSummary.missing_ref" class="text-red-600"
|
||||||
|
>{{ activeSummary.missing_ref }} manjka referenca</span
|
||||||
|
>
|
||||||
|
<span v-if="activeSummary.invalid" class="text-red-700"
|
||||||
|
>{{ activeSummary.invalid }} neveljavnih</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="activeEntity" class="border rounded bg-white">
|
||||||
|
<div class="max-h-[28rem] overflow-auto">
|
||||||
|
<table class="min-w-full text-[12px]">
|
||||||
|
<thead class="bg-gray-100 text-left sticky top-0 z-10">
|
||||||
|
<tr>
|
||||||
|
<th class="px-2 py-1 border w-14">#</th>
|
||||||
|
<th class="px-2 py-1 border">Podatki</th>
|
||||||
|
<th class="px-2 py-1 border w-48">Učinek (plačilo)</th>
|
||||||
|
<th class="px-2 py-1 border w-24">Opombe</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-if="loading">
|
||||||
|
<td :colspan="4" class="p-4 text-center text-gray-500">Nalagam…</td>
|
||||||
|
</tr>
|
||||||
|
<tr
|
||||||
|
v-for="r in visibleRows"
|
||||||
|
:key="r.index"
|
||||||
|
class="border-t"
|
||||||
|
:class="r.status !== 'ok' ? 'bg-red-50' : ''"
|
||||||
|
>
|
||||||
|
<td class="p-2 border text-gray-500 align-top">{{ r.index }}</td>
|
||||||
|
<td class="p-2 border align-top">
|
||||||
|
<div
|
||||||
|
v-if="r.entities && r.entities[activeEntity]"
|
||||||
|
class="text-[11px] border rounded p-2 bg-white/70 max-w-[360px]"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="font-semibold uppercase tracking-wide text-gray-600 mb-1 flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<span>{{ activeEntity }}</span>
|
||||||
|
<span
|
||||||
|
v-if="r.entities[activeEntity].action_label"
|
||||||
|
class="text-[10px] px-1 py-0.5 rounded bg-gray-100"
|
||||||
|
>{{ r.entities[activeEntity].action_label }}</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="r.entities[activeEntity].existing_chain"
|
||||||
|
class="ml-1 text-[9px] px-1 py-0.5 rounded bg-indigo-100 text-indigo-700"
|
||||||
|
title="Iz obstoječe verige (contract → client_case → person)"
|
||||||
|
>chain</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="r.entities[activeEntity].inherited_reference"
|
||||||
|
class="ml-1 text-[9px] px-1 py-0.5 rounded bg-amber-100 text-amber-700"
|
||||||
|
title="Referenca podedovana"
|
||||||
|
>inh</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="r.entities[activeEntity].action === 'implicit'"
|
||||||
|
class="ml-1 text-[9px] px-1 py-0.5 rounded bg-teal-100 text-teal-700"
|
||||||
|
title="Implicitno"
|
||||||
|
>impl</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="activeEntity === 'account'">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
Ref:
|
||||||
|
<span class="font-medium flex items-center gap-1">
|
||||||
|
{{ referenceOf(activeEntity, r.entities[activeEntity]) }}
|
||||||
|
<span
|
||||||
|
v-if="r.entities[activeEntity].inherited_reference"
|
||||||
|
class="text-[9px] px-1 py-0.5 rounded bg-indigo-100 text-indigo-700"
|
||||||
|
title="Podedovano iz pogodbe"
|
||||||
|
>inh</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="r.entities[activeEntity].balance_before !== undefined"
|
||||||
|
class="mt-1 space-y-0.5"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<span class="text-gray-500">Saldo:</span
|
||||||
|
><span>{{ fmt(r.entities[activeEntity].balance_before) }}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="r.entities[activeEntity].balance_after !== undefined"
|
||||||
|
class="flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<ArrowRightIcon
|
||||||
|
v-if="
|
||||||
|
(r.entities[activeEntity].balance_after ??
|
||||||
|
r.entities[activeEntity].balance_before) ===
|
||||||
|
r.entities[activeEntity].balance_before
|
||||||
|
"
|
||||||
|
class="h-3 w-3 text-gray-400"
|
||||||
|
/>
|
||||||
|
<ArrowDownIcon
|
||||||
|
v-else-if="
|
||||||
|
(r.entities[activeEntity].balance_after ??
|
||||||
|
r.entities[activeEntity].balance_before) <
|
||||||
|
r.entities[activeEntity].balance_before
|
||||||
|
"
|
||||||
|
class="h-3 w-3 text-emerald-500"
|
||||||
|
/>
|
||||||
|
<ArrowUpIcon v-else class="h-3 w-3 text-red-500" />
|
||||||
|
<span
|
||||||
|
:class="
|
||||||
|
(r.entities[activeEntity].balance_after ??
|
||||||
|
r.entities[activeEntity].balance_before) <
|
||||||
|
r.entities[activeEntity].balance_before
|
||||||
|
? 'text-emerald-600 font-medium'
|
||||||
|
: 'text-red-600 font-medium'
|
||||||
|
"
|
||||||
|
>{{
|
||||||
|
fmt(
|
||||||
|
r.entities[activeEntity].balance_after ??
|
||||||
|
r.entities[activeEntity].balance_before
|
||||||
|
)
|
||||||
|
}}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="activeEntity === 'payment'">
|
||||||
|
<div>
|
||||||
|
Znesek:
|
||||||
|
<span class="font-medium">{{
|
||||||
|
fmt(
|
||||||
|
r.entities[activeEntity].amount ??
|
||||||
|
r.entities[activeEntity].raw_amount
|
||||||
|
)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Datum: {{ formatDate(r.entities[activeEntity].payment_date) }}
|
||||||
|
</div>
|
||||||
|
<div v-if="r.entities[activeEntity].reference">
|
||||||
|
Ref:
|
||||||
|
<span class="font-medium">{{
|
||||||
|
r.entities[activeEntity].reference
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Status:
|
||||||
|
<span
|
||||||
|
:class="
|
||||||
|
r.entities[activeEntity].status === 'ok'
|
||||||
|
? 'text-emerald-600'
|
||||||
|
: r.entities[activeEntity].status === 'duplicate' ||
|
||||||
|
r.entities[activeEntity].status === 'duplicate_db'
|
||||||
|
? 'text-amber-600'
|
||||||
|
: 'text-red-600'
|
||||||
|
"
|
||||||
|
>{{
|
||||||
|
r.entities[activeEntity].status_label ||
|
||||||
|
r.entities[activeEntity].status
|
||||||
|
}}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="activeEntity === 'contract'">
|
||||||
|
<div>
|
||||||
|
Ref:
|
||||||
|
<span class="font-medium">{{
|
||||||
|
referenceOf(activeEntity, r.entities[activeEntity])
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Akcija:
|
||||||
|
<span class="font-medium">{{
|
||||||
|
r.entities[activeEntity].action_label ||
|
||||||
|
r.entities[activeEntity].action
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex flex-wrap gap-1 mb-1">
|
||||||
|
<span
|
||||||
|
v-if="r.entities[activeEntity].identity_used"
|
||||||
|
class="px-1 py-0.5 rounded bg-indigo-50 text-indigo-700 text-[10px]"
|
||||||
|
title="Uporabljena identiteta"
|
||||||
|
>{{ r.entities[activeEntity].identity_used }}</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="r.entities[activeEntity].duplicate"
|
||||||
|
class="px-1 py-0.5 rounded bg-amber-100 text-amber-700 text-[10px]"
|
||||||
|
title="Podvojen v tej seriji"
|
||||||
|
>duplikat</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="r.entities[activeEntity].duplicate_db"
|
||||||
|
class="px-1 py-0.5 rounded bg-amber-200 text-amber-800 text-[10px]"
|
||||||
|
title="Že obstaja v bazi"
|
||||||
|
>obstaja v bazi</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<template v-if="activeEntity === 'person'">
|
||||||
|
<div class="grid grid-cols-1 gap-0.5">
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
referenceOf(activeEntity, r.entities[activeEntity]) !== '—'
|
||||||
|
"
|
||||||
|
class="text-[10px] text-gray-600"
|
||||||
|
>
|
||||||
|
Ref:
|
||||||
|
<span class="font-medium text-gray-800">{{
|
||||||
|
referenceOf(activeEntity, r.entities[activeEntity])
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="r.entities[activeEntity].full_name"
|
||||||
|
class="text-[10px] text-gray-600"
|
||||||
|
>
|
||||||
|
Ime:
|
||||||
|
<span class="font-medium">{{
|
||||||
|
r.entities[activeEntity].full_name
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="
|
||||||
|
r.entities[activeEntity].first_name ||
|
||||||
|
r.entities[activeEntity].last_name
|
||||||
|
"
|
||||||
|
class="text-[10px] text-gray-600"
|
||||||
|
>
|
||||||
|
Ime:
|
||||||
|
<span class="font-medium">{{
|
||||||
|
[
|
||||||
|
r.entities[activeEntity].first_name,
|
||||||
|
r.entities[activeEntity].last_name,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="r.entities[activeEntity].birthday"
|
||||||
|
class="text-[10px] text-gray-600"
|
||||||
|
>
|
||||||
|
Rojstvo:
|
||||||
|
<span class="font-medium">{{
|
||||||
|
r.entities[activeEntity].birthday
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="r.entities[activeEntity].description"
|
||||||
|
class="text-[10px] text-gray-600"
|
||||||
|
>
|
||||||
|
Opis:
|
||||||
|
<span class="font-medium">{{
|
||||||
|
r.entities[activeEntity].description
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="r.entities[activeEntity].identity_candidates?.length"
|
||||||
|
class="text-[10px] text-gray-600"
|
||||||
|
>
|
||||||
|
Identitete:
|
||||||
|
{{ r.entities[activeEntity].identity_candidates.join(", ") }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="activeEntity === 'email'"
|
||||||
|
><div class="text-[10px] text-gray-600">
|
||||||
|
Email:
|
||||||
|
<span class="font-medium">{{
|
||||||
|
referenceOf(activeEntity, r.entities[activeEntity])
|
||||||
|
}}</span>
|
||||||
|
</div></template
|
||||||
|
>
|
||||||
|
<template v-else-if="activeEntity === 'phone'"
|
||||||
|
><div class="text-[10px] text-gray-600">
|
||||||
|
Telefon:
|
||||||
|
<span class="font-medium">{{
|
||||||
|
referenceOf(activeEntity, r.entities[activeEntity])
|
||||||
|
}}</span>
|
||||||
|
</div></template
|
||||||
|
>
|
||||||
|
<template v-else-if="activeEntity === 'address'">
|
||||||
|
<div class="text-[10px] text-gray-600 space-y-0.5">
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
referenceOf(activeEntity, r.entities[activeEntity]) !== '—'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Ref:
|
||||||
|
<span class="font-medium">{{
|
||||||
|
referenceOf(activeEntity, r.entities[activeEntity])
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="r.entities[activeEntity].address">
|
||||||
|
Naslov:
|
||||||
|
<span class="font-medium">{{
|
||||||
|
r.entities[activeEntity].address
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
r.entities[activeEntity].postal_code ||
|
||||||
|
r.entities[activeEntity].country
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Lokacija:
|
||||||
|
<span class="font-medium">{{
|
||||||
|
[
|
||||||
|
r.entities[activeEntity].postal_code,
|
||||||
|
r.entities[activeEntity].country,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="activeEntity === 'client_case'">
|
||||||
|
<div class="text-[10px] text-gray-600 space-y-0.5">
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
referenceOf(activeEntity, r.entities[activeEntity]) !== '—'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Ref:
|
||||||
|
<span class="font-medium">{{
|
||||||
|
referenceOf(activeEntity, r.entities[activeEntity])
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="r.entities[activeEntity].title">
|
||||||
|
Naslov:
|
||||||
|
<span class="font-medium">{{
|
||||||
|
r.entities[activeEntity].title
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="r.entities[activeEntity].status">
|
||||||
|
Status:
|
||||||
|
<span class="font-medium">{{
|
||||||
|
r.entities[activeEntity].status
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<pre class="text-[10px] whitespace-pre-wrap">{{
|
||||||
|
r.entities[activeEntity]
|
||||||
|
}}</pre>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="p-2 border align-top text-[11px]">
|
||||||
|
<div v-if="r.entities.payment">
|
||||||
|
<div class="mb-1 font-semibold text-gray-700">Učinek plačila</div>
|
||||||
|
<div v-if="r.entities.account && r.entities.payment.amount !== null">
|
||||||
|
Saldo:
|
||||||
|
<span class="inline-flex items-center gap-1 font-medium">
|
||||||
|
<ArrowDownIcon
|
||||||
|
v-if="
|
||||||
|
r.entities.account.balance_after -
|
||||||
|
r.entities.account.balance_before <
|
||||||
|
0
|
||||||
|
"
|
||||||
|
class="h-3 w-3 text-emerald-500"
|
||||||
|
/>
|
||||||
|
<ArrowUpIcon
|
||||||
|
v-else-if="
|
||||||
|
r.entities.account.balance_after -
|
||||||
|
r.entities.account.balance_before >
|
||||||
|
0
|
||||||
|
"
|
||||||
|
class="h-3 w-3 text-red-500"
|
||||||
|
/>
|
||||||
|
<ArrowRightIcon v-else class="h-3 w-3 text-gray-400" />
|
||||||
|
<span
|
||||||
|
:class="
|
||||||
|
r.entities.account.balance_after -
|
||||||
|
r.entities.account.balance_before <
|
||||||
|
0
|
||||||
|
? 'text-emerald-600'
|
||||||
|
: r.entities.account.balance_after -
|
||||||
|
r.entities.account.balance_before >
|
||||||
|
0
|
||||||
|
? 'text-red-600'
|
||||||
|
: 'text-gray-700'
|
||||||
|
"
|
||||||
|
>{{
|
||||||
|
fmt(
|
||||||
|
r.entities.account.balance_after -
|
||||||
|
r.entities.account.balance_before
|
||||||
|
)
|
||||||
|
}}</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="r.entities.account && r.entities.account.delta !== undefined"
|
||||||
|
class="text-gray-500"
|
||||||
|
>
|
||||||
|
(pred {{ fmt(r.entities.account.balance_before) }} → po
|
||||||
|
{{ fmt(r.entities.account.balance_after) }})
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="verbose && r.entities.payment.sources"
|
||||||
|
class="mt-2 space-y-1"
|
||||||
|
>
|
||||||
|
<div class="font-semibold text-gray-600">Učinkoviti stolpci</div>
|
||||||
|
<table class="min-w-full border text-[10px] bg-white">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-gray-50">
|
||||||
|
<th class="px-1 py-0.5 border text-left">Tarča</th>
|
||||||
|
<th class="px-1 py-0.5 border text-left">Izvorni stolpec</th>
|
||||||
|
<th class="px-1 py-0.5 border text-left">Vrednost</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(src, key) in r.entities.payment.sources" :key="key">
|
||||||
|
<td class="px-1 py-0.5 border whitespace-nowrap">
|
||||||
|
{{ key }}
|
||||||
|
</td>
|
||||||
|
<td class="px-1 py-0.5 border">{{ src.source_column }}</td>
|
||||||
|
<td class="px-1 py-0.5 border">
|
||||||
|
<span v-if="key === 'payment.amount'"
|
||||||
|
>{{ src.value
|
||||||
|
}}<span
|
||||||
|
v-if="
|
||||||
|
src.normalized !== undefined &&
|
||||||
|
src.normalized !== src.value
|
||||||
|
"
|
||||||
|
class="text-gray-500"
|
||||||
|
>
|
||||||
|
→ {{ src.normalized }}</span
|
||||||
|
></span
|
||||||
|
><span v-else>{{ src.value ?? "—" }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="p-2 border text-[11px] align-top">
|
||||||
|
<div class="text-gray-400">—</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!loading && !visibleRows.length">
|
||||||
|
<td :colspan="4" class="p-4 text-center text-gray-500">
|
||||||
|
Ni simuliranih vrstic
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-[11px] text-gray-500">
|
||||||
|
Samo simulacija – podatki niso bili spremenjeni. Saldi predpostavljajo zaporedno
|
||||||
|
obdelavo plačil.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
159
resources/js/Pages/Imports/Partials/TemplateControls.vue
Normal file
159
resources/js/Pages/Imports/Partials/TemplateControls.vue
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
<script setup>
|
||||||
|
import Multiselect from "vue-multiselect";
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
isCompleted: Boolean,
|
||||||
|
hasHeader: Boolean,
|
||||||
|
delimiterState: Object,
|
||||||
|
selectedTemplateOption: Object,
|
||||||
|
filteredTemplates: Array,
|
||||||
|
templateApplied: Boolean,
|
||||||
|
form: Object, // reactive object reference from parent
|
||||||
|
});
|
||||||
|
const emits = defineEmits([
|
||||||
|
"update:hasHeader",
|
||||||
|
"update:delimiterMode",
|
||||||
|
"update:delimiterCustom",
|
||||||
|
"apply-template",
|
||||||
|
"preview",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function onHeaderChange(e) {
|
||||||
|
emits("update:hasHeader", e.target.value === "true");
|
||||||
|
}
|
||||||
|
function onDelimiterMode(e) {
|
||||||
|
emits("update:delimiterMode", e.target.value);
|
||||||
|
}
|
||||||
|
function onDelimiterCustom(e) {
|
||||||
|
emits("update:delimiterCustom", e.target.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proxy selected template object <-> form.import_template_id (which stores the id)
|
||||||
|
const selectedTemplateProxy = computed({
|
||||||
|
get() {
|
||||||
|
return props.selectedTemplateOption || null;
|
||||||
|
},
|
||||||
|
set(opt) {
|
||||||
|
props.form.import_template_id = opt ? opt.id : null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="block text-sm font-medium text-gray-700">Template</label>
|
||||||
|
<Multiselect
|
||||||
|
v-model="selectedTemplateProxy"
|
||||||
|
:options="filteredTemplates"
|
||||||
|
track-by="id"
|
||||||
|
label="name"
|
||||||
|
placeholder="Izberi predlogo..."
|
||||||
|
:searchable="true"
|
||||||
|
:allow-empty="true"
|
||||||
|
class="mt-1"
|
||||||
|
:custom-label="(o) => o.name"
|
||||||
|
:disabled="filteredTemplates?.length === 0"
|
||||||
|
:show-no-results="true"
|
||||||
|
:clear-on-select="false"
|
||||||
|
>
|
||||||
|
<template #option="{ option }">
|
||||||
|
<div class="flex items-center justify-between w-full">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span>{{ option.name }}</span>
|
||||||
|
<span v-if="option.source_type" class="ml-2 text-xs text-gray-500"
|
||||||
|
>({{ option.source_type }})</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<span class="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">{{
|
||||||
|
option.client_id ? "Client" : "Global"
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #singleLabel="{ option }">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span>{{ option.name }}</span>
|
||||||
|
<span v-if="option.source_type" class="ml-1 text-xs text-gray-500"
|
||||||
|
>({{ option.source_type }})</span
|
||||||
|
>
|
||||||
|
<span class="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">{{
|
||||||
|
option.client_id ? "Client" : "Global"
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #noResult>
|
||||||
|
<div class="px-2 py-1 text-xs text-gray-500">Ni predlog.</div>
|
||||||
|
</template>
|
||||||
|
</Multiselect>
|
||||||
|
<div v-if="isCompleted" class="mt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="$emit('preview')"
|
||||||
|
class="px-3 py-1.5 bg-indigo-600 text-white rounded text-sm hover:bg-indigo-500 w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
Ogled CSV
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="!isCompleted" class="flex flex-col gap-3">
|
||||||
|
<div class="flex flex-col sm:flex-row gap-3">
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="block text-xs font-medium text-gray-600">Header row</label>
|
||||||
|
<select
|
||||||
|
:value="hasHeader"
|
||||||
|
@change="onHeaderChange"
|
||||||
|
class="mt-1 block w-full border rounded p-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="true">Has header</option>
|
||||||
|
<option value="false">No header (positional)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="block text-xs font-medium text-gray-600">Delimiter</label>
|
||||||
|
<select
|
||||||
|
:value="delimiterState.mode"
|
||||||
|
@change="onDelimiterMode"
|
||||||
|
class="mt-1 block w-full border rounded p-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="auto">Auto-detect</option>
|
||||||
|
<option value="comma">Comma ,</option>
|
||||||
|
<option value="semicolon">Semicolon ;</option>
|
||||||
|
<option value="tab">Tab \t</option>
|
||||||
|
<option value="pipe">Pipe |</option>
|
||||||
|
<option value="space">Space ␠</option>
|
||||||
|
<option value="custom">Custom…</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="delimiterState.mode === 'custom'" class="flex items-end gap-3">
|
||||||
|
<div class="w-40">
|
||||||
|
<label class="block text-xs font-medium text-gray-600">Custom delimiter</label>
|
||||||
|
<input
|
||||||
|
:value="delimiterState.custom"
|
||||||
|
@input="onDelimiterCustom"
|
||||||
|
maxlength="4"
|
||||||
|
placeholder=","
|
||||||
|
class="mt-1 block w-full border rounded p-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
Template default: {{ selectedTemplateOption?.meta?.delimiter || "auto" }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p v-else class="text-xs text-gray-500">
|
||||||
|
Template default: {{ selectedTemplateOption?.meta?.delimiter || "auto" }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="!isCompleted"
|
||||||
|
class="px-3 py-1.5 bg-emerald-600 text-white rounded text-sm"
|
||||||
|
:disabled="!form.import_template_id"
|
||||||
|
@click="$emit('apply-template')"
|
||||||
|
>
|
||||||
|
{{ templateApplied ? "Ponovno uporabi predlogo" : "Uporabi predlogo" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
68
resources/js/Pages/Imports/useCurrencyFormat.js
Normal file
68
resources/js/Pages/Imports/useCurrencyFormat.js
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
// Generic currency formatting composable with fallback chain.
|
||||||
|
// Usage:
|
||||||
|
// const { formatMoney, currentCurrency } = useCurrencyFormat({
|
||||||
|
// primary: clientCurrency, // e.g. 'EUR'
|
||||||
|
// fallbacks: ['EUR'],
|
||||||
|
// locale: 'sl-SI'
|
||||||
|
// })
|
||||||
|
// formatMoney(123.45) -> '123,45 €'
|
||||||
|
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
|
export function useCurrencyFormat(options = {}) {
|
||||||
|
const {
|
||||||
|
primary = 'EUR',
|
||||||
|
fallbacks = ['EUR'],
|
||||||
|
locale = 'sl-SI',
|
||||||
|
minimumFractionDigits = 2,
|
||||||
|
maximumFractionDigits = 2,
|
||||||
|
} = options
|
||||||
|
|
||||||
|
const primaryCurrency = ref(primary)
|
||||||
|
const fallbackList = ref(Array.isArray(fallbacks) && fallbacks.length ? fallbacks : ['EUR'])
|
||||||
|
|
||||||
|
const currencyChain = computed(() => [primaryCurrency.value, ...fallbackList.value].filter(Boolean))
|
||||||
|
|
||||||
|
const formatterByCode = new Map()
|
||||||
|
function getFormatter(code) {
|
||||||
|
if (!code) return null
|
||||||
|
if (!formatterByCode.has(code)) {
|
||||||
|
try {
|
||||||
|
formatterByCode.set(
|
||||||
|
code,
|
||||||
|
new Intl.NumberFormat(locale, {
|
||||||
|
style: 'currency',
|
||||||
|
currency: code,
|
||||||
|
minimumFractionDigits,
|
||||||
|
maximumFractionDigits,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
} catch (e) {
|
||||||
|
// invalid currency code – skip
|
||||||
|
formatterByCode.set(code, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return formatterByCode.get(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeCurrency = computed(() => {
|
||||||
|
for (const c of currencyChain.value) {
|
||||||
|
if (getFormatter(c)) return c
|
||||||
|
}
|
||||||
|
return 'EUR'
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatMoney(val, overrideCurrency) {
|
||||||
|
if (val === null || val === undefined || val === '' || isNaN(val)) return '—'
|
||||||
|
const code = overrideCurrency || activeCurrency.value
|
||||||
|
const fmt = getFormatter(code) || getFormatter('EUR')
|
||||||
|
return fmt ? fmt.format(Number(val)) : Number(val).toFixed(2) + ' ' + code
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
formatMoney,
|
||||||
|
activeCurrency,
|
||||||
|
primaryCurrency,
|
||||||
|
setCurrency(code) { primaryCurrency.value = code },
|
||||||
|
}
|
||||||
|
}
|
||||||
14
resources/js/Pages/Imports/useEurFormat.js
Normal file
14
resources/js/Pages/Imports/useEurFormat.js
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
export function useEurFormat(locale = 'sl-SI', currency = 'EUR') {
|
||||||
|
const formatter = new Intl.NumberFormat(locale, {
|
||||||
|
style: 'currency',
|
||||||
|
currency,
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
function formatEur(val) {
|
||||||
|
if (val === null || val === undefined || val === '' || isNaN(val)) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
return formatter.format(Number(val));
|
||||||
|
}
|
||||||
|
return { formatEur };
|
||||||
|
}
|
||||||
|
|
@ -1,17 +1,19 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||||
import { Link, useForm } from '@inertiajs/vue3';
|
import { Link, useForm } from "@inertiajs/vue3";
|
||||||
import DialogModal from '@/Components/DialogModal.vue';
|
import DialogModal from "@/Components/DialogModal.vue";
|
||||||
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
import PrimaryButton from "@/Components/PrimaryButton.vue";
|
||||||
import InputLabel from '@/Components/InputLabel.vue';
|
import InputLabel from "@/Components/InputLabel.vue";
|
||||||
import InputError from '@/Components/InputError.vue';
|
import InputError from "@/Components/InputError.vue";
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, onMounted, watch } from "vue";
|
||||||
import Multiselect from 'vue-multiselect';
|
import Multiselect from "vue-multiselect";
|
||||||
|
import { de } from "date-fns/locale";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
settings: Array,
|
settings: Array,
|
||||||
segments: Array,
|
segments: Array,
|
||||||
decisions: Array,
|
decisions: Array,
|
||||||
|
actions: Array,
|
||||||
});
|
});
|
||||||
|
|
||||||
const showCreate = ref(false);
|
const showCreate = ref(false);
|
||||||
|
|
@ -19,10 +21,15 @@ const showEdit = ref(false);
|
||||||
const editingId = ref(null);
|
const editingId = ref(null);
|
||||||
const segmentOptions = ref([]);
|
const segmentOptions = ref([]);
|
||||||
const decisionOptions = ref([]);
|
const decisionOptions = ref([]);
|
||||||
|
const actionOptions = ref([]);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
segmentOptions.value = (props.segments || []).map(s => ({ id: s.id, name: s.name }));
|
segmentOptions.value = (props.segments || []).map((s) => ({ id: s.id, name: s.name }));
|
||||||
decisionOptions.value = (props.decisions || []).map(d => ({ id: d.id, name: d.name }));
|
decisionOptions.value = (props.decisions || []).map((d) => ({
|
||||||
|
id: d.id,
|
||||||
|
name: d.name,
|
||||||
|
}));
|
||||||
|
actionOptions.value = (props.actions || []).map((a) => ({ id: a.id, name: a.name }));
|
||||||
});
|
});
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
|
|
@ -33,6 +40,7 @@ const form = useForm({
|
||||||
cancel_decision_id: null,
|
cancel_decision_id: null,
|
||||||
return_segment_id: null,
|
return_segment_id: null,
|
||||||
queue_segment_id: null,
|
queue_segment_id: null,
|
||||||
|
action_id: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
|
|
@ -46,7 +54,7 @@ const closeCreate = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const store = () => {
|
const store = () => {
|
||||||
form.post(route('settings.fieldjob.store'), {
|
form.post(route("settings.fieldjob.store"), {
|
||||||
preserveScroll: true,
|
preserveScroll: true,
|
||||||
onSuccess: () => closeCreate(),
|
onSuccess: () => closeCreate(),
|
||||||
});
|
});
|
||||||
|
|
@ -60,17 +68,31 @@ const editForm = useForm({
|
||||||
cancel_decision_id: null,
|
cancel_decision_id: null,
|
||||||
return_segment_id: null,
|
return_segment_id: null,
|
||||||
queue_segment_id: null,
|
queue_segment_id: null,
|
||||||
|
action_id: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const openEdit = (row) => {
|
const openEdit = (row) => {
|
||||||
editingId.value = row.id;
|
editingId.value = row.id;
|
||||||
editForm.segment_id = row.segment_id ?? row.segment?.id ?? null;
|
editForm.segment_id = row.segment_id ?? row.segment?.id ?? null;
|
||||||
editForm.initial_decision_id = row.initial_decision_id ?? row.initial_decision?.id ?? row.initialDecision?.id ?? null;
|
editForm.initial_decision_id =
|
||||||
editForm.assign_decision_id = row.assign_decision_id ?? row.assign_decision?.id ?? row.assignDecision?.id ?? null;
|
row.initial_decision_id ??
|
||||||
editForm.complete_decision_id = row.complete_decision_id ?? row.complete_decision?.id ?? row.completeDecision?.id ?? null;
|
row.initial_decision?.id ??
|
||||||
editForm.cancel_decision_id = row.cancel_decision_id ?? row.cancel_decision?.id ?? row.cancelDecision?.id ?? null;
|
row.initialDecision?.id ??
|
||||||
editForm.return_segment_id = row.return_segment_id ?? row.return_segment?.id ?? row.returnSegment?.id ?? null;
|
null;
|
||||||
editForm.queue_segment_id = row.queue_segment_id ?? row.queue_segment?.id ?? row.queueSegment?.id ?? null;
|
editForm.assign_decision_id =
|
||||||
|
row.assign_decision_id ?? row.assign_decision?.id ?? row.assignDecision?.id ?? null;
|
||||||
|
editForm.complete_decision_id =
|
||||||
|
row.complete_decision_id ??
|
||||||
|
row.complete_decision?.id ??
|
||||||
|
row.completeDecision?.id ??
|
||||||
|
null;
|
||||||
|
editForm.cancel_decision_id =
|
||||||
|
row.cancel_decision_id ?? row.cancel_decision?.id ?? row.cancelDecision?.id ?? null;
|
||||||
|
editForm.return_segment_id =
|
||||||
|
row.return_segment_id ?? row.return_segment?.id ?? row.returnSegment?.id ?? null;
|
||||||
|
editForm.queue_segment_id =
|
||||||
|
row.queue_segment_id ?? row.queue_segment?.id ?? row.queueSegment?.id ?? null;
|
||||||
|
editForm.action_id = row.action_id ?? row.action?.id ?? null;
|
||||||
showEdit.value = true;
|
showEdit.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -85,11 +107,48 @@ const update = () => {
|
||||||
if (!editingId.value) {
|
if (!editingId.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
editForm.put(route('settings.fieldjob.update', { setting: editingId.value }), {
|
editForm.put(route("settings.fieldjob.update", { setting: editingId.value }), {
|
||||||
preserveScroll: true,
|
preserveScroll: true,
|
||||||
onSuccess: () => closeEdit(),
|
onSuccess: () => closeEdit(),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => editForm.action_id,
|
||||||
|
(newActionId) => {
|
||||||
|
// Clear decision fields when action changes
|
||||||
|
/*editForm.initial_decision_id = null;
|
||||||
|
editForm.assign_decision_id = null;
|
||||||
|
editForm.complete_decision_id = null;
|
||||||
|
editForm.cancel_decision_id = null;*/
|
||||||
|
if (newActionId !== null) {
|
||||||
|
// Optionally, you can filter decisionOptions based on the selected action here
|
||||||
|
decisionOptions.value = (props.decisions || [])
|
||||||
|
.filter((decision) => decision.actions?.some((a) => a.id === newActionId) ?? true)
|
||||||
|
.map((d) => ({ id: d.id, name: d.name }));
|
||||||
|
// For simplicity, we are not implementing that logic now
|
||||||
|
console.log(decisionOptions.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => form.action_id,
|
||||||
|
(newActionId) => {
|
||||||
|
// Clear decision fields when action changes
|
||||||
|
form.initial_decision_id = null;
|
||||||
|
form.assign_decision_id = null;
|
||||||
|
form.complete_decision_id = null;
|
||||||
|
if (newActionId !== null) {
|
||||||
|
// Optionally, you can filter decisionOptions based on the selected action here
|
||||||
|
decisionOptions.value = (props.decisions || [])
|
||||||
|
.filter((decision) => decision.actions?.some((a) => a.id === newActionId) ?? true)
|
||||||
|
.map((d) => ({ id: d.id, name: d.name }));
|
||||||
|
// For simplicity, we are not implementing that logic now
|
||||||
|
console.log(decisionOptions.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -104,9 +163,7 @@ const update = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogModal :show="showCreate" @close="closeCreate">
|
<DialogModal :show="showCreate" @close="closeCreate">
|
||||||
<template #title>
|
<template #title> Create Field Job Setting </template>
|
||||||
Create Field Job Setting
|
|
||||||
</template>
|
|
||||||
<template #content>
|
<template #content>
|
||||||
<form @submit.prevent="store">
|
<form @submit.prevent="store">
|
||||||
<div class="grid grid-cols-1 gap-4">
|
<div class="grid grid-cols-1 gap-4">
|
||||||
|
|
@ -115,27 +172,47 @@ const update = () => {
|
||||||
<multiselect
|
<multiselect
|
||||||
id="segment"
|
id="segment"
|
||||||
v-model="form.segment_id"
|
v-model="form.segment_id"
|
||||||
:options="segmentOptions.map(o=>o.id)"
|
:options="segmentOptions.map((o) => o.id)"
|
||||||
:multiple="false"
|
:multiple="false"
|
||||||
:searchable="true"
|
:searchable="true"
|
||||||
placeholder="Select segment"
|
placeholder="Select segment"
|
||||||
:append-to-body="true"
|
:append-to-body="true"
|
||||||
:custom-label="(opt) => (segmentOptions.find(o=>o.id===opt)?.name || '')"
|
:custom-label="
|
||||||
|
(opt) => segmentOptions.find((o) => o.id === opt)?.name || ''
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
<InputError :message="form.errors.segment_id" class="mt-1" />
|
<InputError :message="form.errors.segment_id" class="mt-1" />
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<InputLabel for="action" value="Action" />
|
||||||
|
<multiselect
|
||||||
|
id="action"
|
||||||
|
v-model="form.action_id"
|
||||||
|
:options="actionOptions.map((o) => o.id)"
|
||||||
|
:multiple="false"
|
||||||
|
:searchable="true"
|
||||||
|
placeholder="Select action"
|
||||||
|
:append-to-body="true"
|
||||||
|
:custom-label="
|
||||||
|
(opt) => actionOptions.find((o) => o.id === opt)?.name || ''
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<InputError :message="form.errors.action_id" class="mt-1" />
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<InputLabel for="initialDecision" value="Initial Decision" />
|
<InputLabel for="initialDecision" value="Initial Decision" />
|
||||||
<multiselect
|
<multiselect
|
||||||
id="initialDecision"
|
id="initialDecision"
|
||||||
v-model="form.initial_decision_id"
|
v-model="form.initial_decision_id"
|
||||||
:options="decisionOptions.map(o=>o.id)"
|
:options="decisionOptions.map((o) => o.id)"
|
||||||
:multiple="false"
|
:multiple="false"
|
||||||
:searchable="true"
|
:searchable="true"
|
||||||
placeholder="Select initial decision"
|
placeholder="Select initial decision"
|
||||||
:append-to-body="true"
|
:append-to-body="true"
|
||||||
:custom-label="(opt) => (decisionOptions.find(o=>o.id===opt)?.name || '')"
|
:disabled="!form.action_id"
|
||||||
|
:custom-label="
|
||||||
|
(opt) => decisionOptions.find((o) => o.id === opt)?.name || ''
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
<InputError :message="form.errors.initial_decision_id" class="mt-1" />
|
<InputError :message="form.errors.initial_decision_id" class="mt-1" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -145,12 +222,15 @@ const update = () => {
|
||||||
<multiselect
|
<multiselect
|
||||||
id="assignDecision"
|
id="assignDecision"
|
||||||
v-model="form.assign_decision_id"
|
v-model="form.assign_decision_id"
|
||||||
:options="decisionOptions.map(o=>o.id)"
|
:options="decisionOptions.map((o) => o.id)"
|
||||||
:multiple="false"
|
:multiple="false"
|
||||||
:searchable="true"
|
:searchable="true"
|
||||||
placeholder="Select assign decision"
|
placeholder="Select assign decision"
|
||||||
:append-to-body="true"
|
:append-to-body="true"
|
||||||
:custom-label="(opt) => (decisionOptions.find(o=>o.id===opt)?.name || '')"
|
:disabled="!form.action_id"
|
||||||
|
:custom-label="
|
||||||
|
(opt) => decisionOptions.find((o) => o.id === opt)?.name || ''
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
<InputError :message="form.errors.assign_decision_id" class="mt-1" />
|
<InputError :message="form.errors.assign_decision_id" class="mt-1" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -160,14 +240,20 @@ const update = () => {
|
||||||
<multiselect
|
<multiselect
|
||||||
id="completeDecision"
|
id="completeDecision"
|
||||||
v-model="form.complete_decision_id"
|
v-model="form.complete_decision_id"
|
||||||
:options="decisionOptions.map(o=>o.id)"
|
:options="decisionOptions.map((o) => o.id)"
|
||||||
:multiple="false"
|
:multiple="false"
|
||||||
:searchable="true"
|
:searchable="true"
|
||||||
placeholder="Select complete decision"
|
placeholder="Select complete decision"
|
||||||
:append-to-body="true"
|
:append-to-body="true"
|
||||||
:custom-label="(opt) => (decisionOptions.find(o=>o.id===opt)?.name || '')"
|
:disabled="!form.action_id"
|
||||||
|
:custom-label="
|
||||||
|
(opt) => decisionOptions.find((o) => o.id === opt)?.name || ''
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
:message="form.errors.complete_decision_id"
|
||||||
|
class="mt-1"
|
||||||
/>
|
/>
|
||||||
<InputError :message="form.errors.complete_decision_id" class="mt-1" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
|
|
@ -175,12 +261,15 @@ const update = () => {
|
||||||
<multiselect
|
<multiselect
|
||||||
id="cancelDecision"
|
id="cancelDecision"
|
||||||
v-model="form.cancel_decision_id"
|
v-model="form.cancel_decision_id"
|
||||||
:options="decisionOptions.map(o=>o.id)"
|
:options="decisionOptions.map((o) => o.id)"
|
||||||
:multiple="false"
|
:multiple="false"
|
||||||
:searchable="true"
|
:searchable="true"
|
||||||
placeholder="Select cancel decision (optional)"
|
placeholder="Select cancel decision (optional)"
|
||||||
:append-to-body="true"
|
:append-to-body="true"
|
||||||
:custom-label="(opt) => (decisionOptions.find(o=>o.id===opt)?.name || '')"
|
:disabled="!form.action_id"
|
||||||
|
:custom-label="
|
||||||
|
(opt) => decisionOptions.find((o) => o.id === opt)?.name || ''
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
<InputError :message="form.errors.cancel_decision_id" class="mt-1" />
|
<InputError :message="form.errors.cancel_decision_id" class="mt-1" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -190,12 +279,14 @@ const update = () => {
|
||||||
<multiselect
|
<multiselect
|
||||||
id="returnSegment"
|
id="returnSegment"
|
||||||
v-model="form.return_segment_id"
|
v-model="form.return_segment_id"
|
||||||
:options="segmentOptions.map(o=>o.id)"
|
:options="segmentOptions.map((o) => o.id)"
|
||||||
:multiple="false"
|
:multiple="false"
|
||||||
:searchable="true"
|
:searchable="true"
|
||||||
placeholder="Select return segment (optional)"
|
placeholder="Select return segment (optional)"
|
||||||
:append-to-body="true"
|
:append-to-body="true"
|
||||||
:custom-label="(opt) => (segmentOptions.find(o=>o.id===opt)?.name || '')"
|
:custom-label="
|
||||||
|
(opt) => segmentOptions.find((o) => o.id === opt)?.name || ''
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
<InputError :message="form.errors.return_segment_id" class="mt-1" />
|
<InputError :message="form.errors.return_segment_id" class="mt-1" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -205,28 +296,34 @@ const update = () => {
|
||||||
<multiselect
|
<multiselect
|
||||||
id="queueSegment"
|
id="queueSegment"
|
||||||
v-model="form.queue_segment_id"
|
v-model="form.queue_segment_id"
|
||||||
:options="segmentOptions.map(o=>o.id)"
|
:options="segmentOptions.map((o) => o.id)"
|
||||||
:multiple="false"
|
:multiple="false"
|
||||||
:searchable="true"
|
:searchable="true"
|
||||||
placeholder="Select queue segment (optional)"
|
placeholder="Select queue segment (optional)"
|
||||||
:append-to-body="true"
|
:append-to-body="true"
|
||||||
:custom-label="(opt) => (segmentOptions.find(o=>o.id===opt)?.name || '')"
|
:custom-label="
|
||||||
|
(opt) => segmentOptions.find((o) => o.id === opt)?.name || ''
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
<InputError :message="form.errors.queue_segment_id" class="mt-1" />
|
<InputError :message="form.errors.queue_segment_id" class="mt-1" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end gap-2 mt-6">
|
<div class="flex justify-end gap-2 mt-6">
|
||||||
<button type="button" @click="closeCreate" class="px-4 py-2 rounded bg-gray-200 hover:bg-gray-300">Cancel</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="closeCreate"
|
||||||
|
class="px-4 py-2 rounded bg-gray-200 hover:bg-gray-300"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
<PrimaryButton :disabled="form.processing">Create</PrimaryButton>
|
<PrimaryButton :disabled="form.processing">Create</PrimaryButton>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
</DialogModal>
|
</DialogModal>
|
||||||
<DialogModal :show="showEdit" @close="closeEdit">
|
<DialogModal :show="showEdit" @close="closeEdit">
|
||||||
<template #title>
|
<template #title> Edit Field Job Setting </template>
|
||||||
Edit Field Job Setting
|
|
||||||
</template>
|
|
||||||
<template #content>
|
<template #content>
|
||||||
<form @submit.prevent="update">
|
<form @submit.prevent="update">
|
||||||
<div class="grid grid-cols-1 gap-4">
|
<div class="grid grid-cols-1 gap-4">
|
||||||
|
|
@ -235,29 +332,52 @@ const update = () => {
|
||||||
<multiselect
|
<multiselect
|
||||||
id="edit-segment"
|
id="edit-segment"
|
||||||
v-model="editForm.segment_id"
|
v-model="editForm.segment_id"
|
||||||
:options="segmentOptions.map(o=>o.id)"
|
:options="segmentOptions.map((o) => o.id)"
|
||||||
:multiple="false"
|
:multiple="false"
|
||||||
:searchable="true"
|
:searchable="true"
|
||||||
placeholder="Select segment"
|
placeholder="Select segment"
|
||||||
:append-to-body="true"
|
:append-to-body="true"
|
||||||
:custom-label="(opt) => (segmentOptions.find(o=>o.id===opt)?.name || '')"
|
:custom-label="
|
||||||
|
(opt) => segmentOptions.find((o) => o.id === opt)?.name || ''
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
<InputError :message="editForm.errors.segment_id" class="mt-1" />
|
<InputError :message="editForm.errors.segment_id" class="mt-1" />
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<InputLabel for="edit-action" value="Action" />
|
||||||
|
<multiselect
|
||||||
|
id="edit-action"
|
||||||
|
v-model="editForm.action_id"
|
||||||
|
:options="actionOptions.map((o) => o.id)"
|
||||||
|
:multiple="false"
|
||||||
|
:searchable="true"
|
||||||
|
placeholder="Select action"
|
||||||
|
:append-to-body="true"
|
||||||
|
:custom-label="
|
||||||
|
(opt) => actionOptions.find((o) => o.id === opt)?.name || ''
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<InputError :message="editForm.errors.action_id" class="mt-1" />
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<InputLabel for="edit-initialDecision" value="Initial Decision" />
|
<InputLabel for="edit-initialDecision" value="Initial Decision" />
|
||||||
<multiselect
|
<multiselect
|
||||||
id="edit-initialDecision"
|
id="edit-initialDecision"
|
||||||
v-model="editForm.initial_decision_id"
|
v-model="editForm.initial_decision_id"
|
||||||
:options="decisionOptions.map(o=>o.id)"
|
:options="decisionOptions.map((o) => o.id)"
|
||||||
:multiple="false"
|
:multiple="false"
|
||||||
:searchable="true"
|
:searchable="true"
|
||||||
placeholder="Select initial decision"
|
placeholder="Select initial decision"
|
||||||
:append-to-body="true"
|
:append-to-body="true"
|
||||||
:custom-label="(opt) => (decisionOptions.find(o=>o.id===opt)?.name || '')"
|
:disabled="!editForm.action_id"
|
||||||
|
:custom-label="
|
||||||
|
(opt) => decisionOptions.find((o) => o.id === opt)?.name || ''
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
:message="editForm.errors.initial_decision_id"
|
||||||
|
class="mt-1"
|
||||||
/>
|
/>
|
||||||
<InputError :message="editForm.errors.initial_decision_id" class="mt-1" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -265,14 +385,20 @@ const update = () => {
|
||||||
<multiselect
|
<multiselect
|
||||||
id="edit-assignDecision"
|
id="edit-assignDecision"
|
||||||
v-model="editForm.assign_decision_id"
|
v-model="editForm.assign_decision_id"
|
||||||
:options="decisionOptions.map(o=>o.id)"
|
:options="decisionOptions.map((o) => o.id)"
|
||||||
:multiple="false"
|
:multiple="false"
|
||||||
:searchable="true"
|
:searchable="true"
|
||||||
placeholder="Select assign decision"
|
placeholder="Select assign decision"
|
||||||
:append-to-body="true"
|
:append-to-body="true"
|
||||||
:custom-label="(opt) => (decisionOptions.find(o=>o.id===opt)?.name || '')"
|
:disabled="!editForm.action_id"
|
||||||
|
:custom-label="
|
||||||
|
(opt) => decisionOptions.find((o) => o.id === opt)?.name || ''
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
:message="editForm.errors.assign_decision_id"
|
||||||
|
class="mt-1"
|
||||||
/>
|
/>
|
||||||
<InputError :message="editForm.errors.assign_decision_id" class="mt-1" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
|
|
@ -280,14 +406,20 @@ const update = () => {
|
||||||
<multiselect
|
<multiselect
|
||||||
id="edit-completeDecision"
|
id="edit-completeDecision"
|
||||||
v-model="editForm.complete_decision_id"
|
v-model="editForm.complete_decision_id"
|
||||||
:options="decisionOptions.map(o=>o.id)"
|
:options="decisionOptions.map((o) => o.id)"
|
||||||
:multiple="false"
|
:multiple="false"
|
||||||
:searchable="true"
|
:searchable="true"
|
||||||
placeholder="Select complete decision"
|
placeholder="Select complete decision"
|
||||||
|
:disabled="!editForm.action_id"
|
||||||
:append-to-body="true"
|
:append-to-body="true"
|
||||||
:custom-label="(opt) => (decisionOptions.find(o=>o.id===opt)?.name || '')"
|
:custom-label="
|
||||||
|
(opt) => decisionOptions.find((o) => o.id === opt)?.name || ''
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
:message="editForm.errors.complete_decision_id"
|
||||||
|
class="mt-1"
|
||||||
/>
|
/>
|
||||||
<InputError :message="editForm.errors.complete_decision_id" class="mt-1" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
|
|
@ -295,14 +427,20 @@ const update = () => {
|
||||||
<multiselect
|
<multiselect
|
||||||
id="edit-cancelDecision"
|
id="edit-cancelDecision"
|
||||||
v-model="editForm.cancel_decision_id"
|
v-model="editForm.cancel_decision_id"
|
||||||
:options="decisionOptions.map(o=>o.id)"
|
:options="decisionOptions.map((o) => o.id)"
|
||||||
:multiple="false"
|
:multiple="false"
|
||||||
:searchable="true"
|
:searchable="true"
|
||||||
placeholder="Select cancel decision (optional)"
|
placeholder="Select cancel decision (optional)"
|
||||||
:append-to-body="true"
|
:append-to-body="true"
|
||||||
:custom-label="(opt) => (decisionOptions.find(o=>o.id===opt)?.name || '')"
|
:disabled="!editForm.action_id"
|
||||||
|
:custom-label="
|
||||||
|
(opt) => decisionOptions.find((o) => o.id === opt)?.name || ''
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
:message="editForm.errors.cancel_decision_id"
|
||||||
|
class="mt-1"
|
||||||
/>
|
/>
|
||||||
<InputError :message="editForm.errors.cancel_decision_id" class="mt-1" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
|
|
@ -310,14 +448,19 @@ const update = () => {
|
||||||
<multiselect
|
<multiselect
|
||||||
id="edit-returnSegment"
|
id="edit-returnSegment"
|
||||||
v-model="editForm.return_segment_id"
|
v-model="editForm.return_segment_id"
|
||||||
:options="segmentOptions.map(o=>o.id)"
|
:options="segmentOptions.map((o) => o.id)"
|
||||||
:multiple="false"
|
:multiple="false"
|
||||||
:searchable="true"
|
:searchable="true"
|
||||||
placeholder="Select return segment (optional)"
|
placeholder="Select return segment (optional)"
|
||||||
:append-to-body="true"
|
:append-to-body="true"
|
||||||
:custom-label="(opt) => (segmentOptions.find(o=>o.id===opt)?.name || '')"
|
:custom-label="
|
||||||
|
(opt) => segmentOptions.find((o) => o.id === opt)?.name || ''
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
:message="editForm.errors.return_segment_id"
|
||||||
|
class="mt-1"
|
||||||
/>
|
/>
|
||||||
<InputError :message="editForm.errors.return_segment_id" class="mt-1" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
|
|
@ -325,19 +468,30 @@ const update = () => {
|
||||||
<multiselect
|
<multiselect
|
||||||
id="edit-queueSegment"
|
id="edit-queueSegment"
|
||||||
v-model="editForm.queue_segment_id"
|
v-model="editForm.queue_segment_id"
|
||||||
:options="segmentOptions.map(o=>o.id)"
|
:options="segmentOptions.map((o) => o.id)"
|
||||||
:multiple="false"
|
:multiple="false"
|
||||||
:searchable="true"
|
:searchable="true"
|
||||||
placeholder="Select queue segment (optional)"
|
placeholder="Select queue segment (optional)"
|
||||||
:append-to-body="true"
|
:append-to-body="true"
|
||||||
:custom-label="(opt) => (segmentOptions.find(o=>o.id===opt)?.name || '')"
|
:custom-label="
|
||||||
|
(opt) => segmentOptions.find((o) => o.id === opt)?.name || ''
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
:message="editForm.errors.queue_segment_id"
|
||||||
|
class="mt-1"
|
||||||
/>
|
/>
|
||||||
<InputError :message="editForm.errors.queue_segment_id" class="mt-1" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end gap-2 mt-6">
|
<div class="flex justify-end gap-2 mt-6">
|
||||||
<button type="button" @click="closeEdit" class="px-4 py-2 rounded bg-gray-200 hover:bg-gray-300">Cancel</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="closeEdit"
|
||||||
|
class="px-4 py-2 rounded bg-gray-200 hover:bg-gray-300"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
<PrimaryButton :disabled="editForm.processing">Save</PrimaryButton>
|
<PrimaryButton :disabled="editForm.processing">Save</PrimaryButton>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -348,6 +502,7 @@ const update = () => {
|
||||||
<tr class="border-b">
|
<tr class="border-b">
|
||||||
<th class="py-2 pr-4">ID</th>
|
<th class="py-2 pr-4">ID</th>
|
||||||
<th class="py-2 pr-4">Segment</th>
|
<th class="py-2 pr-4">Segment</th>
|
||||||
|
<th class="py-2 pr-4">Action</th>
|
||||||
<th class="py-2 pr-4">Initial Decision</th>
|
<th class="py-2 pr-4">Initial Decision</th>
|
||||||
<th class="py-2 pr-4">Assign Decision</th>
|
<th class="py-2 pr-4">Assign Decision</th>
|
||||||
<th class="py-2 pr-4">Complete Decision</th>
|
<th class="py-2 pr-4">Complete Decision</th>
|
||||||
|
|
@ -361,14 +516,30 @@ const update = () => {
|
||||||
<tr v-for="row in settings" :key="row.id" class="border-b last:border-0">
|
<tr v-for="row in settings" :key="row.id" class="border-b last:border-0">
|
||||||
<td class="py-2 pr-4">{{ row.id }}</td>
|
<td class="py-2 pr-4">{{ row.id }}</td>
|
||||||
<td class="py-2 pr-4">{{ row.segment?.name }}</td>
|
<td class="py-2 pr-4">{{ row.segment?.name }}</td>
|
||||||
<td class="py-2 pr-4">{{ row.initial_decision?.name || row.initialDecision?.name }}</td>
|
<td class="py-2 pr-4">{{ row.action?.name }}</td>
|
||||||
<td class="py-2 pr-4">{{ row.assign_decision?.name || row.assignDecision?.name }}</td>
|
|
||||||
<td class="py-2 pr-4">{{ row.complete_decision?.name || row.completeDecision?.name }}</td>
|
|
||||||
<td class="py-2 pr-4">{{ row.cancel_decision?.name || row.cancelDecision?.name }}</td>
|
|
||||||
<td class="py-2 pr-4">{{ row.return_segment?.name || row.returnSegment?.name }}</td>
|
|
||||||
<td class="py-2 pr-4">{{ row.queue_segment?.name || row.queueSegment?.name }}</td>
|
|
||||||
<td class="py-2 pr-4">
|
<td class="py-2 pr-4">
|
||||||
<button @click="openEdit(row)" class="px-3 py-1 rounded bg-indigo-600 text-white hover:bg-indigo-700">
|
{{ row.initial_decision?.name || row.initialDecision?.name }}
|
||||||
|
</td>
|
||||||
|
<td class="py-2 pr-4">
|
||||||
|
{{ row.assign_decision?.name || row.assignDecision?.name }}
|
||||||
|
</td>
|
||||||
|
<td class="py-2 pr-4">
|
||||||
|
{{ row.complete_decision?.name || row.completeDecision?.name }}
|
||||||
|
</td>
|
||||||
|
<td class="py-2 pr-4">
|
||||||
|
{{ row.cancel_decision?.name || row.cancelDecision?.name }}
|
||||||
|
</td>
|
||||||
|
<td class="py-2 pr-4">
|
||||||
|
{{ row.return_segment?.name || row.returnSegment?.name }}
|
||||||
|
</td>
|
||||||
|
<td class="py-2 pr-4">
|
||||||
|
{{ row.queue_segment?.name || row.queueSegment?.name }}
|
||||||
|
</td>
|
||||||
|
<td class="py-2 pr-4">
|
||||||
|
<button
|
||||||
|
@click="openEdit(row)"
|
||||||
|
class="px-3 py-1 rounded bg-indigo-600 text-white hover:bg-indigo-700"
|
||||||
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
||||||
|
|
@ -167,9 +167,11 @@
|
||||||
Route::post('client-cases/{client_case:uuid}/documents', [ClientCaseContoller::class, 'storeDocument'])->name('clientCase.document.store');
|
Route::post('client-cases/{client_case:uuid}/documents', [ClientCaseContoller::class, 'storeDocument'])->name('clientCase.document.store');
|
||||||
Route::get('client-cases/{client_case:uuid}/documents/{document:uuid}/view', [ClientCaseContoller::class, 'viewDocument'])->name('clientCase.document.view');
|
Route::get('client-cases/{client_case:uuid}/documents/{document:uuid}/view', [ClientCaseContoller::class, 'viewDocument'])->name('clientCase.document.view');
|
||||||
Route::get('client-cases/{client_case:uuid}/documents/{document:uuid}/download', [ClientCaseContoller::class, 'downloadDocument'])->name('clientCase.document.download');
|
Route::get('client-cases/{client_case:uuid}/documents/{document:uuid}/download', [ClientCaseContoller::class, 'downloadDocument'])->name('clientCase.document.download');
|
||||||
|
Route::delete('client-cases/{client_case:uuid}/documents/{document:uuid}', [ClientCaseContoller::class, 'deleteDocument'])->name('clientCase.document.delete');
|
||||||
// contract / documents (direct access by contract)
|
// contract / documents (direct access by contract)
|
||||||
Route::get('contracts/{contract:uuid}/documents/{document:uuid}/view', [ClientCaseContoller::class, 'viewContractDocument'])->name('contract.document.view');
|
Route::get('contracts/{contract:uuid}/documents/{document:uuid}/view', [ClientCaseContoller::class, 'viewContractDocument'])->name('contract.document.view');
|
||||||
Route::get('contracts/{contract:uuid}/documents/{document:uuid}/download', [ClientCaseContoller::class, 'downloadContractDocument'])->name('contract.document.download');
|
Route::get('contracts/{contract:uuid}/documents/{document:uuid}/download', [ClientCaseContoller::class, 'downloadContractDocument'])->name('contract.document.download');
|
||||||
|
Route::delete('contracts/{contract:uuid}/documents/{document:uuid}', [ClientCaseContoller::class, 'deleteContractDocument'])->name('contract.document.delete');
|
||||||
// settings
|
// settings
|
||||||
Route::get('settings', [SettingController::class, 'index'])->name('settings');
|
Route::get('settings', [SettingController::class, 'index'])->name('settings');
|
||||||
Route::get('settings/segments', [SegmentController::class, 'settings'])->name('settings.segments');
|
Route::get('settings/segments', [SegmentController::class, 'settings'])->name('settings.segments');
|
||||||
|
|
@ -210,6 +212,11 @@
|
||||||
Route::post('imports/{import}/mappings', [ImportController::class, 'saveMappings'])->name('imports.mappings.save');
|
Route::post('imports/{import}/mappings', [ImportController::class, 'saveMappings'])->name('imports.mappings.save');
|
||||||
Route::get('imports/{import}/mappings', [ImportController::class, 'getMappings'])->name('imports.mappings.get');
|
Route::get('imports/{import}/mappings', [ImportController::class, 'getMappings'])->name('imports.mappings.get');
|
||||||
Route::get('imports/{import}/events', [ImportController::class, 'getEvents'])->name('imports.events');
|
Route::get('imports/{import}/events', [ImportController::class, 'getEvents'])->name('imports.events');
|
||||||
|
Route::get('imports/{import}/preview', [ImportController::class, 'preview'])->name('imports.preview');
|
||||||
|
// Generic simulation endpoint (new) – provides projected effects for first N rows regardless of payments template
|
||||||
|
Route::get('imports/{import}/simulate', [ImportController::class, 'simulate'])->name('imports.simulate');
|
||||||
|
// Backwards compatible payments simulation route (legacy name) – now proxies to generic simulate method
|
||||||
|
Route::get('imports/{import}/simulate-payments', [ImportController::class, 'simulatePayments'])->name('imports.simulatePayments');
|
||||||
|
|
||||||
// import templates
|
// import templates
|
||||||
Route::get('import-templates', [ImportTemplateController::class, 'index'])->name('importTemplates.index');
|
Route::get('import-templates', [ImportTemplateController::class, 'index'])->name('importTemplates.index');
|
||||||
|
|
@ -224,6 +231,8 @@
|
||||||
Route::delete('import-templates/{template:uuid}/mappings/{mapping}', [ImportTemplateController::class, 'deleteMapping'])->name('importTemplates.mappings.delete');
|
Route::delete('import-templates/{template:uuid}/mappings/{mapping}', [ImportTemplateController::class, 'deleteMapping'])->name('importTemplates.mappings.delete');
|
||||||
Route::post('import-templates/{template:uuid}/mappings/reorder', [ImportTemplateController::class, 'reorderMappings'])->name('importTemplates.mappings.reorder');
|
Route::post('import-templates/{template:uuid}/mappings/reorder', [ImportTemplateController::class, 'reorderMappings'])->name('importTemplates.mappings.reorder');
|
||||||
Route::post('import-templates/{template}/apply/{import}', [ImportTemplateController::class, 'applyToImport'])->name('importTemplates.apply');
|
Route::post('import-templates/{template}/apply/{import}', [ImportTemplateController::class, 'applyToImport'])->name('importTemplates.apply');
|
||||||
|
// Delete an unfinished import
|
||||||
|
Route::delete('imports/{import}', [ImportController::class, 'destroy'])->name('imports.destroy');
|
||||||
// Route::put()
|
// Route::put()
|
||||||
// types
|
// types
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
uses(Tests\TestCase::class, RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
it('returns client cases when searching by contract reference', function () {
|
it('returns client cases when searching by contract reference', function () {
|
||||||
// Arrange: create a user and authenticate
|
// Arrange: create a user and authenticate
|
||||||
|
|
|
||||||
64
tests/Feature/ImportSimulationContractChainTest.php
Normal file
64
tests/Feature/ImportSimulationContractChainTest.php
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Account;
|
||||||
|
use App\Models\ClientCase;
|
||||||
|
use App\Models\Contract;
|
||||||
|
use App\Models\Email;
|
||||||
|
use App\Models\Import;
|
||||||
|
use App\Models\Person\AddressType;
|
||||||
|
use App\Models\Person\Person;
|
||||||
|
use App\Models\Person\PersonAddress;
|
||||||
|
use App\Models\Person\PersonPhone;
|
||||||
|
use App\Models\Person\PhoneType;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
it('attaches chain entities (client_case, person, email, phone, address) for existing contract', function () {
|
||||||
|
Storage::fake('local');
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
\Illuminate\Support\Facades\Auth::login($user);
|
||||||
|
$person = Person::factory()->create(['user_id' => $user->id]);
|
||||||
|
$clientCase = ClientCase::factory()->create(['person_id' => $person->id, 'client_ref' => 'CC-1']);
|
||||||
|
$contract = Contract::factory()->create(['reference' => 'C-CHAIN', 'client_case_id' => $clientCase->id]);
|
||||||
|
$typeId = DB::table('account_types')->insertGetId(['name' => 'Current', 'description' => 'Test', 'created_at' => now(), 'updated_at' => now()]);
|
||||||
|
Account::create(['reference' => 'AC-1', 'contract_id' => $contract->id, 'type_id' => $typeId]);
|
||||||
|
Email::factory()->create(['person_id' => $person->id, 'value' => 'chain@example.com']);
|
||||||
|
$phoneType = PhoneType::factory()->create();
|
||||||
|
$addressType = AddressType::factory()->create();
|
||||||
|
PersonPhone::factory()->create(['person_id' => $person->id, 'nu' => '123456', 'type_id' => $phoneType->id]);
|
||||||
|
PersonAddress::factory()->create(['person_id' => $person->id, 'address' => 'Main St 1', 'country' => 'SI', 'type_id' => $addressType->id]);
|
||||||
|
|
||||||
|
$csv = "contract_ref\nC-CHAIN";
|
||||||
|
Storage::disk('local')->put('imports/chain.csv', $csv);
|
||||||
|
|
||||||
|
$import = Import::factory()->create([
|
||||||
|
'disk' => 'local',
|
||||||
|
'path' => 'imports/chain.csv',
|
||||||
|
'meta' => ['has_header' => true, 'forced_delimiter' => ','],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Map only contract.reference (others should be derived via chain)
|
||||||
|
DB::table('import_mappings')->insert([
|
||||||
|
['import_id' => $import->id, 'source_column' => 'contract_ref', 'target_field' => 'contract.reference', 'position' => 1],
|
||||||
|
// declare roots so they appear in simulation (simulate mapping presence with dummy value fields referencing same column) optional
|
||||||
|
['import_id' => $import->id, 'source_column' => 'contract_ref', 'target_field' => 'person.first_name', 'position' => 2],
|
||||||
|
['import_id' => $import->id, 'source_column' => 'contract_ref', 'target_field' => 'client_case.title', 'position' => 3],
|
||||||
|
['import_id' => $import->id, 'source_column' => 'contract_ref', 'target_field' => 'email.value', 'position' => 4],
|
||||||
|
['import_id' => $import->id, 'source_column' => 'contract_ref', 'target_field' => 'phone.nu', 'position' => 5],
|
||||||
|
['import_id' => $import->id, 'source_column' => 'contract_ref', 'target_field' => 'address.address', 'position' => 6],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service = app(\App\Services\ImportSimulationService::class);
|
||||||
|
$result = $service->simulate($import, 10, false);
|
||||||
|
|
||||||
|
$row = $result['rows'][0]['entities'];
|
||||||
|
|
||||||
|
expect($row['contract']['action'])->toBe('update');
|
||||||
|
expect($row['client_case']['existing_chain'] ?? false)->toBeTrue();
|
||||||
|
expect($row['person']['existing_chain'] ?? false)->toBeTrue();
|
||||||
|
expect($row['email']['existing_chain'] ?? false)->toBeTrue();
|
||||||
|
expect($row['phone']['existing_chain'] ?? false)->toBeTrue();
|
||||||
|
expect($row['address']['existing_chain'] ?? false)->toBeTrue();
|
||||||
|
});
|
||||||
59
tests/Feature/ImportSimulationGenericTest.php
Normal file
59
tests/Feature/ImportSimulationGenericTest.php
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Email;
|
||||||
|
use App\Models\Import;
|
||||||
|
use App\Models\ImportEntity;
|
||||||
|
use App\Models\Person\Person;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
it('simulates generic entities with deduplication and filtering', function () {
|
||||||
|
Storage::fake('local');
|
||||||
|
// Ensure import_entities seeds (or minimal row) exist
|
||||||
|
ImportEntity::query()->firstOrCreate(['key' => 'emails'], [
|
||||||
|
'canonical_root' => 'email',
|
||||||
|
'label' => 'Emails',
|
||||||
|
'fields' => ['value'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Existing email to trigger duplicate_db (attach minimal person)
|
||||||
|
$person = Person::factory()->create();
|
||||||
|
Email::create(['value' => 'test@example.com', 'person_id' => $person->id]);
|
||||||
|
|
||||||
|
$csv = "email\nTest@example.com"; // will normalize to same identity
|
||||||
|
Storage::disk('local')->put('imports/sample.csv', $csv);
|
||||||
|
|
||||||
|
$import = Import::create([
|
||||||
|
'uuid' => (string) Str::uuid(),
|
||||||
|
'user_id' => User::factory()->create()->id,
|
||||||
|
'import_template_id' => null,
|
||||||
|
'client_id' => null,
|
||||||
|
'source_type' => 'csv',
|
||||||
|
'file_name' => 'sample.csv',
|
||||||
|
'original_name' => 'sample.csv',
|
||||||
|
'disk' => 'local',
|
||||||
|
'path' => 'imports/sample.csv',
|
||||||
|
'size' => strlen($csv),
|
||||||
|
'status' => 'uploaded',
|
||||||
|
'meta' => [
|
||||||
|
'has_header' => true,
|
||||||
|
'columns' => ['email'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Mapping: email.value -> column email
|
||||||
|
\DB::table('import_mappings')->insert([
|
||||||
|
'import_id' => $import->id,
|
||||||
|
'source_column' => 'email',
|
||||||
|
'target_field' => 'email.value',
|
||||||
|
'position' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service = app(\App\Services\ImportSimulationService::class);
|
||||||
|
$result = $service->simulate($import, 10, false);
|
||||||
|
|
||||||
|
expect($result['entities'])->toContain('email');
|
||||||
|
expect($result['summaries']['email']['total_rows'])->toBe(1);
|
||||||
|
// Because existing duplicate in DB
|
||||||
|
expect($result['summaries']['email']['duplicate_db'] ?? 0)->toBe(1);
|
||||||
|
});
|
||||||
60
tests/Feature/ImportSimulationMultiRootsTest.php
Normal file
60
tests/Feature/ImportSimulationMultiRootsTest.php
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Account;
|
||||||
|
use App\Models\Contract;
|
||||||
|
use App\Models\Email;
|
||||||
|
use App\Models\Import;
|
||||||
|
use App\Models\Person\Person;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('simulates multiple roots with duplicate flags', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
Auth::login($user);
|
||||||
|
|
||||||
|
$contract = Contract::factory()->create(['reference' => 'C-1']);
|
||||||
|
$typeId = DB::table('account_types')->insertGetId(['name' => 'Current', 'description' => 'Test', 'created_at' => now(), 'updated_at' => now()]);
|
||||||
|
$account = Account::create(['reference' => 'A-1', 'contract_id' => $contract->id, 'type_id' => $typeId]);
|
||||||
|
|
||||||
|
Email::factory()->create(['value' => 'test@example.com', 'person_id' => Person::factory()->create()->id]);
|
||||||
|
|
||||||
|
$csv = implode("\n", [
|
||||||
|
'contract_ref,account_ref,payment_ref,email_value,payment_amount',
|
||||||
|
'C-1,A-1,P-1,test@example.com,10',
|
||||||
|
'C-1,A-1,P-1,NEW@example.com,10',
|
||||||
|
'C-1,A-1,P-2,test@example.com,15',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Storage::fake('local');
|
||||||
|
Storage::disk('local')->put('imports/test.csv', $csv);
|
||||||
|
|
||||||
|
$import = Import::factory()->create([
|
||||||
|
'disk' => 'local',
|
||||||
|
'path' => 'imports/test.csv',
|
||||||
|
'meta' => [
|
||||||
|
'has_header' => true,
|
||||||
|
'forced_delimiter' => ',',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('import_mappings')->insert([
|
||||||
|
['import_id' => $import->id, 'source_column' => 'contract_ref', 'target_field' => 'contract.reference', 'position' => 1],
|
||||||
|
['import_id' => $import->id, 'source_column' => 'account_ref', 'target_field' => 'account.reference', 'position' => 2],
|
||||||
|
['import_id' => $import->id, 'source_column' => 'payment_ref', 'target_field' => 'payment.reference', 'position' => 3],
|
||||||
|
['import_id' => $import->id, 'source_column' => 'email_value', 'target_field' => 'email.value', 'position' => 4],
|
||||||
|
['import_id' => $import->id, 'source_column' => 'payment_amount', 'target_field' => 'payment.amount', 'position' => 5],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service = app(\App\Services\ImportSimulationService::class);
|
||||||
|
$result = $service->simulate($import, 100, false);
|
||||||
|
|
||||||
|
expect($result['entities'])->toContain('contract', 'account', 'payment', 'email');
|
||||||
|
expect($result['rows'][0]['entities']['email']['duplicate_db'] ?? false)->toBeTrue();
|
||||||
|
expect($result['rows'][1]['entities']['payment']['status'])->toBe('duplicate');
|
||||||
|
expect($result['rows'][2]['entities']['payment']['status'])->toBe('ok');
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user