Mager updated

This commit is contained in:
Simon Pocrnjič 2025-09-27 17:45:55 +02:00
parent d17e34941b
commit 7227c888d4
74 changed files with 6339 additions and 342 deletions

View File

@ -3,7 +3,7 @@ APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_TIMEZONE=UTC
APP_URL=http://localhost
APP_URL=http://localhost:8001
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
@ -62,3 +62,14 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
# LibreOffice binary path for document previews (set full path on Windows)
# Example (Windows): C:\\Program Files\\LibreOffice\\program\\soffice.exe
# Example (Linux): soffice
LIBREOFFICE_BIN=soffice
# Storage configuration for generated previews
FILES_PREVIEW_DISK=public
FILES_PREVIEW_BASE=previews/cases
FILES_ENABLE_PREVIEW_PRUNE=true
FILES_PREVIEW_RETENTION_DAYS=90

View File

@ -0,0 +1,64 @@
<?php
namespace App\Console\Commands;
use App\Models\Document;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
class PruneDocumentPreviews extends Command
{
protected $signature = 'documents:prune-previews {--days=90 : Delete previews older than this many days} {--dry-run : Show what would be deleted without deleting}';
protected $description = 'Deletes generated document preview files older than N days and clears their metadata.';
public function handle(): int
{
$days = (int) $this->option('days');
if ($days < 1) { $days = 90; }
$cutoff = Carbon::now()->subDays($days);
$previewDisk = config('files.preview_disk', 'public');
$query = Document::query()
->whereNotNull('preview_path')
->whereNotNull('preview_generated_at')
->where('preview_generated_at', '<', $cutoff);
$count = $query->count();
if ($count === 0) {
$this->info('No stale previews found.');
return self::SUCCESS;
}
$this->info("Found {$count} previews older than {$days} days.");
$dry = (bool) $this->option('dry-run');
$query->chunkById(200, function ($docs) use ($previewDisk, $dry) {
foreach ($docs as $doc) {
$path = $doc->preview_path;
if (!$path) { continue; }
if ($dry) {
$this->line("Would delete: {$previewDisk}://{$path} (document #{$doc->id})");
continue;
}
try {
Storage::disk($previewDisk)->delete($path);
} catch (\Throwable $e) {
// ignore errors, continue processing
}
$doc->preview_path = null;
$doc->preview_mime = null;
$doc->preview_generated_at = null;
$doc->save();
}
});
if ($dry) {
$this->info('Dry run completed. No files were deleted.');
} else {
$this->info('Stale previews deleted and metadata cleared.');
}
return self::SUCCESS;
}
}

34
app/Console/Kernel.php Normal file
View File

@ -0,0 +1,34 @@
<?php
namespace App\Console;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
class Kernel extends ConsoleKernel
{
/**
* Define the application's command schedule.
*/
protected function schedule(Schedule $schedule): void
{
// Optionally prune old previews daily
if (config('files.enable_preview_prune', true)) {
$days = (int) config('files.preview_retention_days', 90);
if ($days < 1) { $days = 90; }
$schedule->command('documents:prune-previews', [
'--days' => $days,
])->dailyAt('02:00');
}
}
/**
* Register the commands for the application.
*/
protected function commands(): void
{
$this->load(__DIR__.'/Commands');
require base_path('routes/console.php');
}
}

View File

@ -4,6 +4,9 @@
use App\Models\ClientCase;
use App\Models\Contract;
use App\Models\Document;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
use Exception;
use Illuminate\Database\QueryException;
use Illuminate\Http\Request;
@ -162,6 +165,119 @@ public function deleteContract(ClientCase $clientCase, String $uuid, Request $re
return to_route('clientCase.show', $clientCase);
}
public function storeDocument(ClientCase $clientCase, Request $request)
{
$validated = $request->validate([
'file' => 'required|file|max:25600|mimes:doc,docx,pdf,txt,csv,xls,xlsx,jpeg,png', // 25MB and allowed types
'name' => 'nullable|string|max:255',
'description' => 'nullable|string',
'is_public' => 'sometimes|boolean',
]);
$file = $validated['file'];
$disk = 'public';
$directory = 'cases/' . $clientCase->uuid . '/documents';
$path = $file->store($directory, $disk);
$doc = new Document([
'name' => $validated['name'] ?? pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME),
'description' => $validated['description'] ?? null,
'user_id' => optional($request->user())->id,
'disk' => $disk,
'path' => $path,
'file_name' => basename($path),
'original_name' => $file->getClientOriginalName(),
'extension' => $file->getClientOriginalExtension(),
'mime_type' => $file->getMimeType(),
'size' => $file->getSize(),
'checksum' => null,
'is_public' => (bool)($validated['is_public'] ?? false),
]);
$clientCase->documents()->save($doc);
// Generate preview immediately for Office docs to avoid first-view delay
$ext = strtolower($doc->extension ?? pathinfo($doc->original_name ?? $doc->file_name, PATHINFO_EXTENSION));
if (in_array($ext, ['doc','docx'])) {
\App\Jobs\GenerateDocumentPreview::dispatch($doc->id);
}
return back()->with('success', 'Document uploaded.');
}
public function viewDocument(ClientCase $clientCase, Document $document, Request $request)
{
// Ensure the document belongs to this client case
if ($document->documentable_type !== ClientCase::class || $document->documentable_id !== $clientCase->id) {
abort(404);
}
// Optional: add authz checks here (e.g., policies)
$disk = $document->disk ?: 'public';
// If a preview exists (e.g., PDF generated for doc/docx), stream that
$previewDisk = config('files.preview_disk', 'public');
if ($document->preview_path && Storage::disk($previewDisk)->exists($document->preview_path)) {
$stream = Storage::disk($previewDisk)->readStream($document->preview_path);
if ($stream === false) abort(404);
return response()->stream(function () use ($stream) { fpassthru($stream); }, 200, [
'Content-Type' => $document->preview_mime ?: 'application/pdf',
'Content-Disposition' => 'inline; filename="' . addslashes(($document->original_name ?: $document->file_name) . '.pdf') . '"',
'Cache-Control' => 'private, max-age=0, no-cache',
'Pragma' => 'no-cache',
]);
}
// If it's a DOC/DOCX and no preview yet, queue generation and show 202 Accepted
$ext = strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION));
if (in_array($ext, ['doc','docx'])) {
\App\Jobs\GenerateDocumentPreview::dispatch($document->id);
return response('Preview is being generated. Please try again shortly.', 202);
}
if (!Storage::disk($disk)->exists($document->path)) {
abort(404);
}
$stream = Storage::disk($disk)->readStream($document->path);
if ($stream === false) {
abort(404);
}
return response()->stream(function () use ($stream) {
fpassthru($stream);
}, 200, [
'Content-Type' => $document->mime_type ?: 'application/octet-stream',
'Content-Disposition' => 'inline; filename="' . addslashes($document->original_name ?: $document->file_name) . '"',
'Cache-Control' => 'private, max-age=0, no-cache',
'Pragma' => 'no-cache',
]);
}
public function downloadDocument(ClientCase $clientCase, Document $document, Request $request)
{
if ($document->documentable_type !== ClientCase::class || $document->documentable_id !== $clientCase->id) {
abort(404);
}
$disk = $document->disk ?: 'public';
if (!Storage::disk($disk)->exists($document->path)) {
abort(404);
}
$name = $document->original_name ?: $document->file_name;
$stream = Storage::disk($disk)->readStream($document->path);
if ($stream === false) {
abort(404);
}
return response()->stream(function () use ($stream) {
fpassthru($stream);
}, 200, [
'Content-Type' => $document->mime_type ?: 'application/octet-stream',
'Content-Disposition' => 'attachment; filename="' . addslashes($name) . '"',
'Cache-Control' => 'private, max-age=0, no-cache',
'Pragma' => 'no-cache',
]);
}
/**
* Display the specified resource.
*/
@ -185,6 +301,7 @@ public function show(ClientCase $clientCase)
'activities' => $case->activities()->with(['action', 'decision'])
->orderByDesc('created_at')
->paginate(20, ['*'], 'activities'),
'documents' => $case->documents()->orderByDesc('created_at')->get(),
'contract_types' => \App\Models\ContractType::whereNull('deleted_at')->get(),
'actions' => \App\Models\Action::with('decisions')->get(),
'types' => $types

View File

@ -0,0 +1,358 @@
<?php
namespace App\Http\Controllers;
use App\Models\Client;
use App\Models\Import;
use App\Models\ImportTemplate;
use App\Models\ImportRow;
use App\Models\ImportEvent;
use App\Services\ImportProcessor;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Inertia\Inertia;
use App\Services\CsvImportService;
class ImportController extends Controller
{
// List imports (paginated)
public function index(Request $request)
{
$paginator = Import::query()
->with(['client:id,uuid', 'template:id,name'])
->orderByDesc('created_at')
->paginate(15);
$imports = [
'data' => $paginator->items(),
'links' => [
'first' => $paginator->url(1),
'last' => $paginator->url($paginator->lastPage()),
'prev' => $paginator->previousPageUrl(),
'next' => $paginator->nextPageUrl(),
],
'meta' => [
'current_page' => $paginator->currentPage(),
'from' => $paginator->firstItem(),
'last_page' => $paginator->lastPage(),
'path' => $paginator->path(),
'per_page' => $paginator->perPage(),
'to' => $paginator->lastItem(),
'total' => $paginator->total(),
],
];
// Map items into a simpler shape
$imports['data'] = array_map(function ($imp) {
return [
'id' => $imp->id,
'uuid' => $imp->uuid,
'created_at' => $imp->created_at,
'original_name' => $imp->original_name,
'size' => $imp->size,
'status' => $imp->status,
'client' => $imp->client ? [ 'id' => $imp->client_id, 'uuid' => $imp->client->uuid ] : null,
'template' => $imp->template ? [ 'id' => $imp->import_template_id, 'name' => $imp->template->name ] : null,
];
}, $imports['data']);
return Inertia::render('Imports/Index', [
'imports' => $imports,
]);
}
// Show the import creation page
public function create(Request $request)
{
$templates = ImportTemplate::query()
->leftJoin('clients', 'clients.id', '=', 'import_templates.client_id')
->where('import_templates.is_active', true)
->orderBy('import_templates.name')
->get([
'import_templates.id',
'import_templates.uuid',
'import_templates.name',
'import_templates.source_type',
'import_templates.default_record_type',
'import_templates.client_id',
DB::raw('clients.uuid as client_uuid'),
]);
$clients = Client::query()
->join('person', 'person.id', '=', 'clients.person_id')
->orderBy('person.full_name')
->get([
'clients.id',
'clients.uuid',
DB::raw('person.full_name as name'),
]);
return Inertia::render('Imports/Create', [
'templates' => $templates,
'clients' => $clients,
// no existing import on create
]);
}
// Create a new import job, store file, and return basic info
public function store(Request $request)
{
$validated = $request->validate([
'file' => 'required|file|max:20480', // 20MB; adjust as needed
'source_type' => 'nullable|string|in:csv,xml,xls,xlsx,json,txt',
'sheet_name' => 'nullable|string|max:64',
'has_header' => 'nullable|boolean',
'import_template_id' => 'nullable|integer|exists:import_templates,id',
'client_uuid' => 'nullable|string|exists:clients,uuid',
]);
$file = $validated['file'];
$ext = strtolower($file->getClientOriginalExtension());
$sourceType = $validated['source_type'] ?? ($ext === 'txt' ? 'csv' : $ext);
$uuid = (string) Str::uuid();
$disk = 'local';
$path = $file->storeAs('imports', $uuid.'.'.$ext, $disk);
// Resolve client_uuid to client_id if provided
$clientId = null;
if (!empty($validated['client_uuid'] ?? null)) {
$clientId = Client::where('uuid', $validated['client_uuid'])->value('id');
}
$import = Import::create([
'uuid' => $uuid,
'user_id' => $request->user()?->id,
'import_template_id' => $validated['import_template_id'] ?? null,
'client_id' => $clientId,
'source_type' => $sourceType,
'file_name' => basename($path),
'original_name' => $file->getClientOriginalName(),
'disk' => $disk,
'path' => $path,
'size' => $file->getSize(),
'sheet_name' => $validated['sheet_name'] ?? null,
'status' => 'uploaded',
'meta' => [
'has_header' => $validated['has_header'] ?? true,
],
]);
return response()->json([
'id' => $import->id,
'uuid' => $import->uuid,
'status' => $import->status,
]);
}
// Kick off processing of an import - simple synchronous step for now
public function process(Import $import, Request $request, ImportProcessor $processor)
{
$import->update(['status' => 'validating', 'started_at' => now()]);
$result = $processor->process($import, user: $request->user());
return response()->json($result);
}
// Analyze the uploaded file and return column headers or positional indices
public function columns(Request $request, Import $import, CsvImportService $csv)
{
$validated = $request->validate([
'has_header' => 'nullable|boolean',
]);
$hasHeader = array_key_exists('has_header', $validated)
? (bool) $validated['has_header']
: (bool) ($import->meta['has_header'] ?? true);
// Only implement CSV/TSV detection for now; others can be added later
if (!in_array($import->source_type, ['csv','txt'])) {
return response()->json([
'columns' => [],
'note' => 'Column preview supported for CSV/TXT at this step.',
]);
}
$fullPath = Storage::disk($import->disk)->path($import->path);
[$delimiter, $columns] = $csv->detectColumnsFromCsv($fullPath, $hasHeader);
// Save meta
$meta = $import->meta ?? [];
$meta['has_header'] = $hasHeader;
$meta['detected_delimiter'] = $delimiter;
$meta['columns'] = $columns;
$import->update([
'meta' => $meta,
'status' => $import->status === 'uploaded' ? 'parsed' : $import->status,
]);
return response()->json([
'columns' => $columns,
'has_header' => $hasHeader,
'detected_delimiter' => $delimiter,
]);
}
// CSV helpers moved to App\Services\CsvImportService
// Save ad-hoc mappings for a specific import (when no template is selected)
public function saveMappings(Request $request, Import $import)
{
$data = $request->validate([
'mappings' => 'required|array',
'mappings.*.source_column' => 'required|string',
'mappings.*.entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts',
'mappings.*.target_field' => 'required|string',
'mappings.*.transform' => 'nullable|string|in:trim,upper,lower',
'mappings.*.apply_mode' => 'nullable|string|in:insert,update,both',
'mappings.*.options' => 'nullable|array',
]);
// Upsert by (import_id, source_column): update existing rows; insert new ones; avoid duplicates
$now = now();
$existing = \DB::table('import_mappings')
->where('import_id', $import->id)
->get(['id','source_column','position']);
$bySource = [];
$dupes = [];
foreach ($existing as $row) {
$src = (string) $row->source_column;
if (!array_key_exists($src, $bySource)) {
$bySource[$src] = [ 'id' => $row->id, 'position' => $row->position ];
} else {
$dupes[$src] = ($dupes[$src] ?? []);
$dupes[$src][] = $row->id;
}
}
$basePosition = (int) (\DB::table('import_mappings')->where('import_id', $import->id)->max('position') ?? -1);
$inserted = 0; $updated = 0; $deduped = 0;
foreach ($data['mappings'] as $pos => $m) {
$src = (string) $m['source_column'];
$payload = [
'entity' => $m['entity'] ?? null,
'target_field' => $m['target_field'],
'transform' => $m['transform'] ?? null,
'apply_mode' => $m['apply_mode'] ?? 'both',
'options' => $m['options'] ?? null,
'position' => $pos, // keep UI order
'updated_at' => $now,
];
if (array_key_exists($src, $bySource)) {
// Update first occurrence
\DB::table('import_mappings')->where('id', $bySource[$src]['id'])->update($payload);
$updated++;
// Remove duplicates if any
if (!empty($dupes[$src])) {
$deleted = \DB::table('import_mappings')->whereIn('id', $dupes[$src])->delete();
$deduped += (int) $deleted;
unset($dupes[$src]);
}
} else {
// Insert new
\DB::table('import_mappings')->insert([
'import_id' => $import->id,
'entity' => $payload['entity'],
'source_column' => $src,
'target_field' => $payload['target_field'],
'transform' => $payload['transform'],
'apply_mode' => $payload['apply_mode'],
'options' => $payload['options'],
'position' => ++$basePosition,
'created_at' => $now,
'updated_at' => $now,
]);
$inserted++;
}
}
// Mark this as ad-hoc mapping usage
$import->update(['import_template_id' => null]);
return response()->json(['ok' => true, 'saved' => ($inserted + $updated), 'inserted' => $inserted, 'updated' => $updated, 'deduped' => $deduped]);
}
// Fetch current mappings for an import (after applying a template or saving ad-hoc mappings)
public function getMappings(Import $import)
{
$rows = \DB::table('import_mappings')
->where('import_id', $import->id)
->orderBy('position')
->orderBy('id')
->get([
'id',
'entity',
'source_column',
'target_field',
'transform',
'apply_mode',
'options',
'position'
]);
return response()->json(['mappings' => $rows]);
}
// Fetch recent import events (logs) for an import
public function getEvents(Import $import)
{
$limit = (int) request()->query('limit', 200);
$limit = max(1, min($limit, 1000));
$events = ImportEvent::query()
->where('import_id', $import->id)
->orderByDesc('id')
->limit($limit)
->get(['id','created_at','level','event','message','import_row_id','context']);
return response()->json(['events' => $events]);
}
// Show an existing import by UUID to continue where left off
public function show(Import $import)
{
// Load templates (global + client specific) and clients for selection on continue page
$templates = ImportTemplate::query()
->leftJoin('clients', 'clients.id', '=', 'import_templates.client_id')
->where('import_templates.is_active', true)
->orderBy('import_templates.name')
->get([
'import_templates.id',
'import_templates.uuid',
'import_templates.name',
'import_templates.source_type',
'import_templates.default_record_type',
'import_templates.client_id',
DB::raw('clients.uuid as client_uuid'),
]);
$clients = Client::query()
->join('person', 'person.id', '=', 'clients.person_id')
->orderBy('person.full_name')
->get([
'clients.id',
'clients.uuid',
DB::raw('person.full_name as name'),
]);
// Render a dedicated page to continue the import
return Inertia::render('Imports/Import', [
'import' => [
'id' => $import->id,
'uuid' => $import->uuid,
'status' => $import->status,
'meta' => $import->meta,
'client_id' => $import->client_id,
'import_template_id' => $import->import_template_id,
'total_rows' => $import->total_rows,
'imported_rows' => $import->imported_rows,
'invalid_rows' => $import->invalid_rows,
'valid_rows' => $import->valid_rows,
'finished_at' => $import->finished_at,
],
'templates' => $templates,
'clients' => $clients,
]);
}
}

View File

@ -0,0 +1,432 @@
<?php
namespace App\Http\Controllers;
use App\Models\Import;
use App\Models\ImportTemplate;
use App\Models\ImportTemplateMapping;
use App\Models\Client;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Inertia\Inertia;
class ImportTemplateController extends Controller
{
public function index()
{
$templates = ImportTemplate::query()
->with(['client:id,uuid,person_id', 'client.person:id,full_name'])
->orderBy('name')
->get();
return Inertia::render('Imports/Templates/Index', [
'templates' => $templates->map(fn($t) => [
'uuid' => $t->uuid,
'name' => $t->name,
'description' => $t->description,
'source_type' => $t->source_type,
'is_active' => $t->is_active,
'client' => $t->client ? [
'uuid' => $t->client->uuid,
'name' => $t->client->person?->full_name,
] : null,
]),
]);
}
// Show the template creation page
public function create()
{
// Preload clients for optional association (global when null)
$clients = Client::query()
->join('person', 'person.id', '=', 'clients.person_id')
->orderBy('person.full_name')
->get([
'clients.id', // kept for compatibility, UI will use uuid
'clients.uuid',
DB::raw('person.full_name as name'),
]);
return Inertia::render('Imports/Templates/Create', [
'clients' => $clients,
]);
}
public function store(Request $request)
{
// Normalize payload to be resilient to UI variations
$raw = $request->all();
// Resolve client by uuid if provided, or cast string numeric to int
if (!empty($raw['client_uuid'] ?? null)) {
$raw['client_id'] = Client::where('uuid', $raw['client_uuid'])->value('id');
} elseif (isset($raw['client_id']) && is_string($raw['client_id']) && ctype_digit($raw['client_id'])) {
$raw['client_id'] = (int) $raw['client_id'];
}
// Normalize entities to array of strings
if (isset($raw['entities']) && is_array($raw['entities'])) {
$raw['entities'] = array_values(array_filter(array_map(function ($e) {
if (is_string($e)) return $e;
if (is_array($e) && array_key_exists('value', $e)) return (string) $e['value'];
return null;
}, $raw['entities'])));
}
$data = validator($raw, [
'name' => 'required|string|max:100',
'description' => 'nullable|string|max:255',
'source_type' => 'required|string|in:csv,xml,xls,xlsx,json',
'default_record_type' => 'nullable|string|max:50',
'sample_headers' => 'nullable|array',
'client_id' => 'nullable|integer|exists:clients,id',
'is_active' => 'boolean',
'entities' => 'nullable|array',
'entities.*' => 'string|in:person,person_addresses,person_phones,emails,accounts,contracts',
'mappings' => 'array',
'mappings.*.source_column' => 'required|string',
'mappings.*.entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts',
'mappings.*.target_field' => 'nullable|string',
'mappings.*.transform' => 'nullable|string|max:50',
'mappings.*.apply_mode' => 'nullable|string|in:insert,update,both',
'mappings.*.options' => 'nullable|array',
'mappings.*.position' => 'nullable|integer',
])->validate();
$template = null;
DB::transaction(function () use (&$template, $request, $data) {
$template = ImportTemplate::create([
'uuid' => (string) Str::uuid(),
'name' => $data['name'],
'description' => $data['description'] ?? null,
'source_type' => $data['source_type'],
'default_record_type' => $data['default_record_type'] ?? null,
'sample_headers' => $data['sample_headers'] ?? null,
'user_id' => $request->user()?->id,
'client_id' => $data['client_id'] ?? null,
'is_active' => $data['is_active'] ?? true,
'meta' => [
'entities' => $data['entities'] ?? [],
],
]);
foreach (($data['mappings'] ?? []) as $m) {
ImportTemplateMapping::create([
'import_template_id' => $template->id,
'entity' => $m['entity'] ?? null,
'source_column' => $m['source_column'],
'target_field' => $m['target_field'] ?? null,
'transform' => $m['transform'] ?? null,
'apply_mode' => $m['apply_mode'] ?? 'both',
'options' => $m['options'] ?? null,
'position' => $m['position'] ?? null,
]);
}
});
// Redirect to edit page for the newly created template
return redirect()
->route('importTemplates.edit', ['template' => $template->uuid])
->with('success', 'Template created successfully.');
}
// Edit template UI (by uuid)
public function edit(ImportTemplate $template)
{
// Eager-load mappings
$template->load(['mappings']);
// Preload clients list (uuid + name) for possible reassignment
$clients = Client::query()
->join('person', 'person.id', '=', 'clients.person_id')
->orderBy('person.full_name')
->get([
'clients.uuid',
DB::raw('person.full_name as name'),
]);
return Inertia::render('Imports/Templates/Edit', [
'template' => [
'uuid' => $template->uuid,
'name' => $template->name,
'description' => $template->description,
'source_type' => $template->source_type,
'default_record_type' => $template->default_record_type,
'is_active' => $template->is_active,
'client_uuid' => $template->client?->uuid,
'sample_headers' => $template->sample_headers,
'meta' => $template->meta,
'mappings' => $template->mappings()->orderBy('position')->get(['id','entity','source_column','target_field','transform','apply_mode','options','position']),
],
'clients' => $clients,
]);
}
// Add a new mapping to a template (by uuid)
public function addMapping(Request $request, ImportTemplate $template)
{
// Normalize empty transform to null
$raw = $request->all();
if (array_key_exists('transform', $raw) && $raw['transform'] === '') {
$raw['transform'] = null;
}
$data = validator($raw, [
'source_column' => 'required|string',
'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts',
'target_field' => 'nullable|string',
'transform' => 'nullable|string|in:trim,upper,lower',
'apply_mode' => 'nullable|string|in:insert,update,both',
'options' => 'nullable|array',
'position' => 'nullable|integer',
])->validate();
// Avoid duplicates by source_column within the same template: update if exists
$existing = ImportTemplateMapping::where('import_template_id', $template->id)
->where('source_column', $data['source_column'])
->first();
if ($existing) {
$existing->update([
'target_field' => $data['target_field'] ?? $existing->target_field,
'entity' => $data['entity'] ?? $existing->entity,
'transform' => $data['transform'] ?? $existing->transform,
'apply_mode' => $data['apply_mode'] ?? $existing->apply_mode ?? 'both',
'options' => $data['options'] ?? $existing->options,
'position' => $data['position'] ?? $existing->position,
]);
return redirect()->route('importTemplates.edit', ['template' => $template->uuid])
->with('info', 'Mapping already existed. Updated existing mapping.');
} else {
$position = $data['position'] ?? (int) (($template->mappings()->max('position') ?? 0) + 1);
ImportTemplateMapping::create([
'import_template_id' => $template->id,
'entity' => $data['entity'] ?? null,
'source_column' => $data['source_column'],
'target_field' => $data['target_field'] ?? null,
'transform' => $data['transform'] ?? null,
'apply_mode' => $data['apply_mode'] ?? 'both',
'options' => $data['options'] ?? null,
'position' => $position,
]);
return redirect()->route('importTemplates.edit', ['template' => $template->uuid])
->with('success', 'Mapping added');
}
}
// Update template basic fields
public function update(Request $request, ImportTemplate $template)
{
$raw = $request->all();
if (!empty($raw['client_uuid'] ?? null)) {
$raw['client_id'] = Client::where('uuid', $raw['client_uuid'])->value('id');
}
$data = validator($raw, [
'name' => 'required|string|max:100',
'description' => 'nullable|string|max:255',
'source_type' => 'required|string|in:csv,xml,xls,xlsx,json',
'default_record_type' => 'nullable|string|max:50',
'client_id' => 'nullable|integer|exists:clients,id',
'is_active' => 'boolean',
'sample_headers' => 'nullable|array',
])->validate();
$template->update([
'name' => $data['name'],
'description' => $data['description'] ?? null,
'source_type' => $data['source_type'],
'default_record_type' => $data['default_record_type'] ?? null,
'client_id' => $data['client_id'] ?? null,
'is_active' => $data['is_active'] ?? $template->is_active,
'sample_headers' => $data['sample_headers'] ?? $template->sample_headers,
]);
return redirect()->route('importTemplates.edit', ['template' => $template->uuid])
->with('success', 'Template updated');
}
// Bulk add multiple mappings from a textarea input
public function bulkAddMappings(Request $request, ImportTemplate $template)
{
// Accept either commas or newlines as separators
$raw = $request->all();
if (array_key_exists('transform', $raw) && $raw['transform'] === '') {
$raw['transform'] = null;
}
$data = validator($raw, [
'sources' => 'required|string', // comma and/or newline separated
'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts',
'default_field' => 'nullable|string', // if provided, used as the field name for all entries
'apply_mode' => 'nullable|string|in:insert,update,both',
'transform' => 'nullable|string|in:trim,upper,lower',
])->validate();
$list = preg_split('/\r?\n|,/', $data['sources']);
$list = array_values(array_filter(array_map(fn($s) => trim($s), $list), fn($s) => $s !== ''));
if (empty($list)) {
return redirect()->route('importTemplates.edit', ['template' => $template->uuid])
->with('warning', 'No valid source columns provided.');
}
$basePosition = (int) (($template->mappings()->max('position') ?? 0));
$apply = $data['apply_mode'] ?? 'both';
$transform = $data['transform'] ?? null;
$entity = $data['entity'] ?? null;
$defaultField = $data['default_field'] ?? null; // allows forcing a specific field for all
$created = 0; $updated = 0;
DB::transaction(function () use ($template, $list, $apply, $transform, $entity, $defaultField, $basePosition, &$created, &$updated) {
foreach ($list as $idx => $source) {
$targetField = null;
if ($defaultField) {
$targetField = $entity ? ($entity . '.' . $defaultField) : $defaultField;
} elseif ($entity) {
$targetField = $entity . '.' . $source;
}
$existing = ImportTemplateMapping::where('import_template_id', $template->id)
->where('source_column', $source)
->first();
if ($existing) {
$existing->update([
'target_field' => $targetField ?? $existing->target_field,
'entity' => $entity ?? $existing->entity,
'transform' => $transform ?? $existing->transform,
'apply_mode' => $apply ?? $existing->apply_mode ?? 'both',
'options' => $existing->options,
// keep existing position
]);
$updated++;
} else {
ImportTemplateMapping::create([
'import_template_id' => $template->id,
'entity' => $entity,
'source_column' => $source,
'target_field' => $targetField,
'transform' => $transform,
'apply_mode' => $apply,
'options' => null,
'position' => $basePosition + $idx + 1,
]);
$created++;
}
}
});
$msg = [];
if ($created) $msg[] = "$created created";
if ($updated) $msg[] = "$updated updated";
$text = 'Mappings processed';
if (!empty($msg)) $text .= ': ' . implode(', ', $msg);
return redirect()->route('importTemplates.edit', ['template' => $template->uuid])
->with('success', $text);
}
// Update an existing mapping
public function updateMapping(Request $request, ImportTemplate $template, ImportTemplateMapping $mapping)
{
if ($mapping->import_template_id !== $template->id) abort(404);
$raw = $request->all();
if (array_key_exists('transform', $raw) && $raw['transform'] === '') {
$raw['transform'] = null;
}
$data = validator($raw, [
'source_column' => 'required|string',
'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts',
'target_field' => 'nullable|string',
'transform' => 'nullable|string|in:trim,upper,lower',
'apply_mode' => 'nullable|string|in:insert,update,both',
'options' => 'nullable|array',
'position' => 'nullable|integer',
])->validate();
$mapping->update([
'source_column' => $data['source_column'],
'entity' => $data['entity'] ?? null,
'target_field' => $data['target_field'] ?? null,
'transform' => $data['transform'] ?? null,
'apply_mode' => $data['apply_mode'] ?? 'both',
'options' => $data['options'] ?? null,
'position' => $data['position'] ?? $mapping->position,
]);
return redirect()->route('importTemplates.edit', ['template' => $template->uuid])
->with('success', 'Mapping updated');
}
// Delete a mapping
public function deleteMapping(ImportTemplate $template, ImportTemplateMapping $mapping)
{
if ($mapping->import_template_id !== $template->id) abort(404);
$mapping->delete();
return redirect()->route('importTemplates.edit', ['template' => $template->uuid])
->with('success', 'Mapping deleted');
}
// Reorder mappings in bulk
public function reorderMappings(Request $request, ImportTemplate $template)
{
$data = $request->validate([
'order' => 'required|array',
'order.*' => 'integer',
]);
$ids = $data['order'];
// Ensure all ids belong to template
$validIds = ImportTemplateMapping::where('import_template_id', $template->id)
->whereIn('id', $ids)->pluck('id')->all();
if (count($validIds) !== count($ids)) abort(422, 'Invalid mapping ids');
// Apply new positions
foreach ($ids as $idx => $id) {
ImportTemplateMapping::where('id', $id)->update(['position' => $idx]);
}
return redirect()->route('importTemplates.edit', ['template' => $template->uuid])
->with('success', 'Mappings reordered');
}
// Apply a templates mappings to a specific import (copy into import_mappings)
public function applyToImport(Request $request, ImportTemplate $template, Import $import)
{
// optional: clear previous mappings
$clear = $request->boolean('clear', true);
$copied = 0;
DB::transaction(function () use ($clear, $template, $import, &$copied) {
if ($clear) {
\DB::table('import_mappings')->where('import_id', $import->id)->delete();
}
$rows = $template->mappings()->orderBy('position')->get();
foreach ($rows as $row) {
\DB::table('import_mappings')->insert([
'import_id' => $import->id,
'entity' => $row->entity,
'source_column' => $row->source_column,
'target_field' => $row->target_field,
'transform' => $row->transform,
'apply_mode' => $row->apply_mode ?? 'both',
'options' => $row->options,
'position' => $row->position ?? null,
'created_at' => now(),
'updated_at' => now(),
]);
$copied++;
}
$import->update(['import_template_id' => $template->id]);
});
return response()->json(['ok' => true, 'copied' => $copied, 'cleared' => $clear]);
}
// Delete a template and cascade delete its mappings; detach from imports
public function destroy(ImportTemplate $template)
{
DB::transaction(function () use ($template) {
// Nullify references from imports to this template
\DB::table('imports')->where('import_template_id', $template->id)->update(['import_template_id' => null]);
// Delete mappings first (if FK cascade not set)
\DB::table('import_template_mappings')->where('import_template_id', $template->id)->delete();
// Delete the template
$template->delete();
});
return redirect()->route('importTemplates.index')->with('success', 'Template deleted');
}
}

View File

@ -49,10 +49,14 @@ public function createAddress(Person $person, Request $request){
'description' => 'nullable|string|max:125'
]);
$address_id = $person->addresses()->create($attributes)->id;
// Dedup: avoid duplicate address per person by (address, country)
$address = $person->addresses()->firstOrCreate([
'address' => $attributes['address'],
'country' => $attributes['country'] ?? null,
], $attributes);
return response()->json([
'address' => \App\Models\Person\PersonAddress::with(['type'])->findOrFail($address_id)
'address' => \App\Models\Person\PersonAddress::with(['type'])->findOrFail($address->id)
]);
}
@ -83,10 +87,14 @@ public function createPhone(Person $person, Request $request)
'description' => 'nullable|string|max:125'
]);
$phone_id = $person->phones()->create($attributes)->id;
// Dedup: avoid duplicate phone per person by (nu, country_code)
$phone = $person->phones()->firstOrCreate([
'nu' => $attributes['nu'],
'country_code' => $attributes['country_code'] ?? null,
], $attributes);
return response()->json([
'phone' => \App\Models\Person\PersonPhone::with(['type'])->findOrFail($phone_id)
'phone' => \App\Models\Person\PersonPhone::with(['type'])->findOrFail($phone->id)
]);
}
@ -107,4 +115,49 @@ public function updatePhone(Person $person, int $phone_id, Request $request)
'phone' => $phone
]);
}
public function createEmail(Person $person, Request $request)
{
$attributes = $request->validate([
'value' => 'required|email:rfc,dns|max:255',
'label' => 'nullable|string|max:50',
'is_primary' => 'boolean',
'is_active' => 'boolean',
'valid' => 'boolean',
'verified_at' => 'nullable|date',
'preferences' => 'nullable|array',
'meta' => 'nullable|array',
]);
// Dedup: avoid duplicate email per person by value
$email = $person->emails()->firstOrCreate([
'value' => $attributes['value'],
], $attributes);
return response()->json([
'email' => \App\Models\Email::findOrFail($email->id)
]);
}
public function updateEmail(Person $person, int $email_id, Request $request)
{
$attributes = $request->validate([
'value' => 'required|email:rfc,dns|max:255',
'label' => 'nullable|string|max:50',
'is_primary' => 'boolean',
'is_active' => 'boolean',
'valid' => 'boolean',
'verified_at' => 'nullable|date',
'preferences' => 'nullable|array',
'meta' => 'nullable|array',
]);
$email = $person->emails()->findOrFail($email_id);
$email->update($attributes);
return response()->json([
'email' => $email
]);
}
}

View File

@ -36,7 +36,12 @@ public function version(Request $request): ?string
public function share(Request $request): array
{
return array_merge(parent::share($request), [
//
'flash' => [
'success' => fn () => $request->session()->get('success'),
'error' => fn () => $request->session()->get('error'),
'warning' => fn () => $request->session()->get('warning'),
'info' => fn () => $request->session()->get('info'),
],
]);
}
}

View File

@ -0,0 +1,149 @@
<?php
namespace App\Jobs;
use App\Models\Document;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
class GenerateDocumentPreview implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public int $documentId)
{
}
public function handle(): void
{
$doc = Document::find($this->documentId);
if (!$doc)
return;
$disk = $doc->disk ?: 'public';
if (!Storage::disk($disk)->exists($doc->path))
return;
$ext = strtolower(pathinfo($doc->original_name ?: $doc->file_name, PATHINFO_EXTENSION));
if (!in_array($ext, ['doc', 'docx']))
return; // only convert office docs here
// Prepare temp files - keep original extension so LibreOffice can detect filter
$tmpBase = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'doc_in_' . uniqid();
$tmpIn = $tmpBase . '.' . $ext; // e.g., .doc or .docx
file_put_contents($tmpIn, Storage::disk($disk)->get($doc->path));
$outDir = sys_get_temp_dir();
// Ensure exec is available
if (!function_exists('exec')) {
Log::error('Preview generation failed: exec() not available in this PHP environment', ['document_id' => $doc->id]);
return;
}
$disabled = array_map('trim', explode(',', (string) ini_get('disable_functions')));
if (in_array('exec', $disabled, true)) {
Log::error('Preview generation failed: exec() is disabled in php.ini (disable_functions)', ['document_id' => $doc->id]);
return;
}
// Run soffice headless to convert to PDF
$binCfg = config('files.libreoffice_bin');
$bin = $binCfg ? (string) $binCfg : 'soffice';
// Windows quoting differs from POSIX. Build command parts safely.
$isWin = strtoupper(substr(PHP_OS, 0, 3)) === 'WIN';
if ($isWin) {
$binPart = '"' . $bin . '"';
$outDirPart = '"' . $outDir . '"';
$inPart = '"' . $tmpIn . '"';
} else {
$binPart = escapeshellcmd($bin);
$outDirPart = escapeshellarg($outDir);
$inPart = escapeshellarg($tmpIn);
}
// Use a temporary user profile to avoid permissions/profile lock issues
$loProfileDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'lo_profile_' . $doc->id;
if (!is_dir($loProfileDir)) {
@mkdir($loProfileDir, 0700, true);
}
$loProfileUri = 'file:///' . ltrim(str_replace('\\', '/', $loProfileDir), '/');
$cmd = sprintf(
'%s --headless --norestore --nolockcheck -env:UserInstallation=%s --convert-to pdf --outdir %s %s',
$binPart,
$isWin ? '"' . $loProfileUri . '"' : escapeshellarg($loProfileUri),
$outDirPart,
$inPart
);
// Capture stderr as well for diagnostics
$cmdWithStderr = $cmd . ' 2>&1';
Log::info('Starting LibreOffice preview conversion', [
'document_id' => $doc->id,
'cmd' => $cmd,
'is_windows' => $isWin,
]);
$out = [];
$ret = 0;
exec($cmdWithStderr, $out, $ret);
if ($ret !== 0) {
Log::warning('Preview generation failed', [
'document_id' => $doc->id,
'ret' => $ret,
'cmd' => $cmd,
'output' => implode("\n", $out),
]);
@unlink($tmpIn);
return;
}
$pdfPathLocal = $tmpIn . '.pdf';
// LibreOffice writes output with source filename base; derive path
$base = pathinfo($tmpIn, PATHINFO_FILENAME);
$pdfPathLocal = $outDir . DIRECTORY_SEPARATOR . $base . '.pdf';
if (!file_exists($pdfPathLocal)) {
// fallback: try with original name base
$origBase = pathinfo($doc->original_name ?: $doc->file_name, PATHINFO_FILENAME);
$try = $outDir . DIRECTORY_SEPARATOR . $origBase . '.pdf';
if (file_exists($try))
$pdfPathLocal = $try;
}
if (!file_exists($pdfPathLocal)) {
Log::warning('Preview generation did not produce expected PDF output', [
'document_id' => $doc->id,
'out_dir' => $outDir,
'tmp_base' => $base,
'command' => $cmd,
'output' => implode("\n", $out),
]);
@unlink($tmpIn);
return;
}
// Store preview PDF to configured disk inside configured previews base path
$previewDisk = config('files.preview_disk', 'public');
$base = trim(config('files.preview_base', 'previews/cases'), '/');
$previewDir = $base . '/' . ($doc->documentable?->uuid ?? 'unknown');
$stored = Storage::disk($previewDisk)->put($previewDir . '/' . ($doc->uuid) . '.pdf', file_get_contents($pdfPathLocal));
if ($stored) {
$doc->preview_path = $previewDir . '/' . $doc->uuid . '.pdf';
$doc->preview_mime = 'application/pdf';
$doc->preview_generated_at = now();
$doc->save();
}
@unlink($tmpIn);
@unlink($pdfPathLocal);
// Clean up temporary LO profile directory
try {
if (is_dir($loProfileDir)) {
@rmdir($loProfileDir);
}
} catch (\Throwable $e) {
// ignore
}
}
}

View File

@ -12,6 +12,15 @@ class Account extends Model
/** @use HasFactory<\Database\Factories\Person/AccountFactory> */
use HasFactory;
protected $fillable = [
'reference',
'description',
'contract_id',
'type_id',
'active',
'balance_amount',
];
public function debtor(): BelongsTo
{
return $this->belongsTo(\App\Models\Person\Person::class, 'debtor_id');

View File

@ -8,6 +8,7 @@
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\Builder;
use Laravel\Scout\Searchable;
@ -19,7 +20,8 @@ class ClientCase extends Model
use Searchable;
protected $fillable = [
'client_id'
'client_id',
'person_id'
];
protected $hidden = [
@ -65,4 +67,9 @@ public function activities(): HasMany
public function segments(): BelongsToMany {
return $this->belongsToMany(\App\Models\Segment::class)->withTimestamps();
}
public function documents(): MorphMany
{
return $this->morphMany(\App\Models\Document::class, 'documentable');
}
}

83
app/Models/Document.php Normal file
View File

@ -0,0 +1,83 @@
<?php
namespace App\Models;
use App\Traits\Uuid;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Storage;
class Document extends Model
{
use HasFactory;
use Uuid;
use SoftDeletes;
protected $fillable = [
'uuid',
'name',
'description',
'user_id',
'disk',
'path',
'file_name',
'original_name',
'extension',
'mime_type',
'size',
'checksum',
'is_public',
'preview_path',
'preview_mime',
'preview_generated_at',
];
protected $casts = [
'is_public' => 'boolean',
'size' => 'integer',
'preview_generated_at' => 'datetime',
];
protected $appends = [];
public function documentable(): MorphTo
{
return $this->morphTo();
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
// No direct public URL exposure; serve via controller stream
protected static function booted(): void
{
static::deleting(function (Document $doc) {
// Only delete files on force delete to keep data when soft-deleted
if (method_exists($doc, 'isForceDeleting') && $doc->isForceDeleting()) {
try {
if ($doc->path) {
$disk = $doc->disk ?: 'public';
Storage::disk($disk)->delete($doc->path);
}
} catch (\Throwable $e) {
// swallow; avoid failing delete due to storage issue
}
try {
if ($doc->preview_path) {
$previewDisk = config('files.preview_disk', 'public');
Storage::disk($previewDisk)->delete($doc->preview_path);
}
} catch (\Throwable $e) {
// swallow
}
}
});
}
}

35
app/Models/Email.php Normal file
View File

@ -0,0 +1,35 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Email extends Model
{
protected $fillable = [
'person_id',
'value',
'label',
'is_primary',
'is_active',
'valid',
'verified_at',
'preferences',
'meta',
];
protected $casts = [
'is_primary' => 'boolean',
'is_active' => 'boolean',
'valid' => 'boolean',
'verified_at' => 'datetime',
'preferences' => 'array',
'meta' => 'array',
];
public function person(): BelongsTo
{
return $this->belongsTo(\App\Models\Person\Person::class, 'person_id');
}
}

50
app/Models/Import.php Normal file
View File

@ -0,0 +1,50 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Import extends Model
{
use HasFactory;
protected $fillable = [
'uuid','user_id','import_template_id','client_id','source_type','file_name','original_name','disk','path','size','sheet_name','status','total_rows','valid_rows','invalid_rows','imported_rows','started_at','finished_at','failed_at','error_summary','meta'
];
protected $casts = [
'error_summary' => 'array',
'meta' => 'array',
'started_at' => 'datetime',
'finished_at' => 'datetime',
'failed_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function template(): BelongsTo
{
return $this->belongsTo(ImportTemplate::class, 'import_template_id');
}
public function client(): BelongsTo
{
return $this->belongsTo(Client::class);
}
public function rows(): HasMany
{
return $this->hasMany(ImportRow::class);
}
public function events(): HasMany
{
return $this->hasMany(ImportEvent::class);
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ImportEvent extends Model
{
use HasFactory;
protected $fillable = [
'import_id','user_id','event','level','message','context','import_row_id'
];
protected $casts = [
'context' => 'array',
];
public function import(): BelongsTo
{
return $this->belongsTo(Import::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function row(): BelongsTo
{
return $this->belongsTo(ImportRow::class, 'import_row_id');
}
}

28
app/Models/ImportRow.php Normal file
View File

@ -0,0 +1,28 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ImportRow extends Model
{
use HasFactory;
protected $fillable = [
'import_id','row_number','sheet_name','record_type','raw_data','mapped_data','status','errors','warnings','entity_type','entity_id','fingerprint'
];
protected $casts = [
'raw_data' => 'array',
'mapped_data' => 'array',
'errors' => 'array',
'warnings' => 'array',
];
public function import(): BelongsTo
{
return $this->belongsTo(Import::class);
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class ImportTemplate extends Model
{
use HasFactory;
protected $fillable = [
'uuid', 'name', 'description', 'source_type', 'default_record_type', 'sample_headers', 'user_id', 'client_id', 'is_active', 'meta'
];
protected $casts = [
'sample_headers' => 'array',
'meta' => 'array',
'is_active' => 'boolean',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function client(): BelongsTo
{
return $this->belongsTo(Client::class);
}
public function mappings(): HasMany
{
return $this->hasMany(ImportTemplateMapping::class);
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ImportTemplateMapping extends Model
{
use HasFactory;
protected $fillable = [
'import_template_id', 'entity', 'source_column', 'target_field', 'transform', 'apply_mode', 'options', 'position'
];
protected $casts = [
'options' => 'array',
];
public function template(): BelongsTo
{
return $this->belongsTo(ImportTemplate::class, 'import_template_id');
}
}

View File

@ -3,6 +3,7 @@
namespace App\Models\Person;
use App\Traits\Uuid;
use Illuminate\Support\Str;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -52,6 +53,10 @@ protected static function booted(){
if(!isset($person->user_id)){
$person->user_id = auth()->id();
}
// Ensure a unique 6-character alphanumeric 'nu' is set globally on create
if (empty($person->nu)) {
$person->nu = static::generateUniqueNu();
}
});
}
@ -88,6 +93,13 @@ public function addresses(): HasMany
->orderBy('id');
}
public function emails(): HasMany
{
return $this->hasMany(\App\Models\Email::class, 'person_id')
->where('is_active', '=', 1)
->orderBy('id');
}
public function group(): BelongsTo
{
return $this->belongsTo(\App\Models\Person\PersonGroup::class, 'group_id');
@ -108,4 +120,14 @@ public function clientCase(): HasOne
return $this->hasOne(\App\Models\ClientCase::class);
}
/**
* Generate a unique 6-character alphanumeric identifier for 'nu'.
*/
protected static function generateUniqueNu(): string
{
do {
$nu = Str::random(6); // [A-Za-z0-9]
} while (static::where('nu', $nu)->exists());
return $nu;
}
}

View File

@ -0,0 +1,65 @@
<?php
namespace App\Services;
class CsvImportService
{
/**
* Read the first line of a file; returns null on failure.
*/
public function readFirstLine(string $path): ?string
{
$fh = @fopen($path, 'r');
if (!$fh) return null;
$line = fgets($fh);
fclose($fh);
return $line === false ? null : $line;
}
/**
* Detect delimiter and return columns for first row.
* If $hasHeader is false, returns positional indices instead of header names.
* Returns [delimiter, columns].
*/
public function detectColumnsFromCsv(string $path, bool $hasHeader): array
{
// Use actual tab character for TSV; keep other common delimiters
$delims = [',',';','|',"\t"];
$bestDelim = ',';
$bestCols = [];
$firstLine = $this->readFirstLine($path);
if ($firstLine === null) {
return [$bestDelim, []];
}
$maxCount = 0;
foreach ($delims as $d) {
$row = str_getcsv($firstLine, $d);
$count = is_array($row) ? count($row) : 0;
if ($count > $maxCount) {
$maxCount = $count;
$bestDelim = $d;
$bestCols = $row;
}
}
if (!$hasHeader) {
// return positional indices 0..N-1
$cols = [];
for ($i = 0; $i < $maxCount; $i++) {
$cols[] = (string) $i;
}
return [$bestDelim, $cols];
}
// Clean header names
$clean = array_map(function ($v) {
$v = trim((string) $v);
$v = preg_replace('/\s+/', ' ', $v);
return $v;
}, $bestCols);
return [$bestDelim, $clean];
}
}

View File

@ -0,0 +1,795 @@
<?php
namespace App\Services;
use App\Models\Import;
use App\Models\ImportEvent;
use App\Models\ImportRow;
use App\Models\Account;
use App\Models\Contract;
use App\Models\Client;
use App\Models\ClientCase;
use App\Models\Email;
use App\Models\Person\Person;
use App\Models\Person\PersonGroup;
use App\Models\Person\PersonType;
use App\Models\Person\PersonAddress;
use App\Models\Person\PersonPhone;
use App\Models\Person\AddressType;
use App\Models\Person\PhoneType;
use App\Models\ContractType;
use App\Models\AccountType;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
class ImportProcessor
{
/**
* Process an import and apply basic dedup checks.
* Returns summary counts.
*/
public function process(Import $import, ?Authenticatable $user = null): array
{
$started = now();
$total = 0; $skipped = 0; $imported = 0; $invalid = 0;
$fh = null;
// Only CSV/TSV supported in this pass
if (!in_array($import->source_type, ['csv','txt'])) {
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'event' => 'processing_skipped',
'level' => 'warning',
'message' => 'Only CSV/TXT supported in this pass.',
]);
$import->update(['status' => 'completed', 'finished_at' => now()]);
return [ 'ok' => true, 'status' => $import->status, 'counts' => compact('total','skipped','imported','invalid') ];
}
// Get mappings for this import (with apply_mode)
$mappings = DB::table('import_mappings')
->where('import_id', $import->id)
->get(['source_column','target_field','transform','apply_mode','options']);
$header = $import->meta['columns'] ?? null;
$delimiter = $import->meta['detected_delimiter'] ?? ',';
$hasHeader = (bool) ($import->meta['has_header'] ?? true);
$path = Storage::disk($import->disk)->path($import->path);
// Parse file and create import_rows with mapped_data
$fh = @fopen($path, 'r');
if (!$fh) {
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'event' => 'processing_failed',
'level' => 'error',
'message' => 'Unable to open file for reading.',
]);
$import->update(['status' => 'failed', 'failed_at' => now()]);
return [ 'ok' => false, 'status' => $import->status ];
}
try {
DB::beginTransaction();
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'event' => 'processing_started',
'level' => 'info',
'message' => 'Processing started.',
]);
$rowNum = 0;
if ($hasHeader) {
$first = fgetcsv($fh, 0, $delimiter);
$rowNum++;
// use actual detected header if not already stored
if (!$header) {
$header = array_map(fn($v) => trim((string) $v), $first ?: []);
}
}
while (($row = fgetcsv($fh, 0, $delimiter)) !== false) {
$rowNum++;
$total++;
$rawAssoc = $this->buildRowAssoc($row, $header);
[$recordType, $mapped] = $this->applyMappings($rawAssoc, $mappings);
$importRow = ImportRow::create([
'import_id' => $import->id,
'row_number' => $rowNum,
'record_type' => $recordType,
'raw_data' => $rawAssoc,
'mapped_data' => $mapped,
'status' => 'valid',
]);
// Contracts
$contractResult = null;
if (isset($mapped['contract'])) {
$contractResult = $this->upsertContractChain($import, $mapped, $mappings);
if ($contractResult['action'] === 'skipped') {
$skipped++;
$importRow->update(['status' => 'skipped']);
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'import_row_id' => $importRow->id,
'event' => 'row_skipped',
'level' => 'info',
'message' => $contractResult['message'] ?? 'Skipped contract (no changes).',
]);
} elseif (in_array($contractResult['action'], ['inserted','updated'])) {
$imported++;
$importRow->update([
'status' => 'imported',
'entity_type' => Contract::class,
'entity_id' => $contractResult['contract']->id,
]);
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'import_row_id' => $importRow->id,
'event' => 'row_imported',
'level' => 'info',
'message' => ucfirst($contractResult['action']).' contract',
'context' => [ 'id' => $contractResult['contract']->id ],
]);
} else {
$invalid++;
$importRow->update(['status' => 'invalid', 'errors' => [$contractResult['message'] ?? 'Contract processing failed']]);
}
}
// Accounts
$accountResult = null;
if (isset($mapped['account'])) {
$accountResult = $this->upsertAccount($import, $mapped, $mappings);
if ($accountResult['action'] === 'skipped') {
$skipped++;
$importRow->update(['status' => 'skipped']);
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'import_row_id' => $importRow->id,
'event' => 'row_skipped',
'level' => 'info',
'message' => $accountResult['message'] ?? 'Skipped (no changes).',
]);
} elseif ($accountResult['action'] === 'inserted' || $accountResult['action'] === 'updated') {
$imported++;
$importRow->update([
'status' => 'imported',
'entity_type' => Account::class,
'entity_id' => $accountResult['account']->id,
]);
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'import_row_id' => $importRow->id,
'event' => 'row_imported',
'level' => 'info',
'message' => ucfirst($accountResult['action']).' account',
'context' => [ 'id' => $accountResult['account']->id ],
]);
} else {
$invalid++;
$importRow->update(['status' => 'invalid', 'errors' => ['Unhandled result']]);
}
}
// Contacts: resolve person strictly via Contract -> ClientCase -> Person, contacts, or identifiers
$personIdForRow = null;
// Prefer person from contract created/updated above
if ($contractResult && isset($contractResult['contract']) && $contractResult['contract'] instanceof Contract) {
$ccId = $contractResult['contract']->client_case_id;
$personIdForRow = ClientCase::where('id', $ccId)->value('person_id');
}
// If we have a contract reference, resolve existing contract for this client and derive person
if (!$personIdForRow && $import->client_id && !empty($mapped['contract']['reference'] ?? null)) {
$existingContract = Contract::query()
->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
->where('client_cases.client_id', $import->client_id)
->where('contracts.reference', $mapped['contract']['reference'])
->select('contracts.client_case_id')
->first();
if ($existingContract) {
$personIdForRow = ClientCase::where('id', $existingContract->client_case_id)->value('person_id');
}
}
// If account processing created/resolved a contract, derive person via its client_case
if (!$personIdForRow && $accountResult) {
if (isset($accountResult['contract']) && $accountResult['contract'] instanceof Contract) {
$ccId = $accountResult['contract']->client_case_id;
$personIdForRow = ClientCase::where('id', $ccId)->value('person_id');
} elseif (isset($accountResult['contract_id'])) {
$ccId = Contract::where('id', $accountResult['contract_id'])->value('client_case_id');
if ($ccId) {
$personIdForRow = ClientCase::where('id', $ccId)->value('person_id');
}
}
}
// Resolve by contact values next
if (!$personIdForRow) {
$emailVal = trim((string)($mapped['email']['value'] ?? ''));
if ($emailVal !== '') {
$personIdForRow = Email::where('value', $emailVal)->value('person_id');
}
}
if (!$personIdForRow) {
$phoneNu = trim((string)($mapped['phone']['nu'] ?? ''));
if ($phoneNu !== '') {
$personIdForRow = PersonPhone::where('nu', $phoneNu)->value('person_id');
}
}
if (!$personIdForRow) {
$addrLine = trim((string)($mapped['address']['address'] ?? ''));
if ($addrLine !== '') {
$personIdForRow = PersonAddress::where('address', $addrLine)->value('person_id');
}
}
// Try identifiers from mapped person (no creation yet)
if (!$personIdForRow && !empty($mapped['person'] ?? [])) {
$personIdForRow = $this->findPersonIdByIdentifiers($mapped['person']);
}
// Finally, if still unknown and person fields provided, create
if (!$personIdForRow && !empty($mapped['person'] ?? [])) {
$personIdForRow = $this->findOrCreatePersonId($mapped['person']);
}
$contactChanged = false;
if ($personIdForRow) {
if (!empty($mapped['email'] ?? [])) {
$r = $this->upsertEmail($personIdForRow, $mapped['email'], $mappings);
if (in_array($r['action'] ?? 'skipped', ['inserted','updated'])) { $contactChanged = true; }
}
if (!empty($mapped['address'] ?? [])) {
$r = $this->upsertAddress($personIdForRow, $mapped['address'], $mappings);
if (in_array($r['action'] ?? 'skipped', ['inserted','updated'])) { $contactChanged = true; }
}
if (!empty($mapped['phone'] ?? [])) {
$r = $this->upsertPhone($personIdForRow, $mapped['phone'], $mappings);
if (in_array($r['action'] ?? 'skipped', ['inserted','updated'])) { $contactChanged = true; }
}
}
if (!isset($mapped['contract']) && !isset($mapped['account'])) {
if ($contactChanged) {
$imported++;
$importRow->update([
'status' => 'imported',
'entity_type' => Person::class,
'entity_id' => $personIdForRow,
]);
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'import_row_id' => $importRow->id,
'event' => 'row_imported',
'level' => 'info',
'message' => 'Contacts upserted',
'context' => [ 'person_id' => $personIdForRow ],
]);
} else {
$skipped++;
$importRow->update(['status' => 'skipped']);
}
}
}
fclose($fh);
$import->update([
'status' => 'completed',
'finished_at' => now(),
'total_rows' => $total,
'imported_rows' => $imported,
'invalid_rows' => $invalid,
'valid_rows' => $total - $invalid,
]);
DB::commit();
return [
'ok' => true,
'status' => $import->status,
'counts' => compact('total','skipped','imported','invalid'),
];
} catch (\Throwable $e) {
if (is_resource($fh)) { @fclose($fh); }
DB::rollBack();
// Mark failed and log after rollback (so no partial writes persist)
$import->refresh();
$import->update(['status' => 'failed', 'failed_at' => now()]);
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'event' => 'processing_failed',
'level' => 'error',
'message' => $e->getMessage(),
]);
return [ 'ok' => false, 'status' => 'failed', 'error' => $e->getMessage() ];
}
}
private function buildRowAssoc(array $row, ?array $header): array
{
if (!$header) {
// positional mapping: 0..N-1
$assoc = [];
foreach ($row as $i => $v) { $assoc[(string)$i] = $v; }
return $assoc;
}
$assoc = [];
foreach ($header as $i => $name) {
$assoc[$name] = $row[$i] ?? null;
}
return $assoc;
}
private function applyMappings(array $raw, $mappings): array
{
$recordType = null;
$mapped = [];
foreach ($mappings as $map) {
$src = $map->source_column;
$target = $map->target_field;
if (!$target) continue;
$value = $raw[$src] ?? null;
// very basic transforms
if ($map->transform === 'trim') { $value = is_string($value) ? trim($value) : $value; }
if ($map->transform === 'upper') { $value = is_string($value) ? strtoupper($value) : $value; }
// detect record type from first segment, e.g., "account.balance_amount"
$parts = explode('.', $target);
if (!$recordType && isset($parts[0])) {
$recordType = $parts[0];
}
// build nested array by dot notation
$this->arraySetDot($mapped, $target, $value);
}
return [$recordType, $mapped];
}
private function arraySetDot(array &$arr, string $path, $value): void
{
$keys = explode('.', $path);
$ref =& $arr;
foreach ($keys as $k) {
if (!isset($ref[$k]) || !is_array($ref[$k])) { $ref[$k] = []; }
$ref =& $ref[$k];
}
$ref = $value;
}
private function upsertAccount(Import $import, array $mapped, $mappings): array
{
$clientId = $import->client_id; // may be null, used for contract lookup/creation
$acc = $mapped['account'] ?? [];
$contractId = $acc['contract_id'] ?? null;
$reference = $acc['reference'] ?? null;
// If contract_id not provided, attempt to resolve by contract reference for the selected client
if (!$contractId) {
$contractRef = $acc['contract_reference'] ?? ($mapped['contract']['reference'] ?? null);
if ($clientId && $contractRef) {
// 1) Search existing contract by reference for that client (across its client cases)
$existingContract = Contract::query()
->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
->where('client_cases.client_id', $clientId)
->where('contracts.reference', $contractRef)
->select('contracts.*')
->first();
if ($existingContract) {
$contractId = $existingContract->id;
} else {
// 2) Not found: attempt to resolve debtor via identifiers or provided person, then create case+contract
// Try strong identifiers first
$personId = $this->findPersonIdByIdentifiers($mapped['person'] ?? []);
// Create from provided person data if unresolved
if (!$personId) {
$personId = $this->findOrCreatePersonId($mapped['person'] ?? []);
}
// Last resort, create minimal
if (!$personId) {
$personId = $this->createMinimalPersonId();
}
// Use the selected client for this import to tie the case/contract
if (!$clientId) {
return ['action' => 'skipped', 'message' => 'Client required to create contract'];
}
$resolvedClientId = $clientId;
$clientCaseId = $this->findOrCreateClientCaseId($resolvedClientId, $personId);
// Build minimal/new contract
$contractFields = $mapped['contract'] ?? [];
$newContractData = [
'client_case_id' => $clientCaseId,
'reference' => $contractRef,
];
foreach (['start_date','end_date','description','type_id'] as $k) {
if (array_key_exists($k, $contractFields) && !is_null($contractFields[$k])) {
$newContractData[$k] = $contractFields[$k];
}
}
// ensure required fields on contracts
$newContractData['start_date'] = $newContractData['start_date'] ?? now()->toDateString();
$newContractData['type_id'] = $newContractData['type_id'] ?? $this->getDefaultContractTypeId();
$createdContract = Contract::create($newContractData);
$contractId = $createdContract->id;
}
if ($contractId) {
$acc['contract_id'] = $contractId;
$mapped['account'] = $acc;
}
}
}
// Default account.reference to contract reference if missing
if (!$reference) {
$contractRef = $acc['contract_reference'] ?? ($mapped['contract']['reference'] ?? null);
if ($contractRef) {
$reference = $contractRef;
$acc['reference'] = $reference;
$mapped['account'] = $acc;
}
}
if (!$contractId || !$reference) {
return ['action' => 'skipped', 'message' => 'Missing contract_id/reference'];
}
$existing = Account::query()
->where('contract_id', $contractId)
->where('reference', $reference)
->first();
// Build applyable data based on apply_mode
$applyInsert = [];
$applyUpdate = [];
foreach ($mappings as $map) {
if (!$map->target_field) continue;
$parts = explode('.', $map->target_field);
if ($parts[0] !== 'account') continue;
$field = $parts[1] ?? null;
if (!$field) continue;
$value = $acc[$field] ?? null;
$mode = $map->apply_mode ?? 'both';
if (in_array($mode, ['insert','both'])) { $applyInsert[$field] = $value; }
if (in_array($mode, ['update','both'])) { $applyUpdate[$field] = $value; }
}
if ($existing) {
if (empty($applyUpdate)) {
return ['action' => 'skipped', 'message' => 'No fields marked for update'];
}
// Only update fields that are set; skip nulls to avoid wiping unintentionally
$changes = array_filter($applyUpdate, fn($v) => !is_null($v));
if (empty($changes)) {
return ['action' => 'skipped', 'message' => 'No non-null changes'];
}
$existing->fill($changes);
$existing->save();
// also include contract hints for downstream contact resolution
return ['action' => 'updated', 'account' => $existing, 'contract_id' => $contractId];
} else {
if (empty($applyInsert)) {
return ['action' => 'skipped', 'message' => 'No fields marked for insert'];
}
$data = array_filter($applyInsert, fn($v) => !is_null($v));
$data['contract_id'] = $contractId;
$data['reference'] = $reference;
// ensure required defaults
$data['type_id'] = $data['type_id'] ?? $this->getDefaultAccountTypeId();
if (!array_key_exists('active', $data)) { $data['active'] = 1; }
$created = Account::create($data);
return ['action' => 'inserted', 'account' => $created, 'contract_id' => $contractId];
}
}
private function findPersonIdByIdentifiers(array $p): ?int
{
$tax = $p['tax_number'] ?? null;
if ($tax) {
$found = Person::where('tax_number', $tax)->first();
if ($found) return $found->id;
}
$ssn = $p['social_security_number'] ?? null;
if ($ssn) {
$found = Person::where('social_security_number', $ssn)->first();
if ($found) return $found->id;
}
return null;
}
private function upsertContractChain(Import $import, array $mapped, $mappings): array
{
$contractData = $mapped['contract'] ?? [];
$reference = $contractData['reference'] ?? null;
if (!$reference) {
return ['action' => 'invalid', 'message' => 'Missing contract.reference'];
}
// Determine client_case_id: prefer provided, else derive via person/client
$clientCaseId = $contractData['client_case_id'] ?? null;
$clientId = $import->client_id; // may be null
// Try to find existing contract EARLY by (client_id, reference) across all cases to prevent duplicates
$existing = null;
if ($clientId) {
$existing = Contract::query()
->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
->where('client_cases.client_id', $clientId)
->where('contracts.reference', $reference)
->select('contracts.*')
->first();
}
// If not found by client+reference and a specific client_case_id is provided, try that too
if (!$existing && $clientCaseId) {
$existing = Contract::query()
->where('client_case_id', $clientCaseId)
->where('reference', $reference)
->first();
}
// If we still need to insert, we must resolve clientCaseId, but avoid creating new person/case unless necessary
if (!$existing && !$clientCaseId) {
// Resolve by identifiers or provided person; do not use Client->person
$personId = null;
if (!empty($mapped['person'] ?? [])) {
$personId = $this->findPersonIdByIdentifiers($mapped['person']);
if (!$personId) {
$personId = $this->findOrCreatePersonId($mapped['person']);
}
}
// As a last resort, create a minimal person for this client
if ($clientId && !$personId) {
$personId = $this->createMinimalPersonId();
}
if ($clientId && $personId) {
$clientCaseId = $this->findOrCreateClientCaseId($clientId, $personId);
} elseif ($personId) {
// require an import client to attach case/contract
return ['action' => 'invalid', 'message' => 'Import must be linked to a client to create a case'];
} else {
return ['action' => 'invalid', 'message' => 'Unable to resolve client_case (need import client)'];
}
}
// Build applyable data based on apply_mode
$applyInsert = [];
$applyUpdate = [];
foreach ($mappings as $map) {
if (!$map->target_field) continue;
$parts = explode('.', $map->target_field);
if ($parts[0] !== 'contract') continue;
$field = $parts[1] ?? null;
if (!$field) continue;
$value = $contractData[$field] ?? null;
$mode = $map->apply_mode ?? 'both';
if (in_array($mode, ['insert','both'])) { $applyInsert[$field] = $value; }
if (in_array($mode, ['update','both'])) { $applyUpdate[$field] = $value; }
}
if ($existing) {
if (empty($applyUpdate)) {
return ['action' => 'skipped', 'message' => 'No contract fields marked for update'];
}
$changes = array_filter($applyUpdate, fn($v) => !is_null($v));
if (empty($changes)) {
return ['action' => 'skipped', 'message' => 'No non-null contract changes'];
}
$existing->fill($changes);
$existing->save();
return ['action' => 'updated', 'contract' => $existing];
} else {
if (empty($applyInsert)) {
return ['action' => 'skipped', 'message' => 'No contract fields marked for insert'];
}
$data = array_filter($applyInsert, fn($v) => !is_null($v));
$data['client_case_id'] = $clientCaseId;
$data['reference'] = $reference;
// ensure required defaults
$data['start_date'] = $data['start_date'] ?? now()->toDateString();
$data['type_id'] = $data['type_id'] ?? $this->getDefaultContractTypeId();
$created = Contract::create($data);
return ['action' => 'inserted', 'contract' => $created];
}
}
private function findOrCreatePersonId(array $p): ?int
{
// Basic dedup: by tax_number, ssn, else full_name
$query = Person::query();
if (!empty($p['tax_number'] ?? null)) {
$found = $query->where('tax_number', $p['tax_number'])->first();
if ($found) return $found->id;
}
if (!empty($p['social_security_number'] ?? null)) {
$found = Person::where('social_security_number', $p['social_security_number'])->first();
if ($found) return $found->id;
}
// Do NOT use full_name as an identifier
// Create person if any fields present; ensure required foreign keys
if (!empty($p)) {
$data = [];
foreach (['first_name','last_name','full_name','tax_number','social_security_number','birthday','gender','description','group_id','type_id'] as $k) {
if (array_key_exists($k, $p)) $data[$k] = $p[$k];
}
// derive full_name if missing
if (empty($data['full_name'])) {
$fn = trim((string)($data['first_name'] ?? ''));
$ln = trim((string)($data['last_name'] ?? ''));
if ($fn || $ln) $data['full_name'] = trim($fn.' '.$ln);
}
// ensure required group/type ids
$data['group_id'] = $data['group_id'] ?? $this->getDefaultPersonGroupId();
$data['type_id'] = $data['type_id'] ?? $this->getDefaultPersonTypeId();
$created = Person::create($data);
return $created->id;
}
return null;
}
private function createMinimalPersonId(): int
{
return Person::create([
'group_id' => $this->getDefaultPersonGroupId(),
'type_id' => $this->getDefaultPersonTypeId(),
// names and identifiers can be null; 'nu' will be auto-generated (unique 6-char)
])->id;
}
private function getDefaultPersonGroupId(): int
{
return (int) (PersonGroup::min('id') ?? 1);
}
private function getDefaultPersonTypeId(): int
{
return (int) (PersonType::min('id') ?? 1);
}
private function getDefaultContractTypeId(): int
{
return (int) (ContractType::min('id') ?? 1);
}
private function getDefaultAccountTypeId(): int
{
return (int) (AccountType::min('id') ?? 1);
}
private function getDefaultAddressTypeId(): int
{
return (int) (AddressType::min('id') ?? 1);
}
private function getDefaultPhoneTypeId(): int
{
return (int) (PhoneType::min('id') ?? 1);
}
// Removed getExistingPersonIdForClient: resolution should come from Contract -> ClientCase -> Person or identifiers
private function findOrCreateClientId(int $personId): int
{
$client = Client::where('person_id', $personId)->first();
if ($client) return $client->id;
return Client::create(['person_id' => $personId])->id;
}
private function findOrCreateClientCaseId(int $clientId, int $personId): int
{
$cc = ClientCase::where('client_id', $clientId)->where('person_id', $personId)->first();
if ($cc) return $cc->id;
return ClientCase::create(['client_id' => $clientId, 'person_id' => $personId])->id;
}
private function upsertEmail(int $personId, array $emailData, $mappings): array
{
$value = trim((string)($emailData['value'] ?? ''));
if ($value === '') return ['action' => 'skipped', 'message' => 'No email value'];
$existing = Email::where('person_id', $personId)->where('value', $value)->first();
$applyInsert = [];
$applyUpdate = [];
foreach ($mappings as $map) {
if (!$map->target_field) continue;
$parts = explode('.', $map->target_field);
if ($parts[0] !== 'email') continue;
$field = $parts[1] ?? null; if (!$field) continue;
$val = $emailData[$field] ?? null;
$mode = $map->apply_mode ?? 'both';
if (in_array($mode, ['insert','both'])) { $applyInsert[$field] = $val; }
if (in_array($mode, ['update','both'])) { $applyUpdate[$field] = $val; }
}
if ($existing) {
$changes = array_filter($applyUpdate, fn($v) => !is_null($v));
if (empty($changes)) return ['action' => 'skipped', 'message' => 'No email updates'];
$existing->fill($changes);
$existing->save();
return ['action' => 'updated', 'email' => $existing];
} else {
if (empty($applyInsert)) return ['action' => 'skipped', 'message' => 'No email fields for insert'];
$data = array_filter($applyInsert, fn($v) => !is_null($v));
$data['person_id'] = $personId;
if (!array_key_exists('is_active', $data)) $data['is_active'] = true;
$created = Email::create($data);
return ['action' => 'inserted', 'email' => $created];
}
}
private function upsertAddress(int $personId, array $addrData, $mappings): array
{
$addressLine = trim((string)($addrData['address'] ?? ''));
if ($addressLine === '') return ['action' => 'skipped', 'message' => 'No address value'];
// Default country SLO if not provided
if (!isset($addrData['country']) || $addrData['country'] === null || $addrData['country'] === '') {
$addrData['country'] = 'SLO';
}
$existing = PersonAddress::where('person_id', $personId)->where('address', $addressLine)->first();
$applyInsert = [];
$applyUpdate = [];
foreach ($mappings as $map) {
if (!$map->target_field) continue;
$parts = explode('.', $map->target_field);
if ($parts[0] !== 'address') continue;
$field = $parts[1] ?? null; if (!$field) continue;
$val = $addrData[$field] ?? null;
$mode = $map->apply_mode ?? 'both';
if (in_array($mode, ['insert','both'])) { $applyInsert[$field] = $val; }
if (in_array($mode, ['update','both'])) { $applyUpdate[$field] = $val; }
}
if ($existing) {
$changes = array_filter($applyUpdate, fn($v) => !is_null($v));
if (empty($changes)) return ['action' => 'skipped', 'message' => 'No address updates'];
$existing->fill($changes);
$existing->save();
return ['action' => 'updated', 'address' => $existing];
} else {
if (empty($applyInsert)) return ['action' => 'skipped', 'message' => 'No address fields for insert'];
$data = array_filter($applyInsert, fn($v) => !is_null($v));
$data['person_id'] = $personId;
$data['country'] = $data['country'] ?? 'SLO';
$data['type_id'] = $data['type_id'] ?? $this->getDefaultAddressTypeId();
$created = PersonAddress::create($data);
return ['action' => 'inserted', 'address' => $created];
}
}
private function upsertPhone(int $personId, array $phoneData, $mappings): array
{
$nu = trim((string)($phoneData['nu'] ?? ''));
if ($nu === '') return ['action' => 'skipped', 'message' => 'No phone value'];
$existing = PersonPhone::where('person_id', $personId)->where('nu', $nu)->first();
$applyInsert = [];
$applyUpdate = [];
foreach ($mappings as $map) {
if (!$map->target_field) continue;
$parts = explode('.', $map->target_field);
if ($parts[0] !== 'phone') continue;
$field = $parts[1] ?? null; if (!$field) continue;
$val = $phoneData[$field] ?? null;
$mode = $map->apply_mode ?? 'both';
if (in_array($mode, ['insert','both'])) { $applyInsert[$field] = $val; }
if (in_array($mode, ['update','both'])) { $applyUpdate[$field] = $val; }
}
if ($existing) {
$changes = array_filter($applyUpdate, fn($v) => !is_null($v));
if (empty($changes)) return ['action' => 'skipped', 'message' => 'No phone updates'];
$existing->fill($changes);
$existing->save();
return ['action' => 'updated', 'phone' => $existing];
} else {
if (empty($applyInsert)) return ['action' => 'skipped', 'message' => 'No phone fields for insert'];
$data = array_filter($applyInsert, fn($v) => !is_null($v));
$data['person_id'] = $personId;
$data['type_id'] = $data['type_id'] ?? $this->getDefaultPhoneTypeId();
$created = PersonPhone::create($data);
return ['action' => 'inserted', 'phone' => $created];
}
}
}

19
config/files.php Normal file
View File

@ -0,0 +1,19 @@
<?php
return [
// Path to LibreOffice soffice binary. On Windows, set to full path like:
// C:\\Program Files\\LibreOffice\\program\\soffice.exe
'libreoffice_bin' => env('LIBREOFFICE_BIN', 'soffice'),
// Disk used to store generated previews (PDFs)
'preview_disk' => env('FILES_PREVIEW_DISK', 'public'),
// Base directory within the disk for previews
'preview_base' => env('FILES_PREVIEW_BASE', 'previews/cases'),
// Whether to enable scheduled pruning of old previews
'enable_preview_prune' => env('FILES_ENABLE_PREVIEW_PRUNE', true),
// How many days to retain previews before pruning (when pruning enabled)
'preview_retention_days' => env('FILES_PREVIEW_RETENTION_DAYS', 90),
];

View File

@ -17,7 +17,15 @@ class PersonFactory extends Factory
public function definition(): array
{
return [
//
'first_name' => $this->faker->firstName(),
'last_name' => $this->faker->lastName(),
'full_name' => fn(array $attrs) => trim(($attrs['first_name'] ?? '').' '.($attrs['last_name'] ?? '')),
'gender' => $this->faker->randomElement(['m','w']),
'birthday' => $this->faker->optional()->date(),
'tax_number' => $this->faker->optional()->bothify('########'),
'social_security_number' => $this->faker->optional()->bothify('#########'),
'description' => $this->faker->optional()->sentence(),
// group_id/type_id are required; keep null here and let tests/seeds assign or rely on defaults in code paths that use factories
];
}
}

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('accounts', function (Blueprint $table) {
$table->decimal("initial_amount", 20, 4)->default(0);
$table->decimal("balance_amount", 20, 4)->default(0);
$table->date("promise_date")->nullable();
$table->index('balance_amount');
$table->index('promise_date');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
//
}
};

View File

@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('object_types', function (Blueprint $table) {
$table->id();
$table->string('name',50);
$table->string('description',125)->nullable();
$table->softDeletes();
$table->timestamps();
});
Schema::create('objects', function (Blueprint $table) {
$table->id();
$table->string('reference', 125)->nullable();
$table->string('name', 255);
$table->string('description', 255)->nullable();
// If you keep the column name as 'type_id', specify the table explicitly
$table->foreignId('type_id')->constrained('object_types')->nullOnDelete();
// Indexes for faster lookups
$table->softDeletes();
$table->timestamps();
$table->index('reference');
$table->index('type_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('objects');
Schema::dropIfExists('object_types');
}
};

View File

@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('bank_accounts', function (Blueprint $table) {
$table->id();
// Ownership (Person-specific). Change to your actual Person table name.
$table->foreignId('person_id')->constrained('person')->cascadeOnDelete();
// Account details
$table->string('bank_name', 100);
$table->string('iban', 34)->nullable();
$table->string('bic_swift', 11)->nullable();
$table->string('account_number', 34)->nullable();
$table->string('routing_number', 20)->nullable();
$table->char('currency', 3)->default('EUR');
$table->char('country_code', 2)->nullable();
$table->string('holder_name', 125)->nullable();
// Status and lifecycle
$table->boolean('is_active')->default(true);
// Misc
$table->text('notes')->nullable();
$table->json('meta')->nullable();
// Indexes
$table->index('person_id');
$table->index('iban');
$table->softDeletes();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('bank_accounts');
}
};

View File

@ -0,0 +1,47 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('emails', function (Blueprint $table) {
$table->id();
$table->foreignId('person_id')->constrained('person')->cascadeOnDelete();
// The email address
$table->string('value', 255);
// Optional label like "work", "home", etc.
$table->string('label', 50)->nullable();
// Mark a preferred email for the person (enforce at most one in app logic)
$table->boolean('is_primary')->default(false);
// Whether this email is considered currently active/usable
$table->boolean('is_active')->default(true);
// Whether validation checks passed (syntax/deliverability)
$table->boolean('valid')->default(true);
// When the email was verified (e.g., via confirmation link)
$table->timestamp('verified_at')->nullable();
// JSON columns for notification preferences and arbitrary metadata
$table->json('preferences')->nullable();
$table->json('meta')->nullable();
// Soft delete support
$table->softDeletes();
// Avoid duplicate emails per person among non-deleted records
$table->unique(['person_id', 'value', 'deleted_at'], 'emails_person_value_unique');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('emails');
}
};

View File

@ -0,0 +1,64 @@
<?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::create('imports', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->unique();
// Who initiated the import
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
// Optional template applied to this import (FK added in a later migration to avoid ordering issues)
$table->foreignId('import_template_id')->nullable();
// Optional client this import is for (many imports per client)
$table->foreignId('client_id')->nullable()->constrained('clients')->nullOnDelete();
// File/source metadata
$table->string('source_type', 12); // csv|xml|xls|xlsx|json
$table->string('file_name', 255);
$table->string('original_name', 255)->nullable();
$table->string('disk', 50)->default('local');
$table->string('path', 2048);
$table->unsignedBigInteger('size')->nullable(); // bytes
$table->string('sheet_name', 64)->nullable(); // for Excel
// Progress/status
$table->string('status', 20)->default('uploaded'); // uploaded|parsing|parsed|validating|importing|completed|failed
$table->unsignedInteger('total_rows')->default(0);
$table->unsignedInteger('valid_rows')->default(0);
$table->unsignedInteger('invalid_rows')->default(0);
$table->unsignedInteger('imported_rows')->default(0);
$table->timestamp('started_at')->nullable();
$table->timestamp('finished_at')->nullable();
$table->timestamp('failed_at')->nullable();
// Diagnostics and flexibility
$table->json('error_summary')->nullable();
$table->json('meta')->nullable();
// Helpful indexes
$table->index('user_id');
$table->index('import_template_id');
$table->index('status');
$table->index('client_id');
$table->index('source_type');
$table->index(['disk', 'path']);
$table->timestamps();
$table->softDeletes();
});
}
public function down(): void
{
Schema::dropIfExists('imports');
}
};

View File

@ -0,0 +1,47 @@
<?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::create('import_rows', function (Blueprint $table) {
$table->id();
$table->foreignId('import_id')->constrained('imports')->cascadeOnDelete();
$table->unsignedInteger('row_number');
$table->string('sheet_name', 64)->nullable();
// Type of record represented in this row (person, account, etc.)
$table->string('record_type', 50)->nullable();
// Data and results
$table->json('raw_data')->nullable();
$table->json('mapped_data')->nullable();
$table->string('status', 20)->default('pending'); // pending|valid|invalid|imported|skipped
$table->json('errors')->nullable();
$table->json('warnings')->nullable();
// Link to created entity (optional, polymorphic)
$table->nullableMorphs('entity'); // entity_type + entity_id
// Dedup/trace
$table->string('fingerprint', 64)->nullable()->index();
// Helpful indexes
$table->index(['import_id', 'status']);
$table->index(['import_id', 'row_number']);
$table->index('record_type');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('import_rows');
}
};

View File

@ -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
{
public function up(): void
{
Schema::create('import_mappings', function (Blueprint $table) {
$table->id();
$table->foreignId('import_id')->constrained('imports')->cascadeOnDelete();
// Column/header from the source file and the target field in the system
$table->string('source_column', 255);
$table->string('target_field', 255)->nullable();
$table->string('transform', 50)->nullable(); // e.g., trim|upper|date:dd.MM.yyyy
$table->json('options')->nullable(); // any extra config for mapping/transforms
// Indexes
$table->index(['import_id', 'source_column']);
$table->index(['import_id', 'target_field']);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('import_mappings');
}
};

View File

@ -0,0 +1,38 @@
<?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::create('import_events', function (Blueprint $table) {
$table->id();
$table->foreignId('import_id')->constrained('imports')->cascadeOnDelete();
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
// Event details
$table->string('event', 50); // created|parsing_started|parsed|validating|importing|completed|failed|retry|...
$table->string('level', 10)->default('info'); // info|warning|error
$table->text('message')->nullable();
$table->json('context')->nullable();
// An optional pointer to a specific row related to the event
$table->foreignId('import_row_id')->nullable()->constrained('import_rows')->nullOnDelete();
// Indexes
$table->index(['import_id', 'event']);
$table->index(['import_id', 'level']);
$table->index('user_id');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('import_events');
}
};

View File

@ -0,0 +1,43 @@
<?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::create('import_templates', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->unique();
$table->string('name', 100);
$table->string('description', 255)->nullable();
// What kind of source this template is for (csv|xml|xls|xlsx|json)
$table->string('source_type', 12)->default('csv');
// Defaults for records handled by this template (e.g., person, account)
$table->string('default_record_type', 50)->nullable();
// Optional sample header row for UI assistance
$table->json('sample_headers')->nullable();
// Ownership and lifecycle
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
$table->boolean('is_active')->default(true);
$table->json('meta')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index(['source_type', 'is_active']);
});
}
public function down(): void
{
Schema::dropIfExists('import_templates');
}
};

View File

@ -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
{
public function up(): void
{
Schema::create('import_template_mappings', function (Blueprint $table) {
$table->id();
$table->foreignId('import_template_id')->constrained('import_templates')->cascadeOnDelete();
$table->string('source_column', 255);
$table->string('target_field', 255)->nullable();
$table->string('transform', 50)->nullable();
$table->json('options')->nullable();
$table->unsignedInteger('position')->nullable(); // order in the header
$table->timestamps();
$table->index(['import_template_id', 'source_column']);
$table->index(['import_template_id', 'position']);
});
}
public function down(): void
{
Schema::dropIfExists('import_template_mappings');
}
};

View File

@ -0,0 +1,27 @@
<?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_templates', function (Blueprint $table) {
$table->foreignId('client_id')->nullable()->constrained('clients')->nullOnDelete()->after('user_id');
$table->index('client_id');
});
}
public function down(): void
{
Schema::table('import_templates', function (Blueprint $table) {
if (Schema::hasColumn('import_templates', 'client_id')) {
$table->dropForeign(['client_id']);
$table->dropIndex(['client_id']);
$table->dropColumn('client_id');
}
});
}
};

View File

@ -0,0 +1,78 @@
<?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
{
// People: unique by (tax_number, social_security_number, deleted_at)
Schema::table('person', function (Blueprint $table) {
if (!self::hasIndex('person', 'person_identity_unique')) {
$table->unique(['tax_number', 'social_security_number', 'deleted_at'], 'person_identity_unique');
}
});
// Phones: unique by (person_id, nu, country_code, deleted_at)
Schema::table('person_phones', function (Blueprint $table) {
if (!self::hasIndex('person_phones', 'person_phones_unique')) {
$table->unique(['person_id', 'nu', 'country_code', 'deleted_at'], 'person_phones_unique');
}
});
// Addresses: unique by (person_id, address, country, deleted_at)
Schema::table('person_addresses', function (Blueprint $table) {
if (!self::hasIndex('person_addresses', 'person_addresses_unique')) {
$table->unique(['person_id', 'address', 'country', 'deleted_at'], 'person_addresses_unique');
}
});
// Contracts: unique by (client_case_id, reference, deleted_at)
Schema::table('contracts', function (Blueprint $table) {
if (!self::hasIndex('contracts', 'contracts_reference_unique')) {
$table->unique(['client_case_id', 'reference', 'deleted_at'], 'contracts_reference_unique');
}
});
// Accounts: unique by (contract_id, reference, deleted_at)
Schema::table('accounts', function (Blueprint $table) {
if (!self::hasIndex('accounts', 'accounts_reference_unique')) {
$table->unique(['contract_id', 'reference', 'deleted_at'], 'accounts_reference_unique');
}
});
}
public function down(): void
{
Schema::table('person', function (Blueprint $table) {
$table->dropUnique('person_identity_unique');
});
Schema::table('person_phones', function (Blueprint $table) {
$table->dropUnique('person_phones_unique');
});
Schema::table('person_addresses', function (Blueprint $table) {
$table->dropUnique('person_addresses_unique');
});
Schema::table('contracts', function (Blueprint $table) {
$table->dropUnique('contracts_reference_unique');
});
Schema::table('accounts', function (Blueprint $table) {
$table->dropUnique('accounts_reference_unique');
});
}
private static function hasIndex(string $table, string $index): bool
{
// Attempt to detect index presence; if not supported, return false to try creating
try {
$connection = Schema::getConnection();
$schemaManager = $connection->getDoctrineSchemaManager();
$doctrineTable = $schemaManager->listTableDetails($table);
return $doctrineTable->hasIndex($index);
} catch (\Throwable $e) {
return false;
}
}
};

View File

@ -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_template_mappings', function (Blueprint $table) {
$table->string('apply_mode', 10)->default('both')->after('transform'); // insert|update|both
$table->index(['import_template_id', 'apply_mode']);
});
}
public function down(): void
{
Schema::table('import_template_mappings', function (Blueprint $table) {
if (Schema::hasColumn('import_template_mappings', 'apply_mode')) {
$table->dropIndex(['import_template_id', 'apply_mode']);
$table->dropColumn('apply_mode');
}
});
}
};

View File

@ -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_mappings', function (Blueprint $table) {
$table->string('apply_mode', 10)->default('both')->after('transform');
$table->index(['import_id', 'apply_mode']);
});
}
public function down(): void
{
Schema::table('import_mappings', function (Blueprint $table) {
if (Schema::hasColumn('import_mappings', 'apply_mode')) {
$table->dropIndex(['import_id', 'apply_mode']);
$table->dropColumn('apply_mode');
}
});
}
};

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('accounts', function (Blueprint $table) {
if (!Schema::hasColumn('accounts', 'balance_amount')) {
$table->decimal('balance_amount', 18, 4)->nullable()->after('description');
$table->index('balance_amount');
}
});
}
public function down(): void
{
Schema::table('accounts', function (Blueprint $table) {
if (Schema::hasColumn('accounts', 'balance_amount')) {
$table->dropIndex(['balance_amount']);
$table->dropColumn('balance_amount');
}
});
}
};

View File

@ -0,0 +1,27 @@
<?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('imports', function (Blueprint $table) {
if (!Schema::hasColumn('imports', 'import_template_id')) {
$table->foreignId('import_template_id')->nullable();
}
// Add foreign key if not exists (Postgres will error if duplicate, so wrap in try/catch in runtime, but Schema builder doesn't support conditional FKs)
$table->foreign('import_template_id', 'imports_import_template_id_foreign')
->references('id')->on('import_templates')->nullOnDelete();
});
}
public function down(): void
{
Schema::table('imports', function (Blueprint $table) {
$table->dropForeign('imports_import_template_id_foreign');
});
}
};

View File

@ -0,0 +1,102 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
return new class extends Migration
{
public function up(): void
{
// Change column type to text/longer string temporarily (nullable) to allow backfill without truncation
$driver = DB::connection()->getDriverName();
if ($driver === 'pgsql') {
DB::statement('ALTER TABLE person ALTER COLUMN nu DROP NOT NULL');
DB::statement('ALTER TABLE person ALTER COLUMN nu TYPE TEXT USING nu::text');
} elseif ($driver === 'mysql') {
DB::statement('ALTER TABLE person MODIFY nu VARCHAR(32) NULL');
} else {
// Fallback: try schema change (may require doctrine/dbal)
Schema::table('person', function (Blueprint $table) {
$table->string('nu', 32)->nullable()->change();
});
}
// Backfill unique 6-char alphanumeric 'nu' values
$rows = DB::table('person')->select('id', 'nu')->orderBy('id')->get();
$used = [];
foreach ($rows as $row) {
if (is_string($row->nu) && preg_match('/^[A-Za-z0-9]{6}$/', $row->nu)) {
if (!isset($used[$row->nu])) {
$used[$row->nu] = true;
continue;
}
// duplicate will be regenerated below
}
// mark to regenerate
$used[$row->nu] = false;
}
$updates = [];
foreach ($rows as $row) {
$needsNew = true;
if (is_string($row->nu) && preg_match('/^[A-Za-z0-9]{6}$/', $row->nu) && ($used[$row->nu] === true)) {
// valid and unique
$needsNew = false;
}
if ($needsNew) {
do {
$nu = Str::random(6); // [A-Za-z0-9]
} while (isset($used[$nu]));
$used[$nu] = true;
$updates[] = ['id' => $row->id, 'nu' => $nu];
}
}
// Apply updates in chunks
foreach (array_chunk($updates, 500) as $chunk) {
foreach ($chunk as $u) {
DB::table('person')->where('id', $u['id'])->update(['nu' => $u['nu']]);
}
}
// Add unique index and then narrow type to VARCHAR(6) and make not nullable
Schema::table('person', function (Blueprint $table) {
$table->unique('nu');
});
if ($driver === 'pgsql') {
DB::statement('ALTER TABLE person ALTER COLUMN nu TYPE VARCHAR(6) USING nu::varchar(6)');
DB::statement('ALTER TABLE person ALTER COLUMN nu SET NOT NULL');
} elseif ($driver === 'mysql') {
DB::statement('ALTER TABLE person MODIFY nu VARCHAR(6) NOT NULL');
} else {
Schema::table('person', function (Blueprint $table) {
$table->string('nu', 6)->nullable(false)->change();
});
}
}
public function down(): void
{
// Drop unique and revert to integer (best-effort)
Schema::table('person', function (Blueprint $table) {
$table->dropUnique(['nu']);
});
$driver = DB::connection()->getDriverName();
// Coerce values back to numeric to avoid issues on some DBs
DB::table('person')->update(['nu' => '0']);
if ($driver === 'pgsql') {
DB::statement('ALTER TABLE person ALTER COLUMN nu TYPE BIGINT USING nu::bigint');
DB::statement('ALTER TABLE person ALTER COLUMN nu SET DEFAULT 0');
DB::statement('ALTER TABLE person ALTER COLUMN nu SET NOT NULL');
} elseif ($driver === 'mysql') {
DB::statement('ALTER TABLE person MODIFY nu BIGINT UNSIGNED NOT NULL DEFAULT 0');
} else {
Schema::table('person', function (Blueprint $table) {
$table->unsignedBigInteger('nu')->default(0)->change();
});
}
}
};

View File

@ -0,0 +1,24 @@
<?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('documents', function (Blueprint $table) {
$table->string('preview_path', 2048)->nullable()->after('path');
$table->string('preview_mime', 100)->nullable()->after('preview_path');
$table->timestamp('preview_generated_at')->nullable()->after('preview_mime');
});
}
public function down(): void
{
Schema::table('documents', function (Blueprint $table) {
$table->dropColumn(['preview_path', 'preview_mime', 'preview_generated_at']);
});
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('import_mappings', function (Blueprint $table) {
if (!Schema::hasColumn('import_mappings', 'position')) {
$table->unsignedInteger('position')->nullable()->after('options');
}
$table->index(['import_id', 'position']);
});
}
public function down(): void
{
Schema::table('import_mappings', function (Blueprint $table) {
if (Schema::hasColumn('import_mappings', 'position')) {
$table->dropIndex(['import_id', 'position']);
$table->dropColumn('position');
}
});
}
};

View File

@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('import_mappings', function (Blueprint $table) {
if (!Schema::hasColumn('import_mappings', 'entity')) {
$table->string('entity', 64)->nullable()->after('import_id');
}
$table->index(['import_id', 'entity']);
});
// Backfill entity from target_field's first segment where possible
DB::table('import_mappings')->orderBy('id')->chunkById(1000, function ($rows) {
foreach ($rows as $row) {
if (!empty($row->entity)) continue;
$entity = null;
if (!empty($row->target_field)) {
$parts = explode('.', $row->target_field);
$record = $parts[0] ?? null;
if ($record) {
// Map record segment to UI entity key
$map = [
'person' => 'person',
'address' => 'person_addresses',
'phone' => 'person_phones',
'email' => 'emails',
'account' => 'accounts',
'contract' => 'contracts',
];
$entity = $map[$record] ?? $record;
}
}
if ($entity) {
DB::table('import_mappings')->where('id', $row->id)->update(['entity' => $entity]);
}
}
});
}
public function down(): void
{
Schema::table('import_mappings', function (Blueprint $table) {
if (Schema::hasColumn('import_mappings', 'entity')) {
// drop composite index if exists
try { $table->dropIndex(['import_id', 'entity']); } catch (\Throwable $e) { /* ignore */ }
$table->dropColumn('entity');
}
});
}
};

View File

@ -0,0 +1,55 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('import_template_mappings', function (Blueprint $table) {
if (!Schema::hasColumn('import_template_mappings', 'entity')) {
$table->string('entity', 64)->nullable()->after('import_template_id');
}
$table->index(['import_template_id', 'entity']);
});
// Backfill entity from target_field first segment
DB::table('import_template_mappings')->orderBy('id')->chunkById(1000, function ($rows) {
foreach ($rows as $row) {
if (!empty($row->entity)) continue;
$entity = null;
if (!empty($row->target_field)) {
$parts = explode('.', $row->target_field);
$record = $parts[0] ?? null;
if ($record) {
$map = [
'person' => 'person',
'address' => 'person_addresses',
'phone' => 'person_phones',
'email' => 'emails',
'account' => 'accounts',
'contract' => 'contracts',
];
$entity = $map[$record] ?? $record;
}
}
if ($entity) {
DB::table('import_template_mappings')->where('id', $row->id)->update(['entity' => $entity]);
}
}
});
}
public function down(): void
{
Schema::table('import_template_mappings', function (Blueprint $table) {
if (Schema::hasColumn('import_template_mappings', 'entity')) {
try { $table->dropIndex(['import_template_id', 'entity']); } catch (\Throwable $e) { /* ignore */ }
$table->dropColumn('entity');
}
});
}
};

View File

@ -27,7 +27,8 @@ public function run(): void
PersonSeeder::class,
SegmentSeeder::class,
ActionSeeder::class,
EventSeeder::class
EventSeeder::class,
ImportTemplateSeeder::class,
]);
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace Database\Seeders;
use App\Models\ImportTemplate;
use App\Models\ImportTemplateMapping;
use Illuminate\Database\Seeder;
use Illuminate\Support\Str;
class ImportTemplateSeeder extends Seeder
{
public function run(): void
{
$template = ImportTemplate::query()->firstOrCreate([
'name' => 'Person basic CSV',
], [
'uuid' => (string) Str::uuid(),
'description' => 'Basic person import: name, email, phone, address',
'source_type' => 'csv',
'default_record_type' => 'person',
'sample_headers' => ['first_name','last_name','email','phone','address','city','postal_code','country'],
'is_active' => true,
'meta' => [
'delimiter' => ',',
'enclosure' => '"',
'escape' => '\\',
],
]);
$mappings = [
['source_column' => 'first_name', 'target_field' => 'person.first_name', 'position' => 1],
['source_column' => 'last_name', 'target_field' => 'person.last_name', 'position' => 2],
['source_column' => 'email', 'target_field' => 'person.email', 'position' => 3],
['source_column' => 'phone', 'target_field' => 'person.phone', 'position' => 4],
['source_column' => 'address', 'target_field' => 'person.address.street', 'position' => 5],
['source_column' => 'city', 'target_field' => 'person.address.city', 'position' => 6],
['source_column' => 'postal_code', 'target_field' => 'person.address.postal_code', 'position' => 7],
['source_column' => 'country', 'target_field' => 'person.address.country', 'position' => 8],
];
foreach ($mappings as $map) {
ImportTemplateMapping::firstOrCreate([
'import_template_id' => $template->id,
'source_column' => $map['source_column'],
], [
'target_field' => $map['target_field'],
'position' => $map['position'],
]);
}
}
}

View File

@ -0,0 +1,6 @@
reference,first name,last name,address,phone number,email,invoice date,due date,amount
REF-1001,John,Doe,"123 Maple St, Springfield",+1 555-0101,john.doe@example.com,2025-09-01,2025-10-01,150.75
REF-1002,Jane,Smith,"456 Oak Ave, Metropolis",+44 20 7946 0958,jane.smith@example.co.uk,2025-09-05,2025-10-05,320.00
REF-1003,Carlos,García,"Calle 12 #34, Madrid",+34 91 123 4567,carlos.garcia@example.es,2025-09-10,2025-10-10,78.99
REF-1004,Anna,Müller,"Hauptstrasse 5, Berlin",+49 30 123456,anna.mueller@example.de,2025-09-12,2025-10-12,980.50
REF-1005,Luka,Novak,"Ilica 10, Zagreb",+385 1 2345 678,luka.novak@example.hr,2025-09-15,2025-10-15,45.00
1 reference first name last name address phone number email invoice date due date amount
2 REF-1001 John Doe 123 Maple St, Springfield +1 555-0101 john.doe@example.com 2025-09-01 2025-10-01 150.75
3 REF-1002 Jane Smith 456 Oak Ave, Metropolis +44 20 7946 0958 jane.smith@example.co.uk 2025-09-05 2025-10-05 320.00
4 REF-1003 Carlos García Calle 12 #34, Madrid +34 91 123 4567 carlos.garcia@example.es 2025-09-10 2025-10-10 78.99
5 REF-1004 Anna Müller Hauptstrasse 5, Berlin +49 30 123456 anna.mueller@example.de 2025-09-12 2025-10-12 980.50
6 REF-1005 Luka Novak Ilica 10, Zagreb +385 1 2345 678 luka.novak@example.hr 2025-09-15 2025-10-15 45.00

View File

@ -1,6 +1,6 @@
<script setup>
import { ref, watch } from 'vue';
import Drawer from './Drawer.vue';
import DialogModal from './DialogModal.vue';
import InputLabel from './InputLabel.vue';
import SectionTitle from './SectionTitle.vue';
import TextInput from './TextInput.vue';
@ -133,7 +133,7 @@ const callSubmit = () => {
</script>
<template>
<Drawer
<DialogModal
:show="show"
@close="close"
>
@ -193,5 +193,5 @@ const callSubmit = () => {
</div>
</form>
</template>
</Drawer>
</DialogModal>
</template>

View File

@ -1,6 +1,6 @@
<script setup>
import { FwbButton, FwbModal, FwbTable, FwbTableBody, FwbTableCell, FwbTableHead, FwbTableHeadCell, FwbTableRow } from 'flowbite-vue';
import Drawer from './Drawer.vue';
import { FwbButton, FwbTable, FwbTableBody, FwbTableCell, FwbTableHead, FwbTableHeadCell, FwbTableRow } from 'flowbite-vue';
import DialogModal from './DialogModal.vue';
import { useForm } from '@inertiajs/vue3';
import { ref } from 'vue';
import TextInput from './TextInput.vue';
@ -63,7 +63,6 @@ const update = () => {
onSuccess: () => {
closeEditor();
formUpdate.reset();
console.log('ssss')
},
preserveScroll: true
});
@ -99,38 +98,48 @@ const remove = () => {
</script>
<template>
<div class="relative overflow-x-auto">
<FwbTable hoverable>
<FwbTableHead>
<FwbTableHeadCell v-for="h in header">{{ h.data }}</FwbTableHeadCell>
<FwbTableHeadCell v-if="editor"></FwbTableHeadCell>
<FwbTableHeadCell v-else />
</FwbTableHead>
<FwbTableBody>
<FwbTableRow v-for="(row, key, parent_index) in body" :class="row.options.class" >
<FwbTableCell v-for="col in row.cols">
<a v-if="col.link !== undefined" :class="col.link.css" :href="route(col.link.route, col.link.options)">{{ col.data }}</a>
<span v-else>{{ col.data }}</span>
</FwbTableCell>
<FwbTableCell v-if="editor">
<fwb-button class="mr-1" size="sm" color="default" @click="openEditor(row.options.ref, row.options.editable)" outline>Edit</fwb-button>
<fwb-button size="sm" color="red" @click="showModal(row.options.ref.val, row.options.title)" outline>Remove</fwb-button>
</FwbTableCell>
<FwbTableCell v-else />
</FwbTableRow>
</FwbTableBody>
</FwbTable>
<div>
<!-- Header -->
<div v-if="title || description" class="mb-4">
<h2 v-if="title" class="text-lg font-semibold text-gray-900">{{ title }}</h2>
<p v-if="description" class="mt-1 text-sm text-gray-600">{{ description }}</p>
</div>
<div class="relative overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm">
<FwbTable hoverable striped class="text-sm">
<FwbTableHead class="sticky top-0 z-10 bg-gray-50/90 backdrop-blur supports-[backdrop-filter]:bg-gray-50/80 border-b border-gray-200 shadow-sm">
<FwbTableHeadCell v-for="(h, hIndex) in header" :key="hIndex" class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3 first:pl-6 last:pr-6">{{ h.data }}</FwbTableHeadCell>
<FwbTableHeadCell v-if="editor" class="w-px text-gray-700 py-3"></FwbTableHeadCell>
<FwbTableHeadCell v-else class="w-px text-gray-700 py-3" />
</FwbTableHead>
<FwbTableBody>
<FwbTableRow v-for="(row, key, parent_index) in body" :key="key" :class="row.options.class">
<FwbTableCell v-for="(col, colIndex) in row.cols" :key="colIndex" class="align-middle">
<a v-if="col.link !== undefined" :class="col.link.css" :href="route(col.link.route, col.link.options)">{{ col.data }}</a>
<span v-else>{{ col.data }}</span>
</FwbTableCell>
<FwbTableCell v-if="editor" class="text-right whitespace-nowrap">
<fwb-button class="mr-2" size="sm" color="default" @click="openEditor(row.options.ref, row.options.editable)" outline>Edit</fwb-button>
<fwb-button size="sm" color="red" @click="showModal(row.options.ref.val, row.options.title)" outline>Remove</fwb-button>
</FwbTableCell>
<FwbTableCell v-else />
</FwbTableRow>
</FwbTableBody>
</FwbTable>
<div v-if="!body || body.length === 0" class="p-6 text-center text-sm text-gray-500">No records found.</div>
</div>
</div>
<Drawer
<DialogModal
v-if="editor"
:show="drawerUpdateForm"
@close="drawerUpdateForm = false"
maxWidth="xl"
>
<template #title>Update {{ options.editor_data.title }}</template>
<template #content>
<form @submit.prevent="update">
<div v-for="e in options.editor_data.form.el" class="col-span-6 sm:col-span-4 mb-4">
<form @submit.prevent="update" class="pt-2">
<div v-for="(e, eIndex) in options.editor_data.form.el" :key="eIndex" class="col-span-6 sm:col-span-4 mb-4">
<InputLabel :for="e.id" :value="e.label"/>
<TextInput
v-if="e.type === 'text'"
@ -138,20 +147,20 @@ const remove = () => {
:ref="e.ref"
type="text"
:autocomplete="e.autocomplete"
class="mt-1 block w-full"
class="mt-1 block w-full text-sm"
v-model="formUpdate[e.bind]"
/>
<select
v-else-if="e.type === 'select'"
class="block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
class="block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm text-sm"
:id="e.id"
:ref="e.ref"
v-model="formUpdate[e.bind]"
>
<option v-for="op in e.selectOptions" :value="op.val">{{ op.desc }}</option>
<option v-for="(op, opIndex) in e.selectOptions" :key="opIndex" :value="op.val">{{ op.desc }}</option>
</select>
</div>
<div class="flex justify-end mt-4">
<div class="flex justify-end mt-6 gap-3">
<ActionMessage :on="formUpdate.recentlySuccessful" class="me-3">
Saved.
</ActionMessage>
@ -161,7 +170,7 @@ const remove = () => {
</div>
</form>
</template>
</Drawer>
</DialogModal>
<Modal
v-if="editor"
@ -170,12 +179,12 @@ const remove = () => {
maxWidth="sm"
>
<form @submit.prevent="remove">
<div class="p-3">
<div class="text-lg text-center py-2 mb-4">
<div class="p-6">
<div class="text-base font-medium text-center py-2 mb-4 text-gray-900">
Remove {{ options.editor_data.title }} <b>{{ modalRemoveTitle }}</b>?
</div>
<div class="flex justify-between">
<div class="flex justify-between items-center">
<SecondaryButton type="button" @click="closeModal">
Cancel
</SecondaryButton>

View File

@ -0,0 +1,116 @@
<script setup>
import DialogModal from '@/Components/DialogModal.vue'
import PrimaryButton from '@/Components/PrimaryButton.vue'
import SecondaryButton from '@/Components/SecondaryButton.vue'
import ActionMessage from '@/Components/ActionMessage.vue'
import InputLabel from '@/Components/InputLabel.vue'
import TextInput from '@/Components/TextInput.vue'
import { useForm } from '@inertiajs/vue3'
import { ref, watch } from 'vue'
const props = defineProps({
show: { type: Boolean, default: false },
postUrl: { type: String, required: true },
})
const emit = defineEmits(['close', 'uploaded'])
const MAX_SIZE = 25 * 1024 * 1024 // 25MB
const ALLOWED_EXTS = ['doc','docx','pdf','txt','csv','xls','xlsx','jpeg','jpg','png']
const form = useForm({
name: '',
description: '',
file: null,
is_public: false,
})
const localError = ref('')
watch(() => props.show, (v) => {
if (!v) return
localError.value = ''
})
const onFileChange = (e) => {
localError.value = ''
const f = e.target.files?.[0]
if (!f) { form.file = null; return }
const ext = (f.name.split('.').pop() || '').toLowerCase()
if (!ALLOWED_EXTS.includes(ext)) {
localError.value = 'Unsupported file type. Allowed: ' + ALLOWED_EXTS.join(', ')
e.target.value = ''
form.file = null
return
}
if (f.size > MAX_SIZE) {
localError.value = 'File is too large. Maximum size is 25MB.'
e.target.value = ''
form.file = null
return
}
form.file = f
if (!form.name) {
form.name = f.name.replace(/\.[^.]+$/, '')
}
}
const submit = () => {
localError.value = ''
if (!form.file) {
localError.value = 'Please choose a file.'
return
}
const ext = (form.file.name.split('.').pop() || '').toLowerCase()
if (!ALLOWED_EXTS.includes(ext)) {
localError.value = 'Unsupported file type. Allowed: ' + ALLOWED_EXTS.join(', ')
return
}
if (form.file.size > MAX_SIZE) {
localError.value = 'File is too large. Maximum size is 25MB.'
return
}
form.post(props.postUrl, {
forceFormData: true,
onSuccess: () => {
emit('uploaded')
close()
form.reset()
},
})
}
const close = () => emit('close')
</script>
<template>
<DialogModal :show="props.show" @close="close" maxWidth="lg">
<template #title>Dodaj dokument</template>
<template #content>
<div class="space-y-4">
<div>
<InputLabel for="doc_name" value="Name" />
<TextInput id="doc_name" class="mt-1 block w-full" v-model="form.name" />
</div>
<div>
<InputLabel for="doc_desc" value="Description" />
<textarea id="doc_desc" class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm" rows="3" v-model="form.description"></textarea>
</div>
<div>
<InputLabel for="doc_file" value="File (max 25MB)" />
<input id="doc_file" type="file" class="mt-1 block w-full" @change="onFileChange" accept=".doc,.docx,.pdf,.txt,.csv,.xls,.xlsx,.jpeg,.jpg,.png" />
<div v-if="localError" class="text-sm text-red-600 mt-1">{{ localError }}</div>
</div>
<label class="inline-flex items-center gap-2 text-sm">
<input type="checkbox" v-model="form.is_public" class="rounded" />
Public
</label>
</div>
</template>
<template #footer>
<div class="flex items-center gap-3">
<ActionMessage :on="form.recentlySuccessful">Uploaded.</ActionMessage>
<SecondaryButton type="button" @click="close">Cancel</SecondaryButton>
<PrimaryButton :disabled="form.processing" @click="submit">Upload</PrimaryButton>
</div>
</template>
</DialogModal>
</template>

View File

@ -0,0 +1,26 @@
<script setup>
import DialogModal from '@/Components/DialogModal.vue'
import SecondaryButton from '@/Components/SecondaryButton.vue'
const props = defineProps({
show: { type: Boolean, default: false },
src: { type: String, default: '' },
title: { type: String, default: 'Document' }
})
const emit = defineEmits(['close'])
</script>
<template>
<DialogModal :show="props.show" @close="$emit('close')" maxWidth="4xl">
<template #title>{{ props.title }}</template>
<template #content>
<div class="h-[70vh]">
<iframe v-if="props.src" :src="props.src" class="w-full h-full rounded border" />
<div v-else class="text-sm text-gray-500">No document to display.</div>
</div>
</template>
<template #footer>
<SecondaryButton type="button" @click="$emit('close')">Close</SecondaryButton>
</template>
</DialogModal>
</template>

View File

@ -0,0 +1,139 @@
<script setup>
import { FwbTable, FwbTableHead, FwbTableHeadCell, FwbTableBody, FwbTableRow, FwbTableCell, FwbBadge } from 'flowbite-vue'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { faFilePdf, faFileWord, faFileExcel, faFileLines, faFileImage, faFile, faCircleInfo } from '@fortawesome/free-solid-svg-icons'
import { ref } from 'vue'
const props = defineProps({
documents: { type: Array, default: () => [] },
viewUrlBuilder: { type: Function, default: null },
})
const emit = defineEmits(['view'])
const formatSize = (bytes) => {
if (bytes == null) return '-'
const thresh = 1024
if (Math.abs(bytes) < thresh) return bytes + ' B'
const units = ['KB', 'MB', 'GB', 'TB']
let u = -1
do { bytes /= thresh; ++u } while (Math.abs(bytes) >= thresh && u < units.length - 1)
return bytes.toFixed(1) + ' ' + units[u]
}
const extFrom = (doc) => {
let ext = (doc?.extension || '').toLowerCase()
if (!ext && doc?.original_name) {
const parts = String(doc.original_name).toLowerCase().split('.')
if (parts.length > 1) ext = parts.pop()
}
// derive from mime
if (!ext && doc?.mime_type) {
const mime = String(doc.mime_type).toLowerCase()
if (mime.includes('pdf')) ext = 'pdf'
else if (mime.includes('word') || mime.includes('msword') || mime.includes('doc')) ext = 'docx'
else if (mime.includes('excel') || mime.includes('sheet')) ext = 'xlsx'
else if (mime.includes('csv')) ext = 'csv'
else if (mime.startsWith('image/')) ext = 'img'
else if (mime.includes('text')) ext = 'txt'
}
return ext
}
const fileTypeInfo = (doc) => {
const ext = extFrom(doc)
const mime = (doc?.mime_type || '').toLowerCase()
switch (ext) {
case 'pdf':
return { icon: faFilePdf, color: 'text-red-600', label: 'PDF' }
case 'doc':
case 'docx':
return { icon: faFileWord, color: 'text-blue-600', label: (ext || 'DOCX').toUpperCase() }
case 'xls':
case 'xlsx':
return { icon: faFileExcel, color: 'text-green-600', label: (ext || 'XLSX').toUpperCase() }
case 'csv':
// treat CSV as spreadsheet-like
return { icon: faFileExcel, color: 'text-emerald-600', label: 'CSV' }
case 'txt':
return { icon: faFileLines, color: 'text-slate-600', label: 'TXT' }
case 'jpg':
case 'jpeg':
case 'png':
case 'img':
return { icon: faFileImage, color: 'text-fuchsia-600', label: (ext === 'img' ? 'IMG' : (ext || 'IMG').toUpperCase()) }
default:
if (mime.startsWith('image/')) return { icon: faFileImage, color: 'text-fuchsia-600', label: 'IMG' }
return { icon: faFile, color: 'text-gray-600', label: (ext || 'FILE').toUpperCase() }
}
}
const hasDesc = (doc) => {
const d = doc?.description
return typeof d === 'string' && d.trim().length > 0
}
const expandedDescKey = ref(null)
const rowKey = (doc, i) => doc?.uuid ?? i
const toggleDesc = (doc, i) => {
const key = rowKey(doc, i)
expandedDescKey.value = expandedDescKey.value === key ? null : key
}
</script>
<template>
<div class="relative overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-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">
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Name</FwbTableHeadCell>
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Type</FwbTableHeadCell>
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Size</FwbTableHeadCell>
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Added</FwbTableHeadCell>
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Drugo</FwbTableHeadCell>
<FwbTableHeadCell class="w-px" />
</FwbTableHead>
<FwbTableBody>
<template v-for="(doc, i) in documents" :key="doc.uuid || i">
<FwbTableRow>
<FwbTableCell>
<div class="flex items-center gap-2">
<button type="button" class="text-indigo-600 hover:underline" @click="$emit('view', doc)">{{ doc.original_name || doc.name }}</button>
<FwbBadge v-if="doc.is_public" type="green">Public</FwbBadge>
</div>
</FwbTableCell>
<FwbTableCell>
<div class="flex items-center gap-2">
<FontAwesomeIcon :icon="fileTypeInfo(doc).icon" :class="['h-5 w-5', fileTypeInfo(doc).color]" />
<span class="text-gray-700">{{ fileTypeInfo(doc).label }}</span>
</div>
</FwbTableCell>
<FwbTableCell>{{ formatSize(doc.size) }}</FwbTableCell>
<FwbTableCell>{{ new Date(doc.created_at).toLocaleString() }}</FwbTableCell>
<FwbTableCell class="text-center">
<button
class="inline-flex items-center justify-center h-8 w-8 rounded-full hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed"
:disabled="!hasDesc(doc)"
:title="hasDesc(doc) ? 'Pokaži opis' : 'Ni opisa'"
type="button"
@click="toggleDesc(doc, i)"
>
<FontAwesomeIcon :icon="faCircleInfo" class="h-4 w-4 text-gray-700" />
</button>
</FwbTableCell>
<FwbTableCell class="text-right whitespace-nowrap">
<!-- future actions: download/delete -->
</FwbTableCell>
</FwbTableRow>
<!-- Expanded description row directly below the item -->
<FwbTableRow :key="'desc-' + (doc.uuid || i)" v-if="expandedDescKey === rowKey(doc, i)">
<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">
{{ doc.description }}
</div>
</FwbTableCell>
</FwbTableRow>
</template>
</FwbTableBody>
</FwbTable>
<div v-if="!documents || documents.length === 0" class="p-6 text-center text-sm text-gray-500">No documents.</div>
</div>
</template>

View File

@ -1,5 +1,5 @@
<script setup>
import Drawer from '@/Components/Drawer.vue';
import DialogModal from '@/Components/DialogModal.vue';
import InputLabel from '@/Components/InputLabel.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import SectionTitle from '@/Components/SectionTitle.vue';
@ -62,7 +62,7 @@ const updatePerson = () => {
</script>
<template>
<Drawer
<DialogModal
:show="show"
@close="close"
>
@ -135,5 +135,5 @@ const updatePerson = () => {
</div>
</form>
</template>
</Drawer>
</DialogModal>
</template>

View File

@ -1,6 +1,6 @@
<script setup>
import { ref, watch } from 'vue';
import Drawer from './Drawer.vue';
import DialogModal from './DialogModal.vue';
import InputLabel from './InputLabel.vue';
import SectionTitle from './SectionTitle.vue';
import TextInput from './TextInput.vue';
@ -123,7 +123,7 @@ const submit = () => {
</script>
<template>
<Drawer
<DialogModal
:show="show"
@close="close"
>
@ -189,5 +189,5 @@ const submit = () => {
</div>
</form>
</template>
</Drawer>
</DialogModal>
</template>

View File

@ -1,12 +1,10 @@
<script setup>
import { ref } from 'vue';
import { onMounted, onUnmounted, ref, watch, computed } from 'vue';
import { Head, Link, router, usePage } from '@inertiajs/vue3';
import ApplicationMark from '@/Components/ApplicationMark.vue';
import Banner from '@/Components/Banner.vue';
import Dropdown from '@/Components/Dropdown.vue';
import DropdownLink from '@/Components/DropdownLink.vue';
import NavLink from '@/Components/NavLink.vue';
import ResponsiveNavLink from '@/Components/ResponsiveNavLink.vue';
import Breadcrumbs from '@/Components/Breadcrumbs.vue';
import GlobalSearch from './Partials/GlobalSearch.vue';
@ -14,13 +12,100 @@ const props = defineProps({
title: String,
});
// Collapsible sidebar state (persisted when user explicitly toggles)
const sidebarCollapsed = ref(false);
const hasSavedSidebarPref = ref(false);
// Mobile off-canvas state
const isMobile = ref(false);
const mobileSidebarOpen = ref(false);
function applyAutoCollapse() {
if (typeof window === 'undefined') return;
isMobile.value = window.innerWidth < 1024; // Tailwind lg breakpoint
sidebarCollapsed.value = isMobile.value;
}
function handleResize() {
if (typeof window !== 'undefined') {
isMobile.value = window.innerWidth < 1024;
if (!isMobile.value) mobileSidebarOpen.value = false; // close drawer when switching to desktop
}
if (!hasSavedSidebarPref.value) applyAutoCollapse();
}
onMounted(() => {
try {
const saved = localStorage.getItem('sidebarCollapsed');
if (saved !== null) {
hasSavedSidebarPref.value = true;
sidebarCollapsed.value = saved === '1';
} else {
applyAutoCollapse();
}
} catch {}
window.addEventListener('resize', handleResize);
});
onUnmounted(() => window.removeEventListener('resize', handleResize));
watch(sidebarCollapsed, (v) => {
if (!hasSavedSidebarPref.value) return; // don't persist auto behavior
try { localStorage.setItem('sidebarCollapsed', v ? '1' : '0'); } catch {}
});
const showingNavigationDropdown = ref(false);
// Global search modal state
const searchOpen = ref(false);
const openSearch = () => (searchOpen.value = true);
const closeSearch = () => (searchOpen.value = false);
// Keyboard shortcut: Ctrl+K / Cmd+K to open search
function onKeydown(e) {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
openSearch();
}
if (e.key === 'Escape' && mobileSidebarOpen.value) {
mobileSidebarOpen.value = false;
}
}
onMounted(() => window.addEventListener('keydown', onKeydown));
onUnmounted(() => window.removeEventListener('keydown', onKeydown));
function toggleSidebar() {
hasSavedSidebarPref.value = true; // user explicitly chose
sidebarCollapsed.value = !sidebarCollapsed.value;
}
function toggleMobileSidebar() {
mobileSidebarOpen.value = !mobileSidebarOpen.value;
}
function handleSidebarToggleClick() {
if (isMobile.value) toggleMobileSidebar();
else toggleSidebar();
}
const logout = () => {
router.post(route('logout'));
};
// Flash toast notifications
const page = usePage();
const flash = computed(() => page.props.flash || {});
const showToast = ref(false);
const toastMessage = ref('');
const toastType = ref('success');
watch(
() => [flash.value.success, flash.value.error, flash.value.warning, flash.value.info],
([s, e, w, i]) => {
const message = s || e || w || i;
const type = s ? 'success' : e ? 'error' : w ? 'warning' : i ? 'info' : null;
if (message && type) {
toastMessage.value = message;
toastType.value = type;
showToast.value = true;
// auto-hide after 3s
setTimeout(() => (showToast.value = false), 3000);
}
},
{ immediate: true }
);
</script>
<template>
@ -29,228 +114,180 @@ const logout = () => {
<Banner />
<div class="min-h-screen bg-gray-100">
<nav class="bg-white border-b border-gray-100">
<!-- Primary Navigation Menu -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<!-- Logo -->
<div class="shrink-0 flex items-center">
<Link :href="route('dashboard')">
<ApplicationMark class="block h-9 w-auto" />
</Link>
</div>
<div class="min-h-screen bg-gray-100 flex">
<!-- Mobile backdrop -->
<div v-if="isMobile && mobileSidebarOpen" class="fixed inset-0 z-40 bg-black/30" @click="mobileSidebarOpen=false"></div>
<!-- Navigation Links -->
<div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
<NavLink :href="route('dashboard')" :active="route().current('dashboard')">
Nadzorna plošča
</NavLink>
</div>
<div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
<NavLink :href="route('client')" :active="route().current('client') || route().current('client.*')">
Naročniki
</NavLink>
</div>
<div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
<NavLink :href="route('clientCase')" :active="route().current('clientCase') || route().current('clientCase.*')">
Primeri
</NavLink>
</div>
<div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
<NavLink :href="route('settings')" :active="route().current('settings') || route().current('settings.*')">
Nastavitve
</NavLink>
</div>
<div class="hidden space-x-8 sm:-my-px sm:items-center sm:ms-10 sm:flex">
<GlobalSearch />
</div>
</div>
<div class="hidden sm:flex sm:items-center sm:ms-6">
<!-- Settings Dropdown -->
<div class="ms-3 relative">
<Dropdown align="right" width="48">
<template #trigger>
<button v-if="$page.props.jetstream.managesProfilePhotos" class="flex text-sm border-2 border-transparent rounded-full focus:outline-none focus:border-gray-300 transition">
<img class="h-8 w-8 rounded-full object-cover" :src="$page.props.auth.user.profile_photo_url" :alt="$page.props.auth.user.name">
</button>
<span v-else class="inline-flex rounded-md">
<button type="button" class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-none focus:bg-gray-50 active:bg-gray-50 transition ease-in-out duration-150">
{{ $page.props.auth.user.name }}
<svg class="ms-2 -me-0.5 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</button>
</span>
</template>
<template #content>
<!-- Account Management -->
<div class="block px-4 py-2 text-xs text-gray-400">
Nastavitve računa
</div>
<DropdownLink :href="route('profile.show')">
Profil
</DropdownLink>
<DropdownLink v-if="$page.props.jetstream.hasApiFeatures" :href="route('api-tokens.index')">
API Tokens
</DropdownLink>
<div class="border-t border-gray-200" />
<!-- Authentication -->
<form @submit.prevent="logout">
<DropdownLink as="button">
Izpis
</DropdownLink>
</form>
</template>
</Dropdown>
</div>
</div>
<!-- Hamburger -->
<div class="-me-2 flex items-center sm:hidden">
<button class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:bg-gray-100 focus:text-gray-500 transition duration-150 ease-in-out" @click="showingNavigationDropdown = ! showingNavigationDropdown">
<svg
class="h-6 w-6"
stroke="currentColor"
fill="none"
viewBox="0 0 24 24"
>
<path
:class="{'hidden': showingNavigationDropdown, 'inline-flex': ! showingNavigationDropdown }"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
/>
<path
:class="{'hidden': ! showingNavigationDropdown, 'inline-flex': showingNavigationDropdown }"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
<!-- Sidebar -->
<aside :class="[
sidebarCollapsed ? 'w-16' : 'w-64',
'bg-white border-r border-gray-200 transition-all duration-200 z-50',
// Off-canvas behavior on mobile
isMobile ? 'fixed inset-y-0 left-0 transform ' + (mobileSidebarOpen ? 'translate-x-0' : '-translate-x-full') : 'relative translate-x-0'
]">
<div class="h-16 px-4 flex items-center justify-between border-b">
<Link :href="route('dashboard')" class="flex items-center gap-2">
<ApplicationMark class="h-8 w-auto" />
<span v-if="!sidebarCollapsed" class="text-sm font-semibold">Teren</span>
</Link>
</div>
<nav class="py-4">
<ul class="space-y-1">
<li>
<Link :href="route('dashboard')" :class="['flex items-center gap-3 px-4 py-2 text-sm hover:bg-gray-100', route().current('dashboard') ? 'bg-gray-100 text-gray-900' : 'text-gray-600']" title="Nadzorna plošča">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955a1.125 1.125 0 011.592 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.5c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v4.5h4.125c.621 0 1.125-.504 1.125-1.125V9.75" />
</svg>
<span v-if="!sidebarCollapsed">Nadzorna plošča</span>
</Link>
</li>
<li>
<Link :href="route('client')" :class="['flex items-center gap-3 px-4 py-2 text-sm hover:bg-gray-100', route().current('client') || route().current('client.*') ? 'bg-gray-100 text-gray-900' : 'text-gray-600']" title="Naročniki">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 003.745-.479 3.375 3.375 0 00-6.49-1.072M15 19.128V18a4.5 4.5 0 00-4.5-4.5H8.25A4.5 4.5 0 003.75 18v1.128M15 19.128V21m0-1.872V21M6.75 7.5a3 3 0 116 0 3 3 0 01-6 0z" />
</svg>
<span v-if="!sidebarCollapsed">Naročniki</span>
</Link>
</li>
<li>
<Link :href="route('clientCase')" :class="['flex items-center gap-3 px-4 py-2 text-sm hover:bg-gray-100', route().current('clientCase') || route().current('clientCase.*') ? 'bg-gray-100 text-gray-900' : 'text-gray-600']" title="Primeri">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-6a2.25 2.25 0 00-2.25-2.25H8.25A2.25 2.25 0 006 8.25v7.5A2.25 2.25 0 008.25 18h9a2.25 2.25 0 002.25-2.25z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M9 9h6m-6 3h6m-6 3h3" />
</svg>
<span v-if="!sidebarCollapsed">Primeri</span>
</Link>
</li>
<li>
<Link :href="route('imports.index')" :class="['flex items-center gap-3 px-4 py-2 text-sm hover:bg-gray-100', (route().current('imports.index') || route().current('imports.*')) ? 'bg-gray-100 text-gray-900' : 'text-gray-600']" title="Uvozi">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M7.5 10.5L12 6l4.5 4.5M12 6v12" />
</svg>
<span v-if="!sidebarCollapsed">Uvozi</span>
</Link>
</li>
<li>
<Link :href="route('importTemplates.index')" :class="['flex items-center gap-3 px-4 py-2 text-sm hover:bg-gray-100', route().current('importTemplates.index') ? 'bg-gray-100 text-gray-900' : 'text-gray-600']" title="Uvozne predloge">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 4.5h5.25l1.5 2.25H20.25A1.5 1.5 0 0121.75 8.25v9A2.25 2.25 0 0119.5 19.5H4.5A2.25 2.25 0 012.25 17.25V6A1.5 1.5 0 013.75 4.5z" />
</svg>
<span v-if="!sidebarCollapsed">Uvozne predloge</span>
</Link>
</li>
<li>
<Link :href="route('importTemplates.create')" :class="['flex items-center gap-3 px-4 py-2 text-sm hover:bg-gray-100', route().current('importTemplates.create') ? 'bg-gray-100 text-gray-900' : 'text-gray-600']" title="Nova uvozna predloga">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
<span v-if="!sidebarCollapsed">Nova uvozna predloga</span>
</Link>
</li>
<li>
<Link :href="route('settings')" :class="['flex items-center gap-3 px-4 py-2 text-sm hover:bg-gray-100', route().current('settings') || route().current('settings.*') ? 'bg-gray-100 text-gray-900' : 'text-gray-600']" title="Nastavitve">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93l.8.334c.486.203.682.78.4 1.223l-.5.805c-.214.343-.17.784.108 1.09l.596.654c.36.395.37 1.002.024 1.41l-.657.76c-.285.33-.347.79-.158 1.182l.3.65c.216.468-.02 1.02-.507 1.21l-.89.345c-.4.155-.68.52-.74.94l-.12.89c-.08.55-.54.96-1.09.96h-1.09c-.55 0-1.01-.41-1.09-.96l-.12-.89c-.06-.42-.34-.785-.74-.94l-.89-.345c-.49-.19-.72-.74-.507-1.21l.3-.65c.19-.392.127-.852-.158-1.182l-.657-.76a1.125 1.125 0 01.033-1.58l.596-.654c.278-.306.322-.747.108-1.09l-.5-.805c-.282-.443-.086-1.02.4-1.223l.8-.334c.396-.166.71-.506.78-.93l.149-.894zM12 15.75a3.75 3.75 0 100-7.5 3.75 3.75 0 000 7.5z" />
</svg>
<span v-if="!sidebarCollapsed">Nastavitve</span>
</Link>
</li>
</ul>
</nav>
</aside>
<!-- Responsive Navigation Menu -->
<div :class="{'block': showingNavigationDropdown, 'hidden': ! showingNavigationDropdown}" class="sm:hidden">
<div class="pt-2 pb-3 space-y-1">
<ResponsiveNavLink :href="route('dashboard')" :active="route().current('dashboard')">
Nadzorna plošča
</ResponsiveNavLink>
<ResponsiveNavLink :href="route('client')" :active="route().current('client')">
Naročniki
</ResponsiveNavLink>
<ResponsiveNavLink :href="route('clientCase')" :active="route().current('clientCase')">
Primeri
</ResponsiveNavLink>
<ResponsiveNavLink :href="route('settings')" :active="route().current('settings')">
Nastavitve
</ResponsiveNavLink>
<!-- Main column -->
<div class="flex-1 flex flex-col min-w-0">
<!-- Top bar -->
<div class="h-16 bg-white border-b border-gray-100 px-4 flex items-center justify-between">
<div class="flex items-center gap-2">
<!-- Sidebar toggle -->
<button
@click="handleSidebarToggleClick()"
class="inline-flex items-center justify-center w-9 h-9 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100"
:title="sidebarCollapsed ? 'Razširi meni' : 'Skrči meni'"
aria-label="Toggle sidebar"
>
<!-- Hamburger (Bars) icon -->
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
</button>
<!-- Search trigger -->
<button @click="openSearch" class="inline-flex items-center gap-2 px-3 py-2 text-sm rounded-md border border-gray-200 text-gray-500 hover:text-gray-700 hover:border-gray-300">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-4.35-4.35m0 0A7.5 7.5 0 1010.5 18.5a7.5 7.5 0 006.15-1.85z" />
</svg>
<span class="hidden sm:inline">Globalni iskalnik</span>
<kbd class="hidden sm:inline ml-2 text-[10px] px-1.5 py-0.5 rounded border bg-gray-50">Ctrl K</kbd>
</button>
</div>
<!-- Responsive Settings Options -->
<div class="pt-4 pb-1 border-t border-gray-200">
<div class="flex items-center px-4">
<div v-if="$page.props.jetstream.managesProfilePhotos" class="shrink-0 me-3">
<img class="h-10 w-10 rounded-full object-cover" :src="$page.props.auth.user.profile_photo_url" :alt="$page.props.auth.user.name">
</div>
<div>
<div class="font-medium text-base text-gray-800">
{{ $page.props.auth.user.name }}
</div>
<div class="font-medium text-sm text-gray-500">
{{ $page.props.auth.user.email }}
</div>
</div>
</div>
<div class="flex items-center">
<div class="ms-3 relative">
<Dropdown align="right" width="48">
<template #trigger>
<button v-if="$page.props.jetstream.managesProfilePhotos" class="flex text-sm border-2 border-transparent rounded-full focus:outline-none focus:border-gray-300 transition">
<img class="h-8 w-8 rounded-full object-cover" :src="$page.props.auth.user.profile_photo_url" :alt="$page.props.auth.user.name">
</button>
<div class="mt-3 space-y-1">
<ResponsiveNavLink :href="route('profile.show')" :active="route().current('profile.show')">
Profil
</ResponsiveNavLink>
<span v-else class="inline-flex rounded-md">
<button type="button" class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-none focus:bg-gray-50 active:bg-gray-50 transition ease-in-out duration-150">
{{ $page.props.auth.user.name }}
<svg class="ms-2 -me-0.5 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</button>
</span>
</template>
<ResponsiveNavLink v-if="$page.props.jetstream.hasApiFeatures" :href="route('api-tokens.index')" :active="route().current('api-tokens.index')">
API Tokens
</ResponsiveNavLink>
<template #content>
<div class="block px-4 py-2 text-xs text-gray-400">Nastavitve računa</div>
<!-- Authentication -->
<form method="POST" @submit.prevent="logout">
<ResponsiveNavLink as="button">
Izpis
</ResponsiveNavLink>
</form>
<DropdownLink :href="route('profile.show')">Profil</DropdownLink>
<DropdownLink v-if="$page.props.jetstream.hasApiFeatures" :href="route('api-tokens.index')">API Tokens</DropdownLink>
<!-- Team Management -->
<template v-if="$page.props.jetstream.hasTeamFeatures">
<div class="border-t border-gray-200" />
<div class="block px-4 py-2 text-xs text-gray-400">
Manage Team
</div>
<!-- Team Settings -->
<ResponsiveNavLink :href="route('teams.show', $page.props.auth.user.current_team)" :active="route().current('teams.show')">
Team Settings
</ResponsiveNavLink>
<ResponsiveNavLink v-if="$page.props.jetstream.canCreateTeams" :href="route('teams.create')" :active="route().current('teams.create')">
Create New Team
</ResponsiveNavLink>
<!-- Team Switcher -->
<template v-if="$page.props.auth.user.all_teams.length > 1">
<div class="border-t border-gray-200" />
<div class="block px-4 py-2 text-xs text-gray-400">
Switch Teams
</div>
<template v-for="team in $page.props.auth.user.all_teams" :key="team.id">
<form @submit.prevent="switchToTeam(team)">
<ResponsiveNavLink as="button">
<div class="flex items-center">
<svg v-if="team.id == $page.props.auth.user.current_team_id" class="me-2 h-5 w-5 text-green-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>{{ team.name }}</div>
</div>
</ResponsiveNavLink>
</form>
</template>
<form @submit.prevent="logout">
<DropdownLink as="button">Izpis</DropdownLink>
</form>
</template>
</template>
</Dropdown>
</div>
</div>
</div>
</nav>
<!-- Page Heading -->
<header v-if="$slots.header" class="bg-white shadow">
<div class="max-w-7xl mx-auto py-4 px-4 sm:px-6 lg:px-8">
<Breadcrumbs :breadcrumbs="$page.props.breadcrumbs"></Breadcrumbs>
</div>
</header>
<!-- Page Heading -->
<header v-if="$slots.header" class="bg-white border-b shadow-sm">
<div class="max-w-7xl mx-auto py-4 px-4 sm:px-6 lg:px-8 space-y-2">
<Breadcrumbs v-if="$page.props.breadcrumbs && $page.props.breadcrumbs.length" :breadcrumbs="$page.props.breadcrumbs" />
<slot name="header" />
</div>
</header>
<!-- Page Content -->
<main>
<slot />
</main>
<!-- Page Content -->
<main class="p-4">
<slot />
</main>
</div>
</div>
<!-- Global Search Modal -->
<GlobalSearch :open="searchOpen" @update:open="(v)=>searchOpen=v" />
<!-- Simple Toast -->
<transition name="fade">
<div
v-if="showToast"
class="fixed bottom-4 right-4 z-[100] px-4 py-3 rounded shadow-lg text-white"
:class="{
'bg-emerald-600': toastType==='success',
'bg-red-600': toastType==='error',
'bg-amber-500': toastType==='warning',
'bg-blue-600': toastType==='info',
}"
>
{{ toastMessage }}
</div>
</transition>
</div>
</template>

View File

@ -1,72 +1,91 @@
<script setup>
import { FwbInput, FwbListGroup, FwbListGroupItem } from 'flowbite-vue';
import { FwbInput } from 'flowbite-vue';
import axios from 'axios';
import { debounce } from 'lodash';
import { SearchIcon } from '@/Utilities/Icons';
import { ref, watch } from 'vue';
import Dropdown from '@/Components/Dropdown.vue';
import DropdownLink from '@/Components/DropdownLink.vue';
import { onMounted, onUnmounted, ref, watch } from 'vue';
import { Link } from '@inertiajs/vue3';
const props = defineProps({
css: String
open: { type: Boolean, default: false },
});
const emit = defineEmits(['update:open']);
const query = ref('');
const result = ref([]);
const result = ref({ clients: [], client_cases: [] });
const isOpen = ref(props.open);
watch(() => props.open, (v) => { isOpen.value = v; if (v) focusInput(); });
watch(isOpen, (v) => emit('update:open', v));
const searching = debounce((value) => {
axios.get(
route('search'),
{
params: {
query: value,
limit: 5,
tag: ''
}
}
)
.then(function(res) {
result.value = res.data
list.value = false;
console.log(res);
})
.catch(function(error){
console.log(error)
})
.finally(function(){
if (!value || !value.trim()) { result.value = { clients: [], client_cases: [] }; return; }
axios.get(route('search'), { params: { query: value, limit: 8, tag: '' } })
.then(res => { result.value = res.data; })
.catch(() => {})
}, 250);
});
}, 300);
watch(
() => query.value,
(val) => searching(val)
);
watch(() => query.value, (val) => searching(val));
const inputWrap = ref(null);
const focusInput = () => setTimeout(() => inputWrap.value?.querySelector('input')?.focus(), 0);
function onKeydown(e) {
if (e.key === 'Escape') { isOpen.value = false; }
}
onMounted(() => window.addEventListener('keydown', onKeydown));
onUnmounted(() => window.removeEventListener('keydown', onKeydown));
</script>
<template>
<Dropdown align="left" :contentClasses="['py-1 bg-white lg:w-60']">
<template #trigger>
<fwb-input
v-model="query"
placeholder="Iskalnik..."
size="sm"
class="lg:w-60"
>
<template #prefix>
<SearchIcon />
</template>
<teleport to="body">
<transition name="fade">
<div v-if="isOpen" class="fixed inset-0 z-50">
<!-- Backdrop -->
<div class="absolute inset-0 bg-black/30" @click="isOpen = false"></div>
</fwb-input>
</template>
<template #content>
<div class="block px-4 py-2 text-xs text-gray-400">Naročnik</div>
<!-- Dialog (click outside closes) -->
<div class="absolute inset-0 flex items-start sm:items-start justify-center p-4 pt-8 sm:pt-16" @click.self="isOpen = false">
<div class="w-full max-w-2xl bg-white rounded-lg shadow-xl overflow-hidden">
<div class="p-3 border-b" ref="inputWrap">
<FwbInput v-model="query" placeholder="Išči po naročnikih in primerih..." size="md" class="w-full">
<template #prefix>
<SearchIcon />
</template>
</FwbInput>
</div>
<div class="max-h-[60vh] overflow-auto">
<div v-if="!query" class="p-6 text-sm text-gray-500">Začni tipkati za iskanje. Namig: pritisni Ctrl+K kjerkoli.</div>
<div v-else>
<div class="px-4 py-2 text-xs text-gray-500">Naročniki</div>
<ul>
<li v-for="client in result.clients" :key="client.client_uuid">
<Link :href="route('client.show', {uuid: client.client_uuid})" class="block px-4 py-2 hover:bg-gray-50" @click="isOpen=false">
{{ client.full_name }}
</Link>
</li>
</ul>
<DropdownLink v-for="client in result.clients" :href="route('client.show', {uuid: client.client_uuid})">{{ client.full_name }}</DropdownLink>
<div class="border-t border-gray-200" />
<div class="block px-4 py-2 text-xs text-gray-400">Cases</div>
<DropdownLink v-for="clientcase in result.client_cases" :href="route('clientCase.show', {uuid: clientcase.case_uuid})">{{ clientcase.full_name }}</DropdownLink>
</template>
</Dropdown>
</template>
<div class="px-4 py-2 mt-2 text-xs text-gray-500">Primeri</div>
<ul>
<li v-for="clientcase in result.client_cases" :key="clientcase.case_uuid">
<Link :href="route('clientCase.show', {uuid: clientcase.case_uuid})" class="block px-4 py-2 hover:bg-gray-50" @click="isOpen=false">
{{ clientcase.full_name }}
</Link>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</transition>
</teleport>
<!-- no inline trigger here; AppLayout provides the button and opens this modal -->
</template>
<style>
.fade-enter-active, .fade-leave-active { transition: opacity .15s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>

View File

@ -1,7 +1,7 @@
<script setup>
import ActionMessage from '@/Components/ActionMessage.vue';
import BasicButton from '@/Components/buttons/BasicButton.vue';
import Drawer from '@/Components/Drawer.vue';
import DialogModal from '@/Components/DialogModal.vue';
import InputLabel from '@/Components/InputLabel.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import SectionTitle from '@/Components/SectionTitle.vue';
@ -87,7 +87,7 @@ const store = () => {
</script>
<template>
<Drawer
<DialogModal
:show="show"
@close="close"
>
@ -161,5 +161,5 @@ const store = () => {
</div>
</form>
</template>
</Drawer>
</DialogModal>
</template>

View File

@ -1,6 +1,6 @@
<script setup>
import ActionMessage from '@/Components/ActionMessage.vue';
import Drawer from '@/Components/Drawer.vue';
import DialogModal from '@/Components/DialogModal.vue';
import InputLabel from '@/Components/InputLabel.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import SectionTitle from '@/Components/SectionTitle.vue';
@ -48,7 +48,7 @@ const storeContract = () => {
</script>
<template>
<Drawer
<DialogModal
:show="show"
@close="close"
>
@ -97,6 +97,6 @@ const storeContract = () => {
</div>
</form>
</template>
</Drawer>
</DialogModal>
</template>

View File

@ -8,6 +8,11 @@ import ContractDrawer from "./Partials/ContractDrawer.vue";
import ContractTable from "./Partials/ContractTable.vue";
import ActivityDrawer from "./Partials/ActivityDrawer.vue";
import ActivityTable from "./Partials/ActivityTable.vue";
import DocumentsTable from "@/Components/DocumentsTable.vue";
import DocumentUploadDialog from "@/Components/DocumentUploadDialog.vue";
import DocumentViewerDialog from "@/Components/DocumentViewerDialog.vue";
import { classifyDocument } from "@/Services/documents";
import { router } from '@inertiajs/vue3';
import { AngleDownIcon, AngleUpIcon } from "@/Utilities/Icons";
import Pagination from "@/Components/Pagination.vue";
@ -18,9 +23,32 @@ const props = defineProps({
activities: Object,
contract_types: Array,
actions: Array,
types: Object
types: Object,
documents: Array
});
const showUpload = ref(false);
const openUpload = () => { showUpload.value = true; };
const closeUpload = () => { showUpload.value = false; };
const onUploaded = () => {
// Refresh page data to include the new document
router.reload({ only: ['documents'] });
};
const viewer = ref({ open: false, src: '', title: '' });
const openViewer = (doc) => {
const kind = classifyDocument(doc)
if (kind === 'preview') {
const url = route('clientCase.document.view', { client_case: props.client_case.uuid, document: doc.uuid })
viewer.value = { open: true, src: url, title: doc.original_name || doc.name };
} else {
const url = route('clientCase.document.download', { client_case: props.client_case.uuid, document: doc.uuid })
// immediate download: navigate to URL
window.location.href = url
}
};
const closeViewer = () => { viewer.value.open = false; viewer.value.src = ''; };
const clientDetails = ref(true);
//Drawer add new contract
@ -131,6 +159,29 @@ const hideClietnDetails = () => {
</div>
</div>
</div>
<!-- Documents section -->
<div class="pt-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg border-l-4">
<div class="mx-auto max-w-4x1">
<div class="flex justify-between p-4">
<SectionTitle>
<template #title>Dokumenti</template>
</SectionTitle>
<FwbButton @click="openUpload">Dodaj</FwbButton>
</div>
<DocumentsTable :documents="documents" @view="openViewer" />
</div>
</div>
</div>
</div>
<DocumentUploadDialog
:show="showUpload"
@close="closeUpload"
@uploaded="onUploaded"
:post-url="route('clientCase.document.store', client_case)"
/>
<DocumentViewerDialog :show="viewer.open" :src="viewer.src" :title="viewer.title" @close="closeViewer" />
<div class="pt-12 pb-6">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg border-l-4">

View File

@ -8,7 +8,7 @@ import InputLabel from '@/Components/InputLabel.vue';
import TextInput from '@/Components/TextInput.vue';
import { Link, useForm } from '@inertiajs/vue3';
import ActionMessage from '@/Components/ActionMessage.vue';
import Drawer from '@/Components/Drawer.vue';
import DialogModal from '@/Components/DialogModal.vue';
import Pagination from '@/Components/Pagination.vue';
import SearchInput from '@/Components/SearchInput.vue';
@ -117,7 +117,7 @@ const storeClient = () => {
</div>
</div>
</AppLayout>
<Drawer
<DialogModal
:show="drawerCreateClient"
@close="drawerCreateClient = false"
>
@ -250,5 +250,5 @@ const storeClient = () => {
</div>
</form>
</template>
</Drawer>
</DialogModal>
</template>

View File

@ -1,5 +1,5 @@
<script setup>
import Drawer from '@/Components/Drawer.vue';
import DialogModal from '@/Components/DialogModal.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import InputLabel from '@/Components/InputLabel.vue';
import TextInput from '@/Components/TextInput.vue';
@ -61,7 +61,7 @@ const storeCase = () => {
</script>
<template>
<Drawer
<DialogModal
:show="show"
@close="close">
@ -197,5 +197,5 @@ const storeCase = () => {
</div>
</form>
</template>
</Drawer>
</DialogModal>
</template>

View File

@ -1,6 +1,6 @@
<script setup>
import ActionMessage from '@/Components/ActionMessage.vue';
import Drawer from '@/Components/Drawer.vue';
import DialogModal from '@/Components/DialogModal.vue';
import InputLabel from '@/Components/InputLabel.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import SectionTitle from '@/Components/SectionTitle.vue';
@ -42,7 +42,7 @@ onMounted(() => {
</script>
<template>
<Drawer
<DialogModal
:show="show"
@close="close"
>
@ -114,5 +114,5 @@ onMounted(() => {
</div>
</form>
</template>
</Drawer>
</DialogModal>
</template>

View File

@ -0,0 +1,537 @@
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue';
import { ref, watch, computed } from 'vue';
import { useForm, router } from '@inertiajs/vue3';
import Multiselect from 'vue-multiselect';
import axios from 'axios';
const props = defineProps({
templates: Array,
clients: Array,
});
const hasHeader = ref(true);
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);
const form = useForm({
client_uuid: null,
import_template_id: null,
source_type: null,
sheet_name: null,
has_header: true,
file: null,
});
// Bridge Multiselect (expects option objects) to our form (stores client_uuid as string)
const selectedClientOption = computed({
get() {
const cuuid = form.client_uuid;
if (!cuuid) return null;
return (props.clients || []).find(c => c.uuid === cuuid) || null;
},
set(val) {
form.client_uuid = val ? val.uuid : null;
}
});
// Bridge Template Multiselect to store only template id (number) in form
const selectedTemplateOption = computed({
get() {
const tid = form.import_template_id;
if (tid == null) return null;
return (props.templates || []).find(t => t.id === tid) || null;
},
set(val) {
form.import_template_id = val ? val.id : null;
}
});
// Helper: selected client's numeric id (fallback)
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 cuuid = form.client_uuid;
const list = props.templates || [];
if (!cuuid) {
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 && t.client_uuid === cuuid);
});
function onFileChange(e) {
const files = e.target.files;
if (files && files.length) {
form.file = files[0];
}
}
async function submitUpload() {
await form.post(route('imports.store'), {
forceFormData: true,
onSuccess: (res) => {
const data = res?.props || {};
},
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,
}));
}
// If there are mappings already (template applied or saved), load them to auto-assign
await loadImportMappings();
}
async function uploadAndPreview() {
if (!form.file) {
// Basic guard: require a file before proceeding
return;
}
templateApplied.value = false;
processResult.value = null;
const fd = new window.FormData();
fd.append('file', form.file);
if (Number.isFinite(form.import_template_id)) {
fd.append('import_template_id', String(form.import_template_id));
}
if (form.client_uuid) {
fd.append('client_uuid', String(form.client_uuid));
}
fd.append('has_header', hasHeader.value ? '1' : '0');
try {
const { data } = await axios.post(route('imports.store'), fd, {
headers: { Accept: 'application/json' },
withCredentials: true,
});
// Redirect immediately to the continue page for this import
if (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 = recordToEntityKey(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;
}
}
const entityOptions = [
{ value: 'person', label: 'Person' },
{ value: 'person_addresses', label: 'Person Address' },
{ value: 'person_phones', label: 'Person Phone' },
{ value: 'emails', label: 'Email' },
{ value: 'accounts', label: 'Account' },
{ value: 'contracts', label: 'Contract' },
];
const fieldOptionsByEntity = {
person: [
{ value: 'first_name', label: 'First name' },
{ value: 'last_name', label: 'Last name' },
{ value: 'full_name', label: 'Full name' },
{ value: 'tax_number', label: 'Tax number' },
{ value: 'social_security_number', label: 'SSN' },
{ value: 'birthday', label: 'Birthday' },
{ value: 'gender', label: 'Gender' },
{ value: 'description', label: 'Description' },
],
person_addresses: [
{ value: 'address', label: 'Address' },
{ value: 'country', label: 'Country' },
{ value: 'type_id', label: 'Address Type Id' },
{ value: 'description', label: 'Description' },
],
person_phones: [
{ value: 'nu', label: 'Phone number' },
{ value: 'country_code', label: 'Country code' },
{ value: 'type_id', label: 'Phone Type Id' },
{ value: 'description', label: 'Description' },
],
emails: [
{ value: 'value', label: 'Email address' },
{ value: 'label', label: 'Label' },
{ value: 'is_primary', label: 'Is primary' },
],
accounts: [
{ value: 'reference', label: 'Reference' },
{ value: 'description', label: 'Description' },
{ value: 'contract_id', label: 'Contract Id' },
{ value: 'contract_reference', label: 'Contract Reference' },
{ value: 'type_id', label: 'Account Type Id' },
{ value: 'active', label: 'Active' },
{ value: 'balance_amount', label: 'Balance Amount' },
],
contracts: [
{ value: 'reference', label: 'Reference' },
{ value: 'start_date', label: 'Start Date' },
{ value: 'end_date', label: 'End Date' },
{ value: 'description', label: 'Description' },
{ value: 'client_case_id', label: 'Client Case Id' },
{ value: 'type_id', label: 'Contract Type Id' },
],
};
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: `${entityKeyToRecord(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;
}
try {
savingMappings.value = true;
const url = (typeof route === 'function')
? route('imports.mappings.save', { import: importId.value })
: `/imports/${importId.value}/mappings`;
const { data } = await axios.post(url, { mappings }, {
headers: { 'Accept': 'application/json' },
withCredentials: true,
});
mappingSaved.value = true;
mappingSavedCount.value = Number(data?.saved || mappings.length);
mappingError.value = '';
} catch (e) {
mappingSaved.value = false;
if (e.response) {
console.error('Save mappings error', e.response.status, e.response.data);
alert('Failed to save mappings: ' + (e.response.data?.message || e.response.status));
} else {
console.error('Save mappings error', e);
alert('Failed to save mappings. See console for details.');
}
} finally {
savingMappings.value = false;
}
}
// Reset saved flag whenever user edits mappings
watch(mappingRows, () => {
mappingSaved.value = false;
mappingSavedCount.value = 0;
mappingError.value = '';
}, { deep: true });
function entityKeyToRecord(key) {
// Map UI entities to record_type nouns used by processor
if (key === 'person_addresses') return 'address';
if (key === 'person_phones') return 'phone';
if (key === 'emails') return 'email';
if (key === 'accounts') return 'account';
if (key === 'contracts') return 'contract';
return 'person';
}
function recordToEntityKey(record) {
if (record === 'address') return 'person_addresses';
if (record === 'phone') return 'person_phones';
if (record === 'email') return 'emails';
if (record === 'account') return 'accounts';
if (record === 'contract') return 'contracts';
return 'person';
}
</script>
<template>
<AppLayout title="New Import">
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">New Import</h2>
</template>
<div class="py-6">
<div class="max-w-5xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white shadow sm:rounded-lg p-6 space-y-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700">Client</label>
<Multiselect
v-model="selectedClientOption"
:options="clients"
track-by="uuid"
label="name"
placeholder="Search clients..."
:searchable="true"
:allow-empty="true"
class="mt-1"
/>
</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"
>
<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>
<p class="text-xs text-gray-500 mt-1" v-if="!form.client_uuid">Only global templates are shown until a client is selected.</p>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700">File</label>
<input type="file" @change="onFileChange" class="mt-1 block w-full" />
</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 class="mt-2 text-xs text-gray-600" v-else-if="importId && !selectedMappingsCount">
Select an Entity and Field for at least one detected column (or uncheck Skip) and then click Save Mappings.
</div>
<div v-if="detected.columns.length" class="pt-4">
<h3 class="font-semibold mb-2">Detected Columns ({{ detected.has_header ? 'header' : 'positional' }})</h3>
<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">{{ row.source_column }}</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">
<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>
</div>
</div>
</AppLayout>
</template>

View File

@ -0,0 +1,805 @@
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue';
import { ref, computed, onMounted, watch } from 'vue';
import Multiselect from 'vue-multiselect';
import axios from 'axios';
const props = defineProps({
import: Object,
templates: Array,
clients: Array,
});
const importId = ref(props.import?.id || null);
const hasHeader = ref(Boolean(props.import?.meta?.has_header ?? true));
const detected = ref({ columns: props.import?.meta?.columns || [], delimiter: props.import?.meta?.detected_delimiter || ',', has_header: hasHeader.value });
const templateApplied = ref(Boolean(props.import?.import_template_id));
const processing = ref(false);
const processResult = ref(null);
const mappingRows = ref([]);
const mappingSaved = ref(false);
const mappingSavedCount = ref(0);
const mappingError = ref('');
const savingMappings = ref(false);
// Persisted mappings from backend (raw view regardless of detected columns)
const persistedMappings = ref([]);
const detectedNote = ref('');
// Logs
const events = ref([]);
const eventsLimit = ref(200);
const loadingEvents = ref(false);
// Completed status helper
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 canProcess = computed(() => !!importId.value && !processing.value && hasPersistedMappings.value && !isCompleted.value);
// Display rows used by the table: prefer mappingRows if present; otherwise fall back to detected columns
const displayRows = computed(() => {
if (Array.isArray(mappingRows.value) && mappingRows.value.length > 0) {
return mappingRows.value;
}
const cols = detected.value?.columns || [];
return cols.map((c, idx) => {
const guess = guessMappingForHeader(c);
return {
source_column: c,
entity: guess?.entity || '',
field: guess?.field || '',
skip: false,
transform: 'trim',
apply_mode: 'both',
position: idx,
};
});
});
// Header normalization and guess mapping for auto-assigning sensible defaults
function stripDiacritics(s) {
if (!s) return '';
return String(s)
.replace(/[čć]/gi, 'c')
.replace(/[š]/gi, 's')
.replace(/[ž]/gi, 'z')
.replace(/[đ]/gi, 'd')
.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
}
function normalizeHeader(h) {
if (!h) return '';
const base = stripDiacritics(String(h)).toLowerCase();
return base.replace(/[^a-z0-9]+/g, ' ').trim().replace(/\s+/g, '');
}
function guessMappingForHeader(h) {
const key = normalizeHeader(h);
// Confident matches only; avoid overly generic keys like 'name'
const dict = {
// Person
firstname: { entity: 'person', field: 'first_name' },
givenname: { entity: 'person', field: 'first_name' },
forename: { entity: 'person', field: 'first_name' },
lastname: { entity: 'person', field: 'last_name' },
surname: { entity: 'person', field: 'last_name' },
familyname: { entity: 'person', field: 'last_name' },
fullname: { entity: 'person', field: 'full_name' },
taxnumber: { entity: 'person', field: 'tax_number' },
taxid: { entity: 'person', field: 'tax_number' },
tin: { entity: 'person', field: 'tax_number' },
socialsecuritynumber: { entity: 'person', field: 'social_security_number' },
ssn: { entity: 'person', field: 'social_security_number' },
birthday: { entity: 'person', field: 'birthday' },
birthdate: { entity: 'person', field: 'birthday' },
dob: { entity: 'person', field: 'birthday' },
gender: { entity: 'person', field: 'gender' },
description: { entity: 'person', field: 'description' },
// Email
email: { entity: 'emails', field: 'value' },
emailaddress: { entity: 'emails', field: 'value' },
// Phone
phone: { entity: 'person_phones', field: 'nu' },
phonenumber: { entity: 'person_phones', field: 'nu' },
mobile: { entity: 'person_phones', field: 'nu' },
gsm: { entity: 'person_phones', field: 'nu' },
telephone: { entity: 'person_phones', field: 'nu' },
countrycode: { entity: 'person_phones', field: 'country_code' },
// Address
address: { entity: 'person_addresses', field: 'address' },
street: { entity: 'person_addresses', field: 'address' },
country: { entity: 'person_addresses', field: 'country' },
// Accounts
accountreference: { entity: 'accounts', field: 'reference' },
accountref: { entity: 'accounts', field: 'reference' },
balancedue: { entity: 'accounts', field: 'balance_amount' },
balance: { entity: 'accounts', field: 'balance_amount' },
amount: { entity: 'accounts', field: 'balance_amount' },
// Contracts
reference: { entity: 'contracts', field: 'reference' },
ref: { entity: 'contracts', field: 'reference' },
contractreference: { entity: 'contracts', field: 'reference' },
contractref: { entity: 'contracts', field: 'reference' },
contract: { entity: 'contracts', field: 'reference' },
contractno: { entity: 'contracts', field: 'reference' },
contractnum: { entity: 'contracts', field: 'reference' },
contractnumber: { entity: 'contracts', field: 'reference' },
agreement: { entity: 'contracts', field: 'reference' },
agreementno: { entity: 'contracts', field: 'reference' },
agreementnum: { entity: 'contracts', field: 'reference' },
agreementnumber: { entity: 'contracts', field: 'reference' },
startdate: { entity: 'contracts', field: 'start_date' },
enddate: { entity: 'contracts', field: 'end_date' },
// Slovenian/common localized headers (normalized)
ime: { entity: 'person', field: 'first_name' },
priimek: { entity: 'person', field: 'last_name' },
imeinpriimek: { entity: 'person', field: 'full_name' },
polnoime: { entity: 'person', field: 'full_name' },
davcna: { entity: 'person', field: 'tax_number' },
davcnastevilka: { entity: 'person', field: 'tax_number' },
emso: { entity: 'person', field: 'social_security_number' },
rojstnidatum: { entity: 'person', field: 'birthday' },
spol: { entity: 'person', field: 'gender' },
opis: { entity: 'person', field: 'description' },
eposta: { entity: 'emails', field: 'value' },
elektronskaposta: { entity: 'emails', field: 'value' },
telefon: { entity: 'person_phones', field: 'nu' },
mobilni: { entity: 'person_phones', field: 'nu' },
gsm: { entity: 'person_phones', field: 'nu' },
klicna: { entity: 'person_phones', field: 'country_code' },
drzava: { entity: 'person_addresses', field: 'country' },
naslov: { entity: 'person_addresses', field: 'address' },
ulica: { entity: 'person_addresses', field: 'address' },
sklic: { entity: 'accounts', field: 'reference' },
referenca: { entity: 'accounts', field: 'reference' },
saldo: { entity: 'accounts', field: 'balance_amount' },
znesek: { entity: 'accounts', field: 'balance_amount' },
pogodbasklic: { entity: 'contracts', field: 'reference' },
pogodbastevilka: { entity: 'contracts', field: 'reference' },
pogodba: { entity: 'contracts', field: 'reference' },
pogodbast: { entity: 'contracts', field: 'reference' },
zacetek: { entity: 'contracts', field: 'start_date' },
konec: { entity: 'contracts', field: 'end_date' },
};
return dict[key] || null;
}
// Normalize source column to match persisted mappings in a case/space/diacritic-insensitive way
function normalizeSource(s) {
return normalizeHeader(s);
}
// Entity and field options used by the mapping grid
const entityOptions = [
{ value: 'person', label: 'Person' },
{ value: 'person_addresses', label: 'Person Address' },
{ value: 'person_phones', label: 'Person Phone' },
{ value: 'emails', label: 'Email' },
{ value: 'accounts', label: 'Account' },
{ value: 'contracts', label: 'Contract' },
];
const fieldOptionsByEntity = {
person: [
{ value: 'first_name', label: 'First name' },
{ value: 'last_name', label: 'Last name' },
{ value: 'full_name', label: 'Full name' },
{ value: 'tax_number', label: 'Tax number' },
{ value: 'social_security_number', label: 'SSN' },
{ value: 'birthday', label: 'Birthday' },
{ value: 'gender', label: 'Gender' },
{ value: 'description', label: 'Description' },
],
person_addresses: [
{ value: 'address', label: 'Address' },
{ value: 'country', label: 'Country' },
{ value: 'type_id', label: 'Address Type Id' },
{ value: 'description', label: 'Description' },
],
person_phones: [
{ value: 'nu', label: 'Phone number' },
{ value: 'country_code', label: 'Country code' },
{ value: 'type_id', label: 'Phone Type Id' },
{ value: 'description', label: 'Description' },
],
emails: [
{ value: 'value', label: 'Email address' },
{ value: 'label', label: 'Label' },
{ value: 'is_primary', label: 'Is primary' },
],
accounts: [
{ value: 'reference', label: 'Reference' },
{ value: 'description', label: 'Description' },
{ value: 'contract_id', label: 'Contract Id' },
{ value: 'contract_reference', label: 'Contract Reference' },
{ value: 'type_id', label: 'Account Type Id' },
{ value: 'active', label: 'Active' },
{ value: 'balance_amount', label: 'Balance Amount' },
],
contracts: [
{ value: 'reference', label: 'Reference' },
{ value: 'start_date', label: 'Start Date' },
{ value: 'end_date', label: 'End Date' },
{ value: 'description', label: 'Description' },
{ value: 'client_case_id', label: 'Client Case Id' },
{ value: 'type_id', label: 'Contract Type Id' },
],
};
// Local state for selects
const form = ref({
client_uuid: null,
import_template_id: props.import?.import_template_id || null,
});
// Initialize client_uuid from numeric client_id using provided clients list
if (props.import?.client_id) {
const found = (props.clients || []).find(c => c.id === props.import.client_id);
form.value.client_uuid = found ? found.uuid : null;
}
const selectedClientOption = computed({
get() {
const cuuid = form.value.client_uuid;
if (!cuuid) return null;
return (props.clients || []).find(c => c.uuid === cuuid) || null;
},
set(val) {
form.value.client_uuid = val ? val.uuid : null;
}
});
const selectedTemplateOption = computed({
get() {
const tid = form.value.import_template_id;
if (tid == null) return null;
return (props.templates || []).find(t => t.id === tid) || null;
},
set(val) {
form.value.import_template_id = val ? val.id : null;
}
});
// Show only global templates when no client is selected.
// When a client is selected, show only that client's templates (strict match by client_uuid, no globals).
const filteredTemplates = computed(() => {
const cuuid = form.value.client_uuid;
const list = props.templates || [];
if (!cuuid) {
return list.filter(t => t.client_id == null);
}
return list.filter(t => t.client_uuid && t.client_uuid === cuuid);
});
const selectedMappingsCount = computed(() => mappingRows.value.filter(r => !r.skip && r.entity && r.field).length);
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 } });
// Normalize columns to strings for consistent rendering
const colsRaw = Array.isArray(data.columns) ? data.columns : [];
const normCols = colsRaw.map((c) => {
if (typeof c === 'string' || typeof c === 'number') return String(c);
if (c && typeof c === 'object') {
return String(c.name ?? c.header ?? c.label ?? Object.values(c)[0] ?? '');
}
return '';
}).filter(Boolean);
detected.value = {
columns: normCols,
delimiter: data.detected_delimiter || ',',
has_header: !!data.has_header,
};
detectedNote.value = data.note || '';
// initialize mapping rows if empty
if (!mappingRows.value.length && detected.value.columns.length) {
mappingRows.value = detected.value.columns.map((c, idx) => {
const guess = guessMappingForHeader(c);
return {
source_column: c,
entity: guess?.entity || '',
field: guess?.field || '',
skip: false,
transform: 'trim',
apply_mode: 'both',
position: idx,
};
});
}
await loadImportMappings();
// Fallback: if no detected columns were found, but persisted mappings exist, use them to render the grid
if ((!detected.value.columns || detected.value.columns.length === 0) && mappingRows.value.length === 0 && persistedMappings.value.length > 0) {
mappingRows.value = persistedMappings.value.map((m, idx) => {
const tf = String(m.target_field || '');
const [record, field] = tf ? tf.split('.', 2) : ['', ''];
return {
source_column: m.source_column,
entity: recordToEntityKey(record),
field: field || '',
skip: false,
transform: m.transform || 'trim',
apply_mode: m.apply_mode || 'both',
position: idx,
};
});
}
}
async function applyTemplateToImport() {
if (!importId.value || !form.value.import_template_id) return;
try {
await axios.post(route('importTemplates.apply', { template: form.value.import_template_id, import: importId.value }), {}, {
headers: { Accept: 'application/json' },
withCredentials: true,
});
templateApplied.value = true;
await loadImportMappings();
} catch (e) {
templateApplied.value = false;
console.error('Apply template error', e.response?.status || '', e.response?.data || 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 : [];
// Store raw persisted mappings for display regardless of detected columns
persistedMappings.value = rows.slice();
if (!rows.length) return;
const bySource = new Map(rows.map(r => [normalizeSource(r.source_column), r]));
mappingRows.value = (mappingRows.value || []).map((r, idx) => {
const m = bySource.get(normalizeSource(r.source_column));
if (!m) return r;
const tf = String(m.target_field || '');
let entity = m.entity || '';
let field = r.field || '';
if (tf) {
const [record, fld] = tf.split('.', 2);
const inferred = recordToEntityKey(record);
if (!entity) entity = inferred;
if (fld) field = fld;
}
return {
...r,
entity,
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 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,
entity: r.entity || null,
target_field: `${entityKeyToRecord(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;
}
try {
savingMappings.value = true;
const url = route('imports.mappings.save', { import: importId.value });
const { data } = await axios.post(url, { mappings }, {
headers: { 'Accept': 'application/json' },
withCredentials: true,
});
mappingSaved.value = true;
mappingSavedCount.value = Number(data?.saved || mappings.length);
mappingError.value = '';
// Refresh persisted mappings so Process gating reflects the actual DB state
await loadImportMappings();
} catch (e) {
mappingSaved.value = false;
console.error('Save mappings error', e.response?.status || '', e.response?.data || e);
} finally {
savingMappings.value = false;
}
}
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) {
console.error('Process import error', e.response?.status || '', e.response?.data || e);
processResult.value = { error: 'Processing failed' };
} finally {
processing.value = false;
}
}
// Helpers
function entityKeyToRecord(key) {
if (key === 'person_addresses') return 'address';
if (key === 'person_phones') return 'phone';
if (key === 'emails') return 'email';
if (key === 'accounts') return 'account';
if (key === 'contracts') return 'contract';
return 'person';
}
function recordToEntityKey(record) {
switch (record) {
case 'person':
return 'person';
case 'address':
case 'person_address':
case 'person_addresses':
return 'person_addresses';
case 'phone':
case 'person_phone':
case 'person_phones':
return 'person_phones';
case 'email':
case 'emails':
return 'emails';
case 'account':
case 'accounts':
return 'accounts';
case 'contract':
case 'contracts':
return 'contracts';
default:
return '';
}
}
// Initial load
onMounted(async () => {
// Build mapping grid from existing meta columns if present
if (detected.value.columns?.length) {
mappingRows.value = (detected.value.columns || []).map((c, idx) => {
const guess = guessMappingForHeader(c);
return {
source_column: c,
entity: guess?.entity || '',
field: guess?.field || '',
skip: false,
transform: 'trim',
apply_mode: 'both',
position: idx,
};
});
await loadImportMappings();
} else {
await fetchColumns();
}
// Load recent events (logs)
await fetchEvents();
});
// Reset saved flag whenever user edits mappings
watch(mappingRows, () => {
mappingSaved.value = false;
mappingSavedCount.value = 0;
mappingError.value = '';
}, { deep: true });
// If detected columns are loaded after mount, initialize mapping rows once
watch(() => detected.value.columns, (cols) => {
if (Array.isArray(cols) && cols.length > 0 && mappingRows.value.length === 0) {
mappingRows.value = cols.map((c, idx) => {
const guess = guessMappingForHeader(c);
return {
source_column: c,
entity: guess?.entity || '',
field: guess?.field || '',
skip: false,
transform: 'trim',
apply_mode: 'both',
position: idx,
};
});
}
});
async function fetchEvents() {
if (!importId.value) return;
loadingEvents.value = true;
try {
const { data } = await axios.get(route('imports.events', { import: importId.value }), {
params: { limit: eventsLimit.value },
headers: { Accept: 'application/json' },
withCredentials: true,
});
events.value = Array.isArray(data?.events) ? data.events : [];
} catch (e) {
console.error('Load import events error', e.response?.status || '', e.response?.data || e);
} finally {
loadingEvents.value = false;
}
}
</script>
<template>
<AppLayout :title="`Import ${props.import?.uuid || ''}`">
<template #header>
<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>
<div class="text-sm text-gray-600">
<span class="mr-4">Client: <strong>{{ selectedClientOption?.name || selectedClientOption?.uuid || '—' }}</strong></span>
<span>
Template:
<strong>{{ selectedTemplateOption?.name || '—' }}</strong>
<span v-if="templateApplied" class="ml-1 text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600 align-middle">locked</span>
</span>
</div>
</div>
</template>
<div class="py-6">
<div class="max-w-5xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white shadow sm:rounded-lg p-6 space-y-6">
<div v-if="isCompleted" class="p-3 border rounded bg-gray-50 text-sm">
<div class="flex flex-wrap gap-x-6 gap-y-1">
<div><span class="text-gray-600">Status:</span> <span class="font-medium text-emerald-700">Completed</span></div>
<div><span class="text-gray-600">Finished:</span> <span class="font-medium">{{ props.import?.finished_at ? new Date(props.import.finished_at).toLocaleString() : '—' }}</span></div>
<div><span class="text-gray-600">Total:</span> <span class="font-medium">{{ props.import?.total_rows ?? '—' }}</span></div>
<div><span class="text-gray-600">Imported:</span> <span class="font-medium">{{ props.import?.imported_rows ?? '—' }}</span></div>
<div><span class="text-gray-600">Invalid:</span> <span class="font-medium">{{ props.import?.invalid_rows ?? '—' }}</span></div>
<div><span class="text-gray-600">Valid:</span> <span class="font-medium">{{ props.import?.valid_rows ?? '—' }}</span></div>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700">Client</label>
<Multiselect
v-model="selectedClientOption"
:options="clients"
track-by="uuid"
label="name"
placeholder="Search clients..."
:searchable="true"
:allow-empty="true"
class="mt-1"
disabled
/>
<p class="text-xs text-gray-500 mt-1">Client is set during upload.</p>
</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="templateApplied"
>
<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 class="flex gap-3" v-if="!isCompleted">
<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 || 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-else-if="importId && !hasPersistedMappings && !isCompleted">
Apply a template or select Entity and Field for one or more columns, then click Save Mappings to enable processing.
</div>
<div v-if="persistedMappings.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 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">
<h3 class="font-semibold mb-2">
<template v-if="!isCompleted">Detected Columns ({{ detected.has_header ? 'header' : 'positional' }})
<span class="ml-2 text-xs text-gray-500">detected: {{ detected.columns.length }}, rows: {{ displayRows.length }}</span>
</template>
<template v-else>Detected Columns</template>
</h3>
<p v-if="detectedNote" class="text-xs text-gray-500 mb-2">{{ 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 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" :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">
<h3 class="font-semibold mb-2">Detected Columns</h3>
<p class="text-sm text-gray-600">No columns detected. {{ detectedNote || 'Preview is available for CSV/TXT files. You can still apply a template or use the saved mappings below.' }}</p>
</div>
<div v-if="processResult" 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">{{ processResult }}</pre>
</div>
<div class="pt-4">
<div class="flex items-center justify-between mb-2">
<h3 class="font-semibold">Logs</h3>
<div class="flex items-center gap-2 text-sm">
<label class="text-gray-600">Show</label>
<select 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>
</AppLayout>
</template>
<style scoped>
</style>

View File

@ -0,0 +1,75 @@
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue';
import { Link } from '@inertiajs/vue3';
const props = defineProps({
imports: Object,
});
function statusBadge(status) {
const map = {
uploaded: 'bg-gray-200 text-gray-700',
parsed: 'bg-blue-100 text-blue-800',
validating: 'bg-amber-100 text-amber-800',
completed: 'bg-emerald-100 text-emerald-800',
failed: 'bg-red-100 text-red-800',
};
return map[status] || 'bg-gray-100 text-gray-800';
}
</script>
<template>
<AppLayout title="Uvozi">
<template #header>
<div class="flex items-center justify-between">
<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>
</div>
</template>
<div class="py-6">
<div class="max-w-6xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white shadow sm:rounded-lg p-6">
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead>
<tr class="text-left text-xs uppercase text-gray-500 border-b">
<th class="p-2">Datum</th>
<th class="p-2">Datoteka</th>
<th class="p-2">Status</th>
<th class="p-2">Naročnik</th>
<th class="p-2">Predloga</th>
<th class="p-2">Akcije</th>
</tr>
</thead>
<tbody>
<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">{{ 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">{{ imp.client?.uuid ?? '—' }}</td>
<td class="p-2">{{ imp.template?.name ?? '—' }}</td>
<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 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>
<span v-else class="text-xs text-gray-400">Zaključen</span>
</td>
</tr>
</tbody>
</table>
</div>
<div class="flex items-center justify-between mt-4 text-sm text-gray-600">
<div>
Prikaz {{ imports.meta.from }}{{ imports.meta.to }} od {{ imports.meta.total }}
</div>
<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 v-if="imports.links.next" :href="imports.links.next" class="px-2 py-1 border rounded">Naprej</Link>
</div>
</div>
</div>
</div>
</div>
</AppLayout>
</template>

View File

@ -0,0 +1,123 @@
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue';
import { ref } from 'vue';
import { useForm } from '@inertiajs/vue3';
import Multiselect from 'vue-multiselect';
const props = defineProps({
clients: Array,
});
const form = useForm({
name: '',
description: '',
source_type: 'csv',
default_record_type: '',
is_active: true,
client_uuid: null,
entities: [],
});
function submit() {
form.post(route('importTemplates.store'), {
onSuccess: () => {
// You can redirect or show a success message here
},
});
}
</script>
<template>
<AppLayout title="Create Import Template">
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Create Import Template</h2>
</template>
<div class="py-6">
<div class="max-w-3xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white shadow sm:rounded-lg p-6 space-y-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700">Client (optional)</label>
<Multiselect
v-model="form.client_uuid"
:options="props.clients || []"
:reduce="c => c.uuid"
track-by="uuid"
label="name"
placeholder="Global (no client)"
:searchable="true"
:allow-empty="true"
class="mt-1"
/>
<p class="text-xs text-gray-500 mt-1">Leave empty to make this template global (visible to all clients).</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Entities (tables)</label>
<Multiselect
v-model="form.entities"
:options="[
{ value: 'person', label: 'Person' },
{ value: 'person_addresses', label: 'Person Addresses' },
{ value: 'person_phones', label: 'Person Phones' },
{ value: 'emails', label: 'Emails' },
{ value: 'accounts', label: 'Accounts' },
{ value: 'contracts', label: 'Contracts' },
]"
:multiple="true"
track-by="value"
label="label"
:reduce="o => o.value"
placeholder="Select one or more entities"
:searchable="false"
class="mt-1"
/>
<p class="text-xs text-gray-500 mt-1">Choose which tables this template targets. You can still define per-column mappings later.</p>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Name</label>
<input v-model="form.name" type="text" class="mt-1 block w-full border rounded p-2" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Description</label>
<textarea v-model="form.description" class="mt-1 block w-full border rounded p-2" rows="3" />
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700">Source Type</label>
<select v-model="form.source_type" class="mt-1 block w-full border rounded p-2">
<option value="csv">CSV</option>
<option value="xml">XML</option>
<option value="xls">XLS</option>
<option value="xlsx">XLSX</option>
<option value="json">JSON</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Default Record Type (optional)</label>
<input v-model="form.default_record_type" type="text" class="mt-1 block w-full border rounded p-2" placeholder="e.g., account, person" />
</div>
</div>
<div class="flex items-center gap-2">
<input id="is_active" v-model="form.is_active" type="checkbox" class="rounded" />
<label for="is_active" class="text-sm font-medium text-gray-700">Active</label>
</div>
<div class="pt-4">
<button @click.prevent="submit" class="px-4 py-2 bg-emerald-600 text-white rounded" :disabled="form.processing">
{{ form.processing ? 'Saving…' : 'Create Template' }}
</button>
</div>
<div v-if="form.errors && Object.keys(form.errors).length" class="text-sm text-red-600">
<div v-for="(msg, key) in form.errors" :key="key">{{ msg }}</div>
</div>
</div>
</div>
</div>
</AppLayout>
</template>

View File

@ -0,0 +1,495 @@
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue';
import { ref, computed } from 'vue';
import { useForm, Link } from '@inertiajs/vue3';
import Multiselect from 'vue-multiselect';
const props = defineProps({
template: Object,
clients: Array,
});
const form = useForm({
name: props.template.name,
description: props.template.description,
source_type: props.template.source_type,
default_record_type: props.template.default_record_type || '',
is_active: props.template.is_active,
client_uuid: props.template.client_uuid || null,
});
const entities = computed(() => (props.template.meta?.entities || []));
const hasMappings = computed(() => (props.template.mappings?.length || 0) > 0);
const canChangeClient = computed(() => !hasMappings.value); // guard reassignment when mappings exist (optional rule)
// Local state for adding a new mapping row per entity accordion
const newRows = ref({});
const bulkRows = ref({}); // per-entity textarea and options
const bulkGlobal = ref({ entity: '', sources: '', default_field: '', transform: '', apply_mode: 'both' });
const unassigned = computed(() => (props.template.mappings || []).filter(m => !m.target_field));
const unassignedSourceColumns = computed(() => {
const set = new Set();
for (const m of unassigned.value) {
if (m.source_column) set.add(m.source_column);
}
return Array.from(set).sort((a,b)=>a.localeCompare(b));
});
const unassignedState = ref({});
function saveUnassigned(m) {
const st = unassignedState.value[m.id] || {};
if (st.entity && st.field) {
m.target_field = `${st.entity}.${st.field}`;
} else {
m.target_field = null;
}
updateMapping(m);
}
const entityOptions = [
{ key: 'person', label: 'Person' },
{ key: 'person_addresses', label: 'Person Addresses' },
{ key: 'person_phones', label: 'Person Phones' },
{ key: 'emails', label: 'Emails' },
{ key: 'accounts', label: 'Accounts' },
{ key: 'contracts', label: 'Contracts' },
];
const fieldOptions = {
person: [
'first_name', 'last_name', 'full_name', 'gender', 'birthday', 'tax_number', 'social_security_number', 'description'
],
person_addresses: [ 'address', 'country', 'type_id', 'description' ],
person_phones: [ 'nu', 'country_code', 'type_id', 'description' ],
emails: [ 'email', 'is_primary' ],
accounts: [ 'reference', 'balance_amount', 'contract_id', 'contract_reference' ],
contracts: [ 'reference', 'start_date', 'end_date', 'description', 'type_id', 'client_case_id' ],
};
function toggle(entity) {
const el = document.getElementById(`acc-${entity}`);
if (el) el.open = !el.open;
}
function addRow(entity) {
const row = newRows.value[entity];
if (!row || !row.source || !row.field) return;
const target_field = `${entity}.${row.field}`;
const payload = {
source_column: row.source,
target_field,
transform: row.transform || null,
apply_mode: row.apply_mode || 'both',
position: (props.template.mappings?.length || 0) + 1,
};
useForm(payload).post(route('importTemplates.mappings.add', { template: props.template.uuid }), {
preserveScroll: true,
onSuccess: () => { newRows.value[entity] = {}; },
});
}
function updateMapping(m) {
const payload = {
source_column: m.source_column,
target_field: m.target_field,
transform: m.transform,
apply_mode: m.apply_mode,
position: m.position,
};
useForm(payload).put(route('importTemplates.mappings.update', { template: props.template.uuid, mapping: m.id }), {
preserveScroll: true,
});
}
function deleteMapping(m) {
useForm({}).delete(route('importTemplates.mappings.delete', { template: props.template.uuid, mapping: m.id }), {
preserveScroll: true,
});
}
function reorder(entity, direction, m) {
// Build new order across all mappings, swapping positions for this entity scope
const all = [...props.template.mappings];
const entityMaps = all.filter(x => x.target_field?.startsWith(entity + '.'));
const idx = entityMaps.findIndex(x => x.id === m.id);
if (idx < 0) return;
const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
if (swapIdx < 0 || swapIdx >= entityMaps.length) return;
const a = entityMaps[idx];
const b = entityMaps[swapIdx];
// Build final ordered ids list using current order, swapping a/b positions
const byId = Object.fromEntries(all.map(x => [x.id, x]));
const ordered = all.map(x => x.id);
const ai = ordered.indexOf(a.id);
const bi = ordered.indexOf(b.id);
if (ai < 0 || bi < 0) return;
[ordered[ai], ordered[bi]] = [ordered[bi], ordered[ai]];
useForm({ order: ordered }).post(route('importTemplates.mappings.reorder', { template: props.template.uuid }), {
preserveScroll: true,
});
}
// Save basic
const save = () => {
const payload = { ...form.data() };
if (!canChangeClient.value) {
// drop client change when blocked
delete payload.client_uuid;
}
useForm(payload).put(route('importTemplates.update', { template: props.template.uuid }), { preserveScroll: true });
};
// Non-blocking confirm modal state for delete
const deleteConfirmOpen = ref(false);
const deleteForm = useForm({});
function openDeleteConfirm() { deleteConfirmOpen.value = true; }
function cancelDelete() { deleteConfirmOpen.value = false; }
function performDelete() {
deleteForm.delete(route('importTemplates.destroy', { template: props.template.uuid }), {
onFinish: () => { deleteConfirmOpen.value = false; },
});
}
</script>
<template>
<AppLayout :title="`Edit Template: ${props.template.name}`">
<template #header>
<div class="flex items-center justify-between">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Uredi uvozno predlogo</h2>
<div class="flex items-center gap-2">
<Link :href="route('importTemplates.index')" class="px-3 py-1.5 border rounded text-sm">Nazaj</Link>
<button class="px-3 py-1.5 border rounded text-sm text-red-700 border-red-300 hover:bg-red-50" @click.prevent="openDeleteConfirm">Izbriši predlogo</button>
</div>
</div>
</template>
<div class="py-6">
<div class="max-w-5xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white shadow sm:rounded-lg p-6 space-y-6">
<!-- Basic info -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700">Ime predloge</label>
<input v-model="form.name" type="text" class="mt-1 block w-full border rounded p-2" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Vir</label>
<select v-model="form.source_type" class="mt-1 block w-full border rounded p-2">
<option value="csv">CSV</option>
<option value="xml">XML</option>
<option value="xls">XLS</option>
<option value="xlsx">XLSX</option>
<option value="json">JSON</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Privzeti tip zapisa</label>
<input v-model="form.default_record_type" type="text" class="mt-1 block w-full border rounded p-2" placeholder="npr.: account, person" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Naročnik (opcijsko)</label>
<Multiselect
v-model="form.client_uuid"
:options="props.clients || []"
:reduce="c => c.uuid"
track-by="uuid"
label="name"
placeholder="Global (brez naročnika)"
:searchable="true"
:allow-empty="true"
class="mt-1"
:disabled="!canChangeClient"
/>
<p v-if="!canChangeClient" class="text-xs text-amber-600 mt-1">Ni mogoče spremeniti naročnika, ker ta predloga že vsebuje preslikave.</p>
</div>
<div class="flex items-center gap-2">
<input id="is_active" v-model="form.is_active" type="checkbox" class="rounded" />
<label for="is_active" class="text-sm font-medium text-gray-700">Aktivna</label>
<button @click.prevent="save" class="ml-auto px-3 py-2 bg-indigo-600 text-white rounded">Shrani</button>
</div>
</div>
<!-- Sample headers viewer/editor -->
<div class="p-3 bg-gray-50 rounded border">
<div class="flex items-center justify-between mb-2">
<div class="text-sm font-medium text-gray-700">Vzorčni glavi stolpcev</div>
<button class="text-xs px-2 py-1 border rounded" @click.prevent="form.sample_headers = (form.sample_headers || []).concat([''])">Dodaj stolpec</button>
</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2">
<div v-for="(h, i) in (form.sample_headers = form.sample_headers ?? props.template.sample_headers ?? [])" :key="i" class="flex items-center gap-2">
<input v-model="form.sample_headers[i]" type="text" class="flex-1 border rounded p-2" placeholder="npr.: reference" />
<button class="px-2 py-1 border rounded" @click.prevent="form.sample_headers.splice(i,1)"></button>
</div>
</div>
</div>
<!-- Global Bulk Add Mappings -->
<div class="p-3 bg-indigo-50 rounded border border-indigo-200">
<div class="mb-2 text-sm font-medium text-indigo-900">Bulk dodajanje preslikav</div>
<div class="grid grid-cols-1 sm:grid-cols-6 gap-3 items-start">
<div class="sm:col-span-3">
<label class="block text-xs text-indigo-900">Source columns (CSV ali po vrsticah)</label>
<textarea v-model="bulkGlobal.sources" rows="3" class="mt-1 w-full border rounded p-2" placeholder="npr.: reference,first name,last name,amount"></textarea>
</div>
<div>
<label class="block text-xs text-indigo-900">Entity (opcijsko)</label>
<select v-model="bulkGlobal.entity" class="mt-1 w-full border rounded p-2">
<option value="">(brez pusti target prazno)</option>
<option v-for="opt in entityOptions" :key="opt.key" :value="opt.key">{{ opt.label }}</option>
</select>
</div>
<div>
<label class="block text-xs text-indigo-900">Field (opcijsko, za vse)</label>
<select v-model="bulkGlobal.default_field" class="mt-1 w-full border rounded p-2" :disabled="!bulkGlobal.entity">
<option value="">(auto from source)</option>
<option v-for="f in (fieldOptions[bulkGlobal.entity] || [])" :key="f" :value="f">{{ f }}</option>
</select>
</div>
<div>
<label class="block text-xs text-indigo-900">Transform (za vse)</label>
<select v-model="bulkGlobal.transform" class="mt-1 w-full border rounded p-2">
<option value="">None</option>
<option value="trim">trim</option>
<option value="upper">upper</option>
<option value="lower">lower</option>
</select>
</div>
<div>
<label class="block text-xs text-indigo-900">Apply (za vse)</label>
<select v-model="bulkGlobal.apply_mode" class="mt-1 w-full border rounded p-2">
<option value="both">both</option>
<option value="insert">insert</option>
<option value="update">update</option>
</select>
</div>
</div>
<div class="mt-3">
<button
@click.prevent="(() => {
if (!bulkGlobal.sources || !bulkGlobal.sources.trim()) return;
useForm({
sources: bulkGlobal.sources,
entity: bulkGlobal.entity || null,
default_field: bulkGlobal.default_field || null,
transform: bulkGlobal.transform || null,
apply_mode: bulkGlobal.apply_mode || 'both',
}).post(route('importTemplates.mappings.bulk', { template: props.template.uuid }), {
preserveScroll: true,
onSuccess: () => { bulkGlobal.entity=''; bulkGlobal.sources=''; bulkGlobal.default_field=''; bulkGlobal.transform=''; bulkGlobal.apply_mode='both'; },
});
})()"
class="px-3 py-2 bg-indigo-600 text-white rounded"
>Dodaj več</button>
</div>
</div>
<!-- Unassigned mappings (no target_field) -->
<div v-if="unassigned.length" class="p-3 bg-amber-50 rounded border border-amber-200">
<div class="mb-2 text-sm font-medium text-amber-900">Nedodeljene preslikave ({{ unassigned.length }})</div>
<div class="space-y-2">
<div v-for="m in unassigned" :key="m.id" class="p-2 bg-white/60 border rounded">
<div class="grid grid-cols-1 sm:grid-cols-6 gap-2 items-center">
<div class="text-sm">
<div class="text-gray-500 text-xs">Source</div>
<div class="font-medium">{{ m.source_column }}</div>
</div>
<div>
<label class="block text-xs text-gray-600">Entity</label>
<select v-model="(unassignedState[m.id] ||= {}).entity" class="mt-1 w-full border rounded p-2">
<option value="">(izberi)</option>
<option v-for="opt in entityOptions" :key="opt.key" :value="opt.key">{{ opt.label }}</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-600">Field</label>
<select v-model="(unassignedState[m.id] ||= {}).field" class="mt-1 w-full border rounded p-2" :disabled="!(unassignedState[m.id]||{}).entity">
<option value="">(izberi)</option>
<option v-for="f in (fieldOptions[(unassignedState[m.id]||{}).entity] || [])" :key="f" :value="f">{{ f }}</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-600">Transform</label>
<select v-model="m.transform" class="mt-1 w-full border rounded p-2">
<option value="">None</option>
<option value="trim">trim</option>
<option value="upper">upper</option>
<option value="lower">lower</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-600">Apply</label>
<select v-model="m.apply_mode" class="mt-1 w-full border rounded p-2">
<option value="both">both</option>
<option value="insert">insert</option>
<option value="update">update</option>
</select>
</div>
<div class="flex items-center gap-2">
<button class="px-3 py-1.5 bg-emerald-600 text-white rounded text-sm" @click.prevent="saveUnassigned(m)">Shrani</button>
<button class="px-3 py-1.5 bg-red-600 text-white rounded text-sm" @click.prevent="deleteMapping(m)">Izbriši</button>
</div>
</div>
</div>
</div>
</div>
<!-- Entities accordion -->
<div class="divide-y">
<details v-for="entity in entities" :key="entity" :id="`acc-${entity}`" class="py-3">
<summary class="cursor-pointer select-none flex items-center justify-between">
<span class="font-medium">{{ entityOptions.find(e=>e.key===entity)?.label || entity }}</span>
<span class="text-xs text-gray-500">Klikni za razširitev</span>
</summary>
<div class="mt-4 space-y-4">
<!-- Existing mappings for this entity -->
<div v-if="props.template.mappings && props.template.mappings.length" class="space-y-2">
<div v-for="m in props.template.mappings.filter(m=>m.target_field?.startsWith(entity + '.'))" :key="m.id" class="flex items-center justify-between p-2 border rounded gap-3">
<div class="grid grid-cols-1 sm:grid-cols-5 gap-2 flex-1 items-center">
<input v-model="m.source_column" class="border rounded p-2 text-sm" />
<input v-model="m.target_field" class="border rounded p-2 text-sm" />
<select v-model="m.transform" class="border rounded p-2 text-sm">
<option value="">None</option>
<option value="trim">trim</option>
<option value="upper">upper</option>
<option value="lower">lower</option>
</select>
<select v-model="m.apply_mode" class="border rounded p-2 text-sm">
<option value="both">both</option>
<option value="insert">insert</option>
<option value="update">update</option>
</select>
<div class="flex items-center gap-2">
<button class="px-2 py-1 text-xs border rounded" @click.prevent="reorder(entity, 'up', m)"></button>
<button class="px-2 py-1 text-xs border rounded" @click.prevent="reorder(entity, 'down', m)"></button>
</div>
</div>
<div class="flex items-center gap-2">
<button class="px-3 py-1.5 bg-emerald-600 text-white rounded text-sm" @click.prevent="updateMapping(m)">Shrani</button>
<button class="px-3 py-1.5 bg-red-600 text-white rounded text-sm" @click.prevent="deleteMapping(m)">Izbriši</button>
</div>
</div>
</div>
<div v-else class="text-sm text-gray-500">Ni definiranih preslikav za to entiteto.</div>
<!-- Add new mapping row -->
<div class="p-3 bg-gray-50 rounded border">
<div class="grid grid-cols-1 sm:grid-cols-5 gap-2 items-end">
<div>
<label class="block text-xs text-gray-600">Source column (ne-dodeljene)</label>
<select v-model="(newRows[entity] ||= {}).source" class="mt-1 w-full border rounded p-2">
<option value="" disabled>(izberi)</option>
<option v-for="s in unassignedSourceColumns" :key="s" :value="s">{{ s }}</option>
</select>
<p v-if="!unassignedSourceColumns.length" class="text-xs text-gray-500 mt-1">Ni nedodeljenih virov. Uporabi Bulk ali najprej dodaj vire.</p>
</div>
<div>
<label class="block text-xs text-gray-600">Field</label>
<select v-model="(newRows[entity] ||= {}).field" class="mt-1 w-full border rounded p-2">
<option v-for="f in (fieldOptions[entity] || [])" :key="f" :value="f">{{ f }}</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-600">Transform</label>
<select v-model="(newRows[entity] ||= {}).transform" class="mt-1 w-full border rounded p-2">
<option value="">None</option>
<option value="trim">trim</option>
<option value="upper">upper</option>
<option value="lower">lower</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-600">Apply</label>
<select v-model="(newRows[entity] ||= {}).apply_mode" class="mt-1 w-full border rounded p-2">
<option value="both">both</option>
<option value="insert">insert</option>
<option value="update">update</option>
</select>
</div>
<div class="sm:col-span-1">
<button @click.prevent="addRow(entity)" class="w-full sm:w-auto px-3 py-2 bg-emerald-600 text-white rounded">Dodaj preslikavo</button>
</div>
</div>
</div>
<!-- Bulk add mapping rows -->
<div class="p-3 bg-gray-50 rounded border">
<div class="mb-2 text-xs text-gray-600">Dodaj več stolpcev naenkrat (ločeno z vejicami ali novimi vrsticami). Če polje ne izbereš, bo target nastavljen na entity + ime stolpca.</div>
<div class="grid grid-cols-1 sm:grid-cols-5 gap-2 items-start">
<div class="sm:col-span-2">
<label class="block text-xs text-gray-600">Source columns (CSV ali po vrsticah)</label>
<textarea v-model="(bulkRows[entity] ||= {}).sources" rows="3" class="mt-1 w-full border rounded p-2" placeholder="npr.: reference,first name,last name"></textarea>
</div>
<div>
<label class="block text-xs text-gray-600">Field (opcijsko, za vse)</label>
<select v-model="(bulkRows[entity] ||= {}).default_field" class="mt-1 w-full border rounded p-2">
<option value="">(auto from source)</option>
<option v-for="f in (fieldOptions[entity] || [])" :key="f" :value="f">{{ f }}</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-600">Transform (za vse)</label>
<select v-model="(bulkRows[entity] ||= {}).transform" class="mt-1 w-full border rounded p-2">
<option value="">None</option>
<option value="trim">trim</option>
<option value="upper">upper</option>
<option value="lower">lower</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-600">Apply (za vse)</label>
<select v-model="(bulkRows[entity] ||= {}).apply_mode" class="mt-1 w-full border rounded p-2">
<option value="both">both</option>
<option value="insert">insert</option>
<option value="update">update</option>
</select>
</div>
</div>
<div class="mt-2">
<button
@click.prevent="(() => {
const b = bulkRows[entity] ||= {};
if (!b.sources || !b.sources.trim()) return;
useForm({
sources: b.sources,
entity,
default_field: b.default_field || null,
transform: b.transform || null,
apply_mode: b.apply_mode || 'both',
}).post(route('importTemplates.mappings.bulk', { template: props.template.uuid }), {
preserveScroll: true,
onSuccess: () => { bulkRows[entity] = {}; },
});
})()"
class="px-3 py-2 bg-indigo-600 text-white rounded"
>Dodaj več</button>
</div>
</div>
</div>
</details>
</div>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div v-if="deleteConfirmOpen" class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/30" @click="cancelDelete"></div>
<div class="relative bg-white rounded shadow-lg w-96 max-w-[90%] p-5">
<div class="text-lg font-semibold mb-2">Izbrišem predlogo?</div>
<p class="text-sm text-gray-600 mb-4">Tega dejanja ni mogoče razveljaviti. Vse preslikave te predloge bodo izbrisane.</p>
<div class="flex items-center justify-end gap-2">
<button class="px-3 py-1.5 border rounded" @click.prevent="cancelDelete" :disabled="deleteForm.processing">Prekliči</button>
<button class="px-3 py-1.5 rounded text-white bg-red-600 disabled:opacity-60" @click.prevent="performDelete" :disabled="deleteForm.processing">
<span v-if="deleteForm.processing">Brisanje</span>
<span v-else>Izbriši</span>
</button>
</div>
</div>
</div>
</AppLayout>
</template>
<style>
.fade-enter-active, .fade-leave-active { transition: opacity .2s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>
<!-- moved modal into main template to avoid multiple <template> blocks -->

View File

@ -0,0 +1,88 @@
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue';
import { Link, useForm } from '@inertiajs/vue3';
import { ref } from 'vue';
// Non-blocking confirm modal state
const confirmOpen = ref(false);
const confirmUuid = ref(null);
const deleteForm = useForm({});
function requestDelete(uuid) {
confirmUuid.value = uuid;
confirmOpen.value = true;
}
function performDelete() {
if (!confirmUuid.value) return;
deleteForm.delete(route('importTemplates.destroy', { template: confirmUuid.value }), {
preserveScroll: true,
onFinish: () => {
confirmOpen.value = false;
confirmUuid.value = null;
},
});
}
function cancelDelete() {
confirmOpen.value = false;
confirmUuid.value = null;
}
const props = defineProps({
templates: Array,
});
</script>
<template>
<AppLayout title="Uvozne predloge">
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Uvozne predloge</h2>
</template>
<div class="py-6">
<div class="max-w-5xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white shadow sm:rounded-lg p-6">
<div class="flex items-center justify-between mb-4">
<div class="text-sm text-gray-600">Skupaj: {{ props.templates?.length || 0 }}</div>
<Link :href="route('importTemplates.create')" class="px-3 py-2 bg-emerald-600 text-white rounded">Nova predloga</Link>
</div>
<div class="divide-y">
<div v-for="t in props.templates" :key="t.uuid" class="py-3 flex items-center justify-between">
<div>
<div class="font-medium">{{ t.name }}</div>
<div class="text-xs text-gray-500">{{ t.description }}</div>
<div class="text-xs text-gray-400 mt-1">{{ t.client?.name || 'Global' }} {{ t.source_type.toUpperCase() }}</div>
</div>
<div class="flex items-center gap-2">
<span :class="['text-xs px-2 py-0.5 rounded', t.is_active ? 'bg-emerald-50 text-emerald-700' : 'bg-gray-100 text-gray-500']">{{ t.is_active ? 'Active' : 'Inactive' }}</span>
<Link :href="route('importTemplates.edit', { template: t.uuid })" class="px-3 py-1.5 border rounded text-sm">Uredi</Link>
<button
class="px-3 py-1.5 border rounded text-sm text-red-700 border-red-300 hover:bg-red-50"
@click.prevent="requestDelete(t.uuid)"
>Izbriši</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Confirm Delete Modal -->
<div v-if="confirmOpen" class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/30" @click="cancelDelete"></div>
<div class="relative bg-white rounded shadow-lg w-96 max-w-[90%] p-5">
<div class="text-lg font-semibold mb-2">Izbrišem predlogo?</div>
<p class="text-sm text-gray-600 mb-4">Tega dejanja ni mogoče razveljaviti. Vse preslikave te predloge bodo izbrisane.</p>
<div class="flex items-center justify-end gap-2">
<button class="px-3 py-1.5 border rounded" @click.prevent="cancelDelete" :disabled="deleteForm.processing">Prekliči</button>
<button class="px-3 py-1.5 rounded text-white bg-red-600 disabled:opacity-60" @click.prevent="performDelete" :disabled="deleteForm.processing">
<span v-if="deleteForm.processing">Brisanje</span>
<span v-else>Izbriši</span>
</button>
</div>
</div>
</div>
</AppLayout>
</template>

View File

@ -1,7 +1,7 @@
<script setup>
import { FwbTable, FwbTableBody, FwbTableHead, FwbTableHeadCell, FwbTableCell, FwbTableRow, FwbDropdown } from 'flowbite-vue';
import { DottedMenu, EditIcon, TrashBinIcon } from '@/Utilities/Icons';
import Drawer from '@/Components/Drawer.vue';
import DialogModal from '@/Components/DialogModal.vue';
import { onMounted, ref } from 'vue';
import { useForm } from '@inertiajs/vue3';
import InputLabel from '@/Components/InputLabel.vue';
@ -134,7 +134,7 @@ const store = () => {
</fwb-table-row>
</fwb-table-body>
</fwb-table>
<Drawer
<DialogModal
:show="drawerEdit"
@close="closeEditDrawer"
>
@ -211,9 +211,9 @@ const store = () => {
</div>
</form>
</template>
</Drawer>
</DialogModal>
<Drawer
<DialogModal
:show="drawerCreate"
@close="closeCreateDrawer"
>
@ -285,5 +285,5 @@ const store = () => {
</div>
</form>
</template>
</Drawer>
</DialogModal>
</template>

View File

@ -1,7 +1,7 @@
<script setup>
import { FwbTable, FwbTableBody, FwbTableHead, FwbTableHeadCell, FwbTableCell, FwbTableRow } from 'flowbite-vue';
import { EditIcon } from '@/Utilities/Icons';
import Drawer from '@/Components/Drawer.vue';
import DialogModal from '@/Components/DialogModal.vue';
import { onMounted, ref } from 'vue';
import { useForm } from '@inertiajs/vue3';
import InputLabel from '@/Components/InputLabel.vue';
@ -116,7 +116,7 @@ const store = () => {
</fwb-table-body>
</fwb-table>
<Drawer
<DialogModal
:show="drawerEdit"
@close="closeEditDrawer"
>
@ -174,9 +174,9 @@ const store = () => {
</div>
</form>
</template>
</Drawer>
</DialogModal>
<Drawer
<DialogModal
:show="drawerCreate"
@close="closeCreateDrawer"
>
@ -234,5 +234,5 @@ const store = () => {
</div>
</form>
</template>
</Drawer>
</DialogModal>
</template>

View File

@ -0,0 +1,25 @@
export type DocLike = { uuid: string; original_name?: string; name?: string; extension?: string; mime_type?: string }
const PREVIEW_EXTS = ['pdf', 'txt', 'jpeg', 'jpg', 'png', 'doc', 'docx']
const DOWNLOAD_ONLY_EXTS = ['xls', 'xlsx', 'csv']
export function getExtension(doc: DocLike): string {
const ext = doc.extension || (doc.original_name || doc.name || '').split('.').pop() || ''
return ext.toLowerCase()
}
export function isPreviewableExt(ext?: string): boolean {
if (!ext) return false
return PREVIEW_EXTS.includes(ext.toLowerCase())
}
export function isDownloadOnlyExt(ext?: string): boolean {
if (!ext) return false
return DOWNLOAD_ONLY_EXTS.includes(ext.toLowerCase())
}
export function classifyDocument(doc: DocLike): 'preview' | 'download' {
const ext = getExtension(doc)
if (isPreviewableExt(ext)) return 'preview'
return 'download'
}

View File

@ -8,6 +8,7 @@ import { ZiggyVue } from '../../vendor/tightenco/ziggy';
import VueApexCharts from 'vue3-apexcharts';
import VueDatePicker from '@vuepic/vue-datepicker';
import '@vuepic/vue-datepicker/dist/main.css';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
@ -20,6 +21,7 @@ createInertiaApp({
.use(ZiggyVue)
.use(VueApexCharts)
.component('vue-date-picker', VueDatePicker)
.component('FontAwesomeIcon', FontAwesomeIcon)
.mount(el);
},
progress: {

View File

@ -2,3 +2,9 @@ import axios from 'axios';
window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
// Ensure CSRF token is sent with axios requests (useful when not relying on XSRF cookie)
const token = document.head && document.head.querySelector('meta[name="csrf-token"]');
if (token) {
window.axios.defaults.headers.common['X-CSRF-TOKEN'] = token.getAttribute('content');
}

View File

@ -6,6 +6,8 @@
use App\Http\Controllers\ClientController;
use App\Http\Controllers\ContractController;
use App\Http\Controllers\SettingController;
use App\Http\Controllers\ImportController;
use App\Http\Controllers\ImportTemplateController;
use App\Models\Person\Person;
use Illuminate\Http\Request;
use ArielMejiaDev\LarapexCharts\LarapexChart;
@ -94,6 +96,8 @@
Route::put('person/{person:uuid}/address/{address_id}', [PersonController::class, 'updateAddress'])->name('person.address.update');
Route::post('person/{person:uuid}/phone', [PersonController::class, 'createPhone'])->name('person.phone.create');
Route::put('person/{person:uuid}/phone/{phone_id}', [PersonController::class, 'updatePhone'])->name('person.phone.update');
Route::post('person/{person:uuid}/email', [PersonController::class, 'createEmail'])->name('person.email.create');
Route::put('person/{person:uuid}/email/{email_id}', [PersonController::class, 'updateEmail'])->name('person.email.update');
//client
Route::get('clients', [ClientController::class, 'index'])->name('client');
Route::get('clients/{client:uuid}', [ClientController::class, 'show'])->name('client.show');
@ -110,12 +114,41 @@
Route::delete('client-cases/{client_case:uuid}/contract/{uuid}', [ClientCaseContoller::class, 'deleteContract'])->name('clientCase.contract.delete');
//client-case / activity
Route::post('client-cases/{client_case:uuid}/activity', [ClientCaseContoller::class, 'storeActivity'])->name('clientCase.activity.store');
//client-case / documents
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}/download', [ClientCaseContoller::class, 'downloadDocument'])->name('clientCase.document.download');
//settings
Route::get('settings', [SettingController::class, 'index'])->name('settings');
Route::post('settings/actions', [SettingController::class, 'storeAction'])->name('settings.actions.store');
Route::put('settings/actions/{id}', [SettingController::class, 'updateAction'])->name('settings.actions.update');
Route::post('settings/decisions', [SettingController::class, 'storeDecision'])->name('settings.decisions.store');
Route::put('settings/decisions/{id}', [SettingController::class, 'updateDecision'])->name('settings.decisions.update');
// imports
Route::get('imports/create', [ImportController::class, 'create'])->name('imports.create');
Route::get('imports', [ImportController::class, 'index'])->name('imports.index');
Route::get('imports/import/{import:uuid}', [ImportController::class, 'show'])->name('imports.continue');
Route::post('imports', [ImportController::class, 'store'])->name('imports.store');
Route::get('imports/{import}/columns', [ImportController::class, 'columns'])->name('imports.columns');
Route::post('imports/{import}/process', [ImportController::class, 'process'])->name('imports.process');
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}/events', [ImportController::class, 'getEvents'])->name('imports.events');
// import templates
Route::get('import-templates', [ImportTemplateController::class, 'index'])->name('importTemplates.index');
Route::get('imports/templates/create', [ImportTemplateController::class, 'create'])->name('importTemplates.create');
Route::post('import-templates', [ImportTemplateController::class, 'store'])->name('importTemplates.store');
Route::get('imports/templates/edit/{template:uuid}', [ImportTemplateController::class, 'edit'])->name('importTemplates.edit');
Route::put('import-templates/{template:uuid}', [ImportTemplateController::class, 'update'])->name('importTemplates.update');
Route::delete('import-templates/{template:uuid}', [ImportTemplateController::class, 'destroy'])->name('importTemplates.destroy');
Route::post('import-templates/{template:uuid}/mappings', [ImportTemplateController::class, 'addMapping'])->name('importTemplates.mappings.add');
Route::post('import-templates/{template:uuid}/mappings/bulk', [ImportTemplateController::class, 'bulkAddMappings'])->name('importTemplates.mappings.bulk');
Route::put('import-templates/{template:uuid}/mappings/{mapping}', [ImportTemplateController::class, 'updateMapping'])->name('importTemplates.mappings.update');
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}/apply/{import}', [ImportTemplateController::class, 'applyToImport'])->name('importTemplates.apply');
//Route::put()
//types