Mager updated
This commit is contained in:
parent
d17e34941b
commit
7227c888d4
13
.env.example
13
.env.example
|
|
@ -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
|
||||
|
|
|
|||
64
app/Console/Commands/PruneDocumentPreviews.php
Normal file
64
app/Console/Commands/PruneDocumentPreviews.php
Normal 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
34
app/Console/Kernel.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
358
app/Http/Controllers/ImportController.php
Normal file
358
app/Http/Controllers/ImportController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
432
app/Http/Controllers/ImportTemplateController.php
Normal file
432
app/Http/Controllers/ImportTemplateController.php
Normal 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 template’s 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
149
app/Jobs/GenerateDocumentPreview.php
Normal file
149
app/Jobs/GenerateDocumentPreview.php
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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
83
app/Models/Document.php
Normal 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
35
app/Models/Email.php
Normal 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
50
app/Models/Import.php
Normal 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);
|
||||
}
|
||||
}
|
||||
35
app/Models/ImportEvent.php
Normal file
35
app/Models/ImportEvent.php
Normal 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
28
app/Models/ImportRow.php
Normal 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);
|
||||
}
|
||||
}
|
||||
38
app/Models/ImportTemplate.php
Normal file
38
app/Models/ImportTemplate.php
Normal 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);
|
||||
}
|
||||
}
|
||||
25
app/Models/ImportTemplateMapping.php
Normal file
25
app/Models/ImportTemplateMapping.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
65
app/Services/CsvImportService.php
Normal file
65
app/Services/CsvImportService.php
Normal 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];
|
||||
}
|
||||
}
|
||||
795
app/Services/ImportProcessor.php
Normal file
795
app/Services/ImportProcessor.php
Normal 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
19
config/files.php
Normal 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),
|
||||
];
|
||||
|
|
@ -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
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
//
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -27,7 +27,8 @@ public function run(): void
|
|||
PersonSeeder::class,
|
||||
SegmentSeeder::class,
|
||||
ActionSeeder::class,
|
||||
EventSeeder::class
|
||||
EventSeeder::class,
|
||||
ImportTemplateSeeder::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
51
database/seeders/ImportTemplateSeeder.php
Normal file
51
database/seeders/ImportTemplateSeeder.php
Normal 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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
6
resources/examples/sample_import.csv
Normal file
6
resources/examples/sample_import.csv
Normal 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,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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
116
resources/js/Components/DocumentUploadDialog.vue
Normal file
116
resources/js/Components/DocumentUploadDialog.vue
Normal 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>
|
||||
26
resources/js/Components/DocumentViewerDialog.vue
Normal file
26
resources/js/Components/DocumentViewerDialog.vue
Normal 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>
|
||||
139
resources/js/Components/DocumentsTable.vue
Normal file
139
resources/js/Components/DocumentsTable.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
537
resources/js/Pages/Imports/Create.vue
Normal file
537
resources/js/Pages/Imports/Create.vue
Normal 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>
|
||||
805
resources/js/Pages/Imports/Import.vue
Normal file
805
resources/js/Pages/Imports/Import.vue
Normal 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>
|
||||
75
resources/js/Pages/Imports/Index.vue
Normal file
75
resources/js/Pages/Imports/Index.vue
Normal 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>
|
||||
123
resources/js/Pages/Imports/Templates/Create.vue
Normal file
123
resources/js/Pages/Imports/Templates/Create.vue
Normal 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>
|
||||
495
resources/js/Pages/Imports/Templates/Edit.vue
Normal file
495
resources/js/Pages/Imports/Templates/Edit.vue
Normal 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 -->
|
||||
88
resources/js/Pages/Imports/Templates/Index.vue
Normal file
88
resources/js/Pages/Imports/Templates/Index.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
25
resources/js/Services/documents.ts
Normal file
25
resources/js/Services/documents.ts
Normal 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'
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
6
resources/js/bootstrap.js
vendored
6
resources/js/bootstrap.js
vendored
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user