Customer_Import в main 2 недель назад
| @@ -14,7 +14,10 @@ | |||
| "Bash(findstr \"^app\")", | |||
| "PowerShell(cd \"d:\\\\Development\\\\PHP\\\\Campaign-Tracker\"; Get-ChildItem -Recurse -Directory | Select-Object -ExpandProperty FullName | Where-Object { $_ -notmatch '\\\\.git|\\\\.claude|node_modules' } | Sort-Object | Select-Object -First 30)", | |||
| "Bash(git -C \"d:\\\\Development\\\\PHP\\\\Campaign-Tracker\" remote get-url origin)", | |||
| "PowerShell(Get-ChildItem -Path \"d:\\\\Development\\\\PHP\\\\Campaign-Tracker\" -Recurse -Directory -ErrorAction SilentlyContinue | Select-Object -First 30 | ForEach-Object { $_.FullName })" | |||
| "PowerShell(Get-ChildItem -Path \"d:\\\\Development\\\\PHP\\\\Campaign-Tracker\" -Recurse -Directory -ErrorAction SilentlyContinue | Select-Object -First 30 | ForEach-Object { $_.FullName })", | |||
| "Skill(graphify)", | |||
| "PowerShell(python -c $script)", | |||
| "PowerShell(python .graphify_query.py)" | |||
| ] | |||
| } | |||
| } | |||
| @@ -1,5 +1,7 @@ | |||
| # AGENT.md — PHP Coding Standard | |||
| before processing If there are any questions to be answered use agent /graphify "<question>" | |||
| This file defines the coding standards and working rules for AI agents and developers contributing to this PHP codebase. It is based on the principles from **PHP: The Right Way** and adapted into practical project instructions. | |||
| Source reference: https://phptherightway.com/ | |||
| @@ -1,5 +1,8 @@ | |||
| # AGENT.md — PHP Coding Standard | |||
| before processing If there are any questions to be answered use agent /graphify "<question>" | |||
| This file defines the coding standards and working rules for AI agents and developers contributing to this PHP codebase. It is based on the principles from **PHP: The Right Way** and adapted into practical project instructions. | |||
| Source reference: https://phptherightway.com/ | |||
| @@ -8,6 +8,7 @@ use App\Models\Customer; | |||
| use App\Repositories\CustomerAuditRepository; | |||
| use App\Repositories\CustomerRepository; | |||
| use App\Repositories\CustomerTypeRepository; | |||
| use App\Services\FileImportService; | |||
| use App\ViewModels\CustomerViewModel; | |||
| use Core\Controller; | |||
| use Core\Request; | |||
| @@ -217,6 +218,162 @@ class CustomerController extends Controller | |||
| return $this->redirect('/customers?deleted=1'); | |||
| } | |||
| // ── CSV Import ──────────────────────────────────────────────────────────── | |||
| public function importUpload(): Response | |||
| { | |||
| $request = Request::capture(); | |||
| if (!verify_csrf_token((string) $request->input('_token', ''))) { | |||
| return Response::json(['error' => 'Session expired. Please refresh.'], 419); | |||
| } | |||
| $upload = $_FILES['csv_file'] ?? null; | |||
| if ($upload === null || ($upload['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) { | |||
| return Response::json(['error' => 'No file was uploaded.'], 422); | |||
| } | |||
| try { | |||
| $service = $this->fileImport(); | |||
| $filename = $service->store($upload); | |||
| $data = $service->rows($filename, '0'); | |||
| return Response::json(['temp_name' => $filename, 'headers' => $data['headers']]); | |||
| } catch (\Throwable $e) { | |||
| return Response::json(['error' => $e->getMessage()], 422); | |||
| } | |||
| } | |||
| public function importPreview(): Response | |||
| { | |||
| $request = Request::capture(); | |||
| if (!verify_csrf_token((string) $request->input('_token', ''))) { | |||
| return Response::json(['error' => 'Session expired. Please refresh.'], 419); | |||
| } | |||
| $customerTypeId = (int) $request->input('customer_type_id', 0); | |||
| if ($customerTypeId === 0) { | |||
| return Response::json(['error' => 'Customer type is required.'], 422); | |||
| } | |||
| $tempName = basename((string) $request->input('temp_name', '')); | |||
| if ($tempName === '') { | |||
| return Response::json(['error' => 'No file uploaded.'], 422); | |||
| } | |||
| $mapping = (array) ($request->input('mapping') ?? []); | |||
| try { | |||
| $data = $this->fileImport()->rows($tempName, '0'); | |||
| $rows = []; | |||
| $stats = ['total' => 0, 'ok' => 0, 'duplicate' => 0, 'empty' => 0]; | |||
| foreach ($data['rows'] as $i => $csvRow) { | |||
| $stats['total']++; | |||
| $attributeValues = $this->applyMapping($mapping, $csvRow); | |||
| $hasValue = false; | |||
| foreach ($attributeValues as $v) { | |||
| if ($v !== '') { $hasValue = true; break; } | |||
| } | |||
| if (!$hasValue) { | |||
| $stats['empty']++; | |||
| $rows[] = ['index' => $i + 1, 'status' => 'empty', 'values' => $attributeValues, 'message' => 'Row is empty']; | |||
| continue; | |||
| } | |||
| $encodedValues = json_encode($attributeValues, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); | |||
| $duplicate = $this->repo()->findDuplicate($customerTypeId, $encodedValues); | |||
| if ($duplicate !== null) { | |||
| $stats['duplicate']++; | |||
| $rows[] = [ | |||
| 'index' => $i + 1, | |||
| 'status' => 'duplicate', | |||
| 'values' => $attributeValues, | |||
| 'message' => 'Duplicate of Customer #' . (int) $duplicate['id'], | |||
| ]; | |||
| } else { | |||
| $stats['ok']++; | |||
| $rows[] = ['index' => $i + 1, 'status' => 'ok', 'values' => $attributeValues, 'message' => null]; | |||
| } | |||
| } | |||
| return Response::json(['rows' => $rows, 'stats' => $stats]); | |||
| } catch (\Throwable $e) { | |||
| return Response::json(['error' => $e->getMessage()], 422); | |||
| } | |||
| } | |||
| public function importApprove(): Response | |||
| { | |||
| $request = Request::capture(); | |||
| if (!verify_csrf_token((string) $request->input('_token', ''))) { | |||
| return Response::json(['error' => 'Session expired. Please refresh.'], 419); | |||
| } | |||
| $customerTypeId = (int) $request->input('customer_type_id', 0); | |||
| if ($customerTypeId === 0) { | |||
| return Response::json(['error' => 'Customer type is required.'], 422); | |||
| } | |||
| $tempName = basename((string) $request->input('temp_name', '')); | |||
| if ($tempName === '') { | |||
| return Response::json(['error' => 'No file uploaded.'], 422); | |||
| } | |||
| $mapping = (array) ($request->input('mapping') ?? []); | |||
| try { | |||
| $service = $this->fileImport(); | |||
| $data = $service->rows($tempName, '0'); | |||
| $inserted = 0; | |||
| $skipped = 0; | |||
| $errors = []; | |||
| foreach ($data['rows'] as $i => $csvRow) { | |||
| $attributeValues = $this->applyMapping($mapping, $csvRow); | |||
| $hasValue = false; | |||
| foreach ($attributeValues as $v) { | |||
| if ($v !== '') { $hasValue = true; break; } | |||
| } | |||
| if (!$hasValue) { | |||
| $skipped++; | |||
| continue; | |||
| } | |||
| try { | |||
| $customer = new Customer(); | |||
| $customer->customerTypeId = $customerTypeId; | |||
| $customer->attributeValues = $attributeValues; | |||
| $this->repo()->create($customer); | |||
| $insertedRow = $this->repo()->findLatestByType($customerTypeId); | |||
| if ($insertedRow !== null) { | |||
| $this->auditRepo()->log( | |||
| (int) $insertedRow['id'], | |||
| 'I', | |||
| $this->toAuditFields($insertedRow), | |||
| $this->currentUsername() | |||
| ); | |||
| } | |||
| $inserted++; | |||
| } catch (\Throwable $e) { | |||
| $errors[] = 'Row ' . ($i + 1) . ': ' . $e->getMessage(); | |||
| } | |||
| } | |||
| $service->delete($tempName); | |||
| return Response::json(['inserted' => $inserted, 'skipped' => $skipped, 'errors' => $errors]); | |||
| } catch (\Throwable $e) { | |||
| return Response::json(['error' => $e->getMessage()], 422); | |||
| } | |||
| } | |||
| // ── Helpers ─────────────────────────────────────────────────────────────── | |||
| private function loadCustomerTypes(): array | |||
| @@ -286,6 +443,22 @@ class CustomerController extends Controller | |||
| return auth()->user()?->username ?? 'system'; | |||
| } | |||
| private function applyMapping(array $mapping, array $csvRow): array | |||
| { | |||
| $attributeValues = []; | |||
| foreach ($mapping as $attrName => $csvColumn) { | |||
| $csvColumn = trim((string) $csvColumn); | |||
| if ($csvColumn === '') continue; | |||
| $attributeValues[trim((string) $attrName)] = trim((string) ($csvRow[$csvColumn] ?? '')); | |||
| } | |||
| return $attributeValues; | |||
| } | |||
| private function fileImport(): FileImportService | |||
| { | |||
| return new FileImportService(); | |||
| } | |||
| private function repo(): CustomerRepository | |||
| { | |||
| return new CustomerRepository(database()); | |||
| @@ -4,7 +4,7 @@ window.__initialCtId = <?= json_encode($model->form['customer_type_id'], JSON_ | |||
| window.__initialCtVals = <?= json_encode($model->form['attribute_values'], JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>; | |||
| </script> | |||
| <section class="content-stack"> | |||
| <section class="content-stack" x-data="customerForm(window.__customerTypes, window.__initialCtId, window.__initialCtVals)"> | |||
| <div class="page-toolbar"> | |||
| <div class="section-heading"> | |||
| @@ -20,7 +20,7 @@ window.__initialCtVals = <?= json_encode($model->form['attribute_values'], JSON_ | |||
| </div> | |||
| <?php else: ?> | |||
| <section class="section-panel" x-data="customerForm(window.__customerTypes, window.__initialCtId, window.__initialCtVals)"> | |||
| <section class="section-panel"> | |||
| <?php if (isset($model->errors['_token'])): ?> | |||
| <div class="alert alert-error"><?= e($model->errors['_token'][0]) ?></div> | |||
| @@ -132,6 +132,182 @@ window.__initialCtVals = <?= json_encode($model->form['attribute_values'], JSON_ | |||
| </section> | |||
| <!-- ── CSV Import Panel ─────────────────────────────────────────────── --> | |||
| <section class="section-panel" x-show="selectedTypeId && selectedTypeId !== '0'" x-cloak> | |||
| <div class="panel-header"> | |||
| <div> | |||
| <h2>Import from CSV</h2> | |||
| <p>Upload a CSV file, map columns to attributes, preview, then approve the import.</p> | |||
| </div> | |||
| </div> | |||
| <!-- Step: idle — file picker --> | |||
| <div x-show="csvStep === 'idle'"> | |||
| <div class="form-section" style="padding-top:12px"> | |||
| <label class="field field-full"> | |||
| <span>CSV file</span> | |||
| <input class="input" type="file" accept=".csv" | |||
| x-ref="csvFileInput" | |||
| x-on:change="onCsvFileSelect($event)"> | |||
| </label> | |||
| </div> | |||
| <div class="form-actions import-actions"> | |||
| <button class="button button-secondary" type="button" | |||
| :disabled="!csvFileSelected || isCsvUploading" | |||
| x-on:click="uploadCsv()"> | |||
| Load CSV | |||
| </button> | |||
| <span class="inline-indicator" x-cloak x-show="isCsvUploading">Reading file…</span> | |||
| </div> | |||
| </div> | |||
| <!-- Step: mapping — field column mapper --> | |||
| <div x-cloak x-show="csvStep === 'mapping'"> | |||
| <p class="attributes-hint" style="margin:12px 0 8px"> | |||
| Auto-matched columns are shown in green. Use the dropdowns to assign any unmatched attributes. | |||
| </p> | |||
| <div class="import-mapping-wrap"> | |||
| <table class="import-mapping-table"> | |||
| <thead> | |||
| <tr> | |||
| <th>Attribute</th> | |||
| <th>CSV Column</th> | |||
| </tr> | |||
| </thead> | |||
| <tbody> | |||
| <template x-if="currentAttributes.length === 0"> | |||
| <tr><td colspan="2" style="padding:12px;color:var(--text-muted)">This customer type has no attributes — nothing to map.</td></tr> | |||
| </template> | |||
| <template x-for="attr in currentAttributes" :key="attr.name"> | |||
| <tr> | |||
| <td x-text="attr.name"></td> | |||
| <td> | |||
| <template x-if="csvMapping[attr.name]"> | |||
| <div class="mapping-matched"> | |||
| <span x-text="csvMapping[attr.name]"></span> | |||
| <button type="button" class="mapping-clear" title="Remove mapping" | |||
| x-on:click="clearMapping(attr.name)">×</button> | |||
| </div> | |||
| </template> | |||
| <template x-if="!csvMapping[attr.name]"> | |||
| <select class="input" | |||
| x-on:change="setMapping(attr.name, $event.target.value)"> | |||
| <option value="">— Not mapped —</option> | |||
| <template x-for="col in unusedCsvHeaders(attr.name)" :key="col"> | |||
| <option :value="col" x-text="col"></option> | |||
| </template> | |||
| </select> | |||
| </template> | |||
| </td> | |||
| </tr> | |||
| </template> | |||
| </tbody> | |||
| </table> | |||
| </div> | |||
| <div class="form-actions import-actions"> | |||
| <button class="button button-primary" type="button" | |||
| :disabled="isCsvPreviewing" | |||
| x-on:click="previewCsv()"> | |||
| Preview Import | |||
| </button> | |||
| <button class="button button-secondary" type="button" | |||
| x-on:click="resetCsvImport()"> | |||
| Reset | |||
| </button> | |||
| <span class="inline-indicator" x-cloak x-show="isCsvPreviewing">Previewing…</span> | |||
| </div> | |||
| </div> | |||
| <!-- Step: preview — review rows before insert --> | |||
| <div x-cloak x-show="csvStep === 'preview'"> | |||
| <div class="import-preview-stats"> | |||
| <span class="preview-stat preview-stat-ok" | |||
| x-show="csvPreviewStats.ok > 0"> | |||
| <span x-text="csvPreviewStats.ok"></span> will import | |||
| </span> | |||
| <span class="preview-stat preview-stat-duplicate" | |||
| x-show="csvPreviewStats.duplicate > 0"> | |||
| <span x-text="csvPreviewStats.duplicate"></span> duplicate | |||
| </span> | |||
| <span class="preview-stat preview-stat-empty" | |||
| x-show="csvPreviewStats.empty > 0"> | |||
| <span x-text="csvPreviewStats.empty"></span> empty (skipped) | |||
| </span> | |||
| </div> | |||
| <div class="import-preview-scroll"> | |||
| <table class="import-preview-table"> | |||
| <thead> | |||
| <tr> | |||
| <th>#</th> | |||
| <template x-for="attr in currentAttributes" :key="attr.name"> | |||
| <th x-text="attr.name"></th> | |||
| </template> | |||
| <th>Status</th> | |||
| </tr> | |||
| </thead> | |||
| <tbody> | |||
| <template x-for="row in csvPreviewRows" :key="row.index"> | |||
| <tr :class="'import-row-' + row.status"> | |||
| <td x-text="row.index"></td> | |||
| <template x-for="attr in currentAttributes" :key="attr.name"> | |||
| <td x-text="row.values[attr.name] ?? ''"></td> | |||
| </template> | |||
| <td class="import-preview-status"> | |||
| <span :class="'import-badge import-badge-' + row.status" | |||
| x-text="row.status === 'ok' ? 'OK' : row.status === 'duplicate' ? 'Duplicate' : 'Empty'"></span> | |||
| <span class="import-row-msg" x-show="row.message" x-text="row.message || ''"></span> | |||
| </td> | |||
| </tr> | |||
| </template> | |||
| </tbody> | |||
| </table> | |||
| </div> | |||
| <div class="form-actions import-actions"> | |||
| <button class="button button-primary" type="button" | |||
| :disabled="isCsvApproving || (csvPreviewStats.ok === 0 && csvPreviewStats.duplicate === 0)" | |||
| x-on:click="approveCsv()"> | |||
| Approve Import | |||
| </button> | |||
| <button class="button button-secondary" type="button" | |||
| :disabled="isCsvApproving" | |||
| x-on:click="csvStep = 'mapping'"> | |||
| ← Back to Mapping | |||
| </button> | |||
| <span class="inline-indicator" x-cloak x-show="isCsvApproving">Importing…</span> | |||
| </div> | |||
| </div> | |||
| <!-- Step: done — summary --> | |||
| <div x-cloak x-show="csvStep === 'done'" style="padding:12px 0"> | |||
| <div class="alert alert-success" x-text="csvDoneMessage"></div> | |||
| <template x-if="csvApproveErrors.length > 0"> | |||
| <div class="alert alert-error" style="margin-top:8px"> | |||
| <strong>Some rows had errors:</strong> | |||
| <ul style="margin:4px 0 0;padding-left:18px"> | |||
| <template x-for="(err, i) in csvApproveErrors" :key="i"> | |||
| <li x-text="err"></li> | |||
| </template> | |||
| </ul> | |||
| </div> | |||
| </template> | |||
| <div class="form-actions import-actions"> | |||
| <a class="button button-primary" href="/customers">View Customers</a> | |||
| <button class="button button-secondary" type="button" | |||
| x-on:click="resetCsvImport()"> | |||
| Import Another File | |||
| </button> | |||
| </div> | |||
| </div> | |||
| <!-- Error bar (visible in all steps) --> | |||
| <div class="alert alert-error" x-cloak x-show="csvError" x-text="csvError" | |||
| style="margin-top:12px"></div> | |||
| </section> | |||
| <?php endif; ?> | |||
| </section> | |||
| @@ -1380,6 +1380,159 @@ a.stat-card:hover::after { | |||
| flex-wrap: wrap; | |||
| } | |||
| /* ── Customer CSV Import ─────────────────────────────────────────────── */ | |||
| .import-mapping-wrap { | |||
| overflow-x: auto; | |||
| border: 1px solid var(--border); | |||
| border-radius: var(--radius); | |||
| margin-bottom: 4px; | |||
| } | |||
| .import-mapping-table { | |||
| width: 100%; | |||
| border-collapse: collapse; | |||
| font-size: 13px; | |||
| min-width: 360px; | |||
| } | |||
| .import-mapping-table th { | |||
| padding: 8px 14px; | |||
| background: var(--surface-raised); | |||
| border-bottom: 1px solid var(--border); | |||
| font-weight: 600; | |||
| color: var(--text-secondary); | |||
| font-size: 11px; | |||
| text-transform: uppercase; | |||
| letter-spacing: 0.04em; | |||
| text-align: left; | |||
| white-space: nowrap; | |||
| } | |||
| .import-mapping-table td { | |||
| padding: 7px 14px; | |||
| border-bottom: 1px solid var(--border); | |||
| vertical-align: middle; | |||
| } | |||
| .import-mapping-table tr:last-child td { border-bottom: none; } | |||
| .mapping-matched { | |||
| display: flex; | |||
| align-items: center; | |||
| gap: 8px; | |||
| } | |||
| .mapping-matched > span { | |||
| display: inline-block; | |||
| background: var(--success-bg); | |||
| color: var(--success); | |||
| border: 1px solid var(--success-border); | |||
| padding: 2px 8px; | |||
| border-radius: var(--radius-sm); | |||
| font-size: 12px; | |||
| font-weight: 500; | |||
| } | |||
| .mapping-clear { | |||
| background: none; | |||
| border: none; | |||
| cursor: pointer; | |||
| color: var(--text-muted); | |||
| font-size: 18px; | |||
| line-height: 1; | |||
| padding: 0 2px; | |||
| transition: color 120ms; | |||
| } | |||
| .mapping-clear:hover { color: var(--error); } | |||
| .import-preview-scroll { | |||
| overflow-x: auto; | |||
| border: 1px solid var(--border); | |||
| border-radius: var(--radius); | |||
| margin-bottom: 8px; | |||
| max-height: 460px; | |||
| overflow-y: auto; | |||
| } | |||
| .import-preview-table { | |||
| width: 100%; | |||
| border-collapse: collapse; | |||
| font-size: 13px; | |||
| min-width: 400px; | |||
| } | |||
| .import-preview-table th { | |||
| position: sticky; | |||
| top: 0; | |||
| z-index: 1; | |||
| padding: 8px 12px; | |||
| background: var(--surface-raised); | |||
| border-bottom: 2px solid var(--border); | |||
| font-weight: 600; | |||
| color: var(--text-secondary); | |||
| font-size: 11px; | |||
| text-transform: uppercase; | |||
| letter-spacing: 0.04em; | |||
| text-align: left; | |||
| white-space: nowrap; | |||
| } | |||
| .import-preview-table td { | |||
| padding: 6px 12px; | |||
| border-bottom: 1px solid var(--border); | |||
| max-width: 220px; | |||
| overflow: hidden; | |||
| text-overflow: ellipsis; | |||
| white-space: nowrap; | |||
| } | |||
| .import-preview-table tr:last-child td { border-bottom: none; } | |||
| .import-row-duplicate { background: var(--warning-bg); } | |||
| .import-row-empty { background: var(--surface-raised); color: var(--text-muted); } | |||
| .import-badge { | |||
| display: inline-block; | |||
| font-size: 11px; | |||
| font-weight: 700; | |||
| text-transform: uppercase; | |||
| padding: 1px 7px; | |||
| border-radius: var(--radius-xs); | |||
| letter-spacing: 0.04em; | |||
| } | |||
| .import-badge-ok { background: var(--success-bg); color: var(--success); border: 1px solid var(--success-border); } | |||
| .import-badge-duplicate { background: var(--warning-bg); color: var(--warning); border: 1px solid var(--warning-border); } | |||
| .import-badge-empty { background: var(--surface-raised); color: var(--text-muted); border: 1px solid var(--border); } | |||
| .import-preview-status { white-space: nowrap; } | |||
| .import-row-msg { | |||
| margin-left: 6px; | |||
| font-size: 12px; | |||
| color: var(--text-secondary); | |||
| } | |||
| .import-preview-stats { | |||
| display: flex; | |||
| gap: 12px; | |||
| margin-bottom: 12px; | |||
| flex-wrap: wrap; | |||
| } | |||
| .preview-stat { | |||
| font-size: 13px; | |||
| font-weight: 600; | |||
| padding: 3px 10px; | |||
| border-radius: var(--radius-sm); | |||
| border: 1px solid transparent; | |||
| } | |||
| .preview-stat-ok { background: var(--success-bg); color: var(--success); border-color: var(--success-border); } | |||
| .preview-stat-duplicate { background: var(--warning-bg); color: var(--warning); border-color: var(--warning-border); } | |||
| .preview-stat-empty { background: var(--surface-raised); color: var(--text-muted); border-color: var(--border); } | |||
| /* Campaign jobs table — horizontal scroll inside the panel */ | |||
| #campaign-jobs-page-table { | |||
| overflow-x: auto; | |||
| @@ -2025,6 +2025,21 @@ window.customerForm = function (customerTypes, initialTypeId, initialValues) { | |||
| apiLookupOptions: {}, | |||
| apiLookupOpen: {}, | |||
| // CSV import | |||
| csvStep: 'idle', | |||
| csvFileSelected: false, | |||
| isCsvUploading: false, | |||
| isCsvPreviewing: false, | |||
| isCsvApproving: false, | |||
| csvError: '', | |||
| csvHeaders: [], | |||
| csvMapping: {}, | |||
| csvTempName: '', | |||
| csvPreviewRows: [], | |||
| csvPreviewStats: { total: 0, ok: 0, duplicate: 0, empty: 0 }, | |||
| csvDoneMessage: '', | |||
| csvApproveErrors: [], | |||
| get currentType() { | |||
| var id = this.selectedTypeId; | |||
| if (!id) return null; | |||
| @@ -2050,6 +2065,7 @@ window.customerForm = function (customerTypes, initialTypeId, initialValues) { | |||
| this.apiLookupOptions = {}; | |||
| this.apiLookupState = {}; | |||
| this.apiLookupOpen = {}; | |||
| this.resetCsvImport(); | |||
| var self = this; | |||
| this.$nextTick(function () { | |||
| self.currentAttributes.forEach(function (attr) { | |||
| @@ -2204,6 +2220,181 @@ window.customerForm = function (customerTypes, initialTypeId, initialValues) { | |||
| event.target.submit(); | |||
| } | |||
| }, | |||
| // ── CSV import ──────────────────────────────────────────────────────── | |||
| onCsvFileSelect(event) { | |||
| this.csvFileSelected = !!(event.target.files && event.target.files.length > 0); | |||
| this.csvError = ''; | |||
| }, | |||
| async uploadCsv() { | |||
| var fileInput = this.$refs.csvFileInput; | |||
| if (!fileInput || !fileInput.files || fileInput.files.length === 0) { | |||
| this.csvError = 'No file selected.'; | |||
| return; | |||
| } | |||
| this.isCsvUploading = true; | |||
| this.csvError = ''; | |||
| try { | |||
| var form = new FormData(); | |||
| form.set('_token', window.__csrf || ''); | |||
| form.set('csv_file', fileInput.files[0]); | |||
| var response = await fetch('/customers/import/upload', { | |||
| method: 'POST', | |||
| headers: { Accept: 'application/json' }, | |||
| body: form, | |||
| }); | |||
| var data = await response.json().catch(function () { return {}; }); | |||
| if (!response.ok) { throw new Error(data.error || 'Could not read the file.'); } | |||
| this.csvHeaders = Array.isArray(data.headers) ? data.headers : []; | |||
| this.csvTempName = data.temp_name || ''; | |||
| this.csvMapping = {}; | |||
| this.autoMatchCsvHeaders(); | |||
| this.csvStep = 'mapping'; | |||
| } catch (err) { | |||
| this.csvError = err.message || 'Could not upload the file.'; | |||
| } finally { | |||
| this.isCsvUploading = false; | |||
| } | |||
| }, | |||
| autoMatchCsvHeaders() { | |||
| var mapping = {}; | |||
| var self = this; | |||
| this.currentAttributes.forEach(function (attr) { | |||
| var norm = self.normalizeCsvHeader(attr.name); | |||
| var match = self.csvHeaders.find(function (h) { | |||
| return self.normalizeCsvHeader(h) === norm; | |||
| }); | |||
| if (match) { mapping[attr.name] = match; } | |||
| }); | |||
| this.csvMapping = mapping; | |||
| }, | |||
| normalizeCsvHeader(s) { | |||
| return s.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(); | |||
| }, | |||
| unusedCsvHeaders(attrName) { | |||
| var self = this; | |||
| var used = new Set( | |||
| Object.entries(this.csvMapping) | |||
| .filter(function (pair) { return pair[0] !== attrName && pair[1]; }) | |||
| .map(function (pair) { return pair[1]; }) | |||
| ); | |||
| return this.csvHeaders.filter(function (h) { return !used.has(h); }); | |||
| }, | |||
| setMapping(attrName, col) { | |||
| var m = Object.assign({}, this.csvMapping); | |||
| if (col === '') { delete m[attrName]; } else { m[attrName] = col; } | |||
| this.csvMapping = m; | |||
| }, | |||
| clearMapping(attrName) { | |||
| var m = Object.assign({}, this.csvMapping); | |||
| delete m[attrName]; | |||
| this.csvMapping = m; | |||
| }, | |||
| buildMappingBody(body) { | |||
| Object.entries(this.csvMapping).forEach(function (pair) { | |||
| body.set('mapping[' + pair[0] + ']', pair[1]); | |||
| }); | |||
| }, | |||
| async previewCsv() { | |||
| this.isCsvPreviewing = true; | |||
| this.csvError = ''; | |||
| try { | |||
| var body = new URLSearchParams(); | |||
| body.set('_token', window.__csrf || ''); | |||
| body.set('customer_type_id', this.selectedTypeId); | |||
| body.set('temp_name', this.csvTempName); | |||
| this.buildMappingBody(body); | |||
| var response = await fetch('/customers/import/preview', { | |||
| method: 'POST', | |||
| headers: { | |||
| Accept: 'application/json', | |||
| 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', | |||
| }, | |||
| body: body.toString(), | |||
| }); | |||
| var data = await response.json().catch(function () { return {}; }); | |||
| if (!response.ok) { throw new Error(data.error || 'Preview failed.'); } | |||
| this.csvPreviewRows = Array.isArray(data.rows) ? data.rows : []; | |||
| this.csvPreviewStats = data.stats || { total: 0, ok: 0, duplicate: 0, empty: 0 }; | |||
| this.csvStep = 'preview'; | |||
| } catch (err) { | |||
| this.csvError = err.message || 'Preview failed.'; | |||
| } finally { | |||
| this.isCsvPreviewing = false; | |||
| } | |||
| }, | |||
| async approveCsv() { | |||
| this.isCsvApproving = true; | |||
| this.csvError = ''; | |||
| try { | |||
| var body = new URLSearchParams(); | |||
| body.set('_token', window.__csrf || ''); | |||
| body.set('customer_type_id', this.selectedTypeId); | |||
| body.set('temp_name', this.csvTempName); | |||
| this.buildMappingBody(body); | |||
| var response = await fetch('/customers/import/approve', { | |||
| method: 'POST', | |||
| headers: { | |||
| Accept: 'application/json', | |||
| 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', | |||
| }, | |||
| body: body.toString(), | |||
| }); | |||
| var data = await response.json().catch(function () { return {}; }); | |||
| if (!response.ok) { throw new Error(data.error || 'Import failed.'); } | |||
| var inserted = data.inserted || 0; | |||
| var skipped = data.skipped || 0; | |||
| this.csvDoneMessage = 'Successfully imported ' + inserted + ' customer' + (inserted !== 1 ? 's' : '') + '.' + | |||
| (skipped > 0 ? ' Skipped ' + skipped + ' empty row' + (skipped !== 1 ? 's' : '') + '.' : ''); | |||
| this.csvApproveErrors = Array.isArray(data.errors) ? data.errors : []; | |||
| this.csvStep = 'done'; | |||
| } catch (err) { | |||
| this.csvError = err.message || 'Import failed.'; | |||
| } finally { | |||
| this.isCsvApproving = false; | |||
| } | |||
| }, | |||
| resetCsvImport() { | |||
| this.csvStep = 'idle'; | |||
| this.csvFileSelected = false; | |||
| this.isCsvUploading = false; | |||
| this.isCsvPreviewing = false; | |||
| this.isCsvApproving = false; | |||
| this.csvError = ''; | |||
| this.csvHeaders = []; | |||
| this.csvMapping = {}; | |||
| this.csvTempName = ''; | |||
| this.csvPreviewRows = []; | |||
| this.csvPreviewStats = { total: 0, ok: 0, duplicate: 0, empty: 0 }; | |||
| this.csvDoneMessage = ''; | |||
| this.csvApproveErrors = []; | |||
| var fi = this.$refs.csvFileInput; | |||
| if (fi) { fi.value = ''; } | |||
| }, | |||
| }; | |||
| }; | |||
| @@ -78,13 +78,16 @@ $router->post('/job-types/{id}/update', [JobTypeController::class, 'update']) -> | |||
| $router->post('/job-types/{id}/delete', [JobTypeController::class, 'destroy'])->middleware('auth'); | |||
| // ── Customers ───────────────────────────────────────────────────────────────── | |||
| $router->get('/customers', [CustomerController::class, 'index']) ->middleware('auth'); | |||
| $router->get('/customers/data', [CustomerController::class, 'data']) ->middleware('auth'); | |||
| $router->get('/customers/create', [CustomerController::class, 'create']) ->middleware('auth'); | |||
| $router->post('/customers', [CustomerController::class, 'store']) ->middleware('auth'); | |||
| $router->get('/customers/{id}/edit', [CustomerController::class, 'edit']) ->middleware('auth'); | |||
| $router->post('/customers/{id}/update', [CustomerController::class, 'update']) ->middleware('auth'); | |||
| $router->post('/customers/{id}/delete', [CustomerController::class, 'destroy'])->middleware('auth'); | |||
| $router->get('/customers', [CustomerController::class, 'index']) ->middleware('auth'); | |||
| $router->get('/customers/data', [CustomerController::class, 'data']) ->middleware('auth'); | |||
| $router->get('/customers/create', [CustomerController::class, 'create']) ->middleware('auth'); | |||
| $router->post('/customers', [CustomerController::class, 'store']) ->middleware('auth'); | |||
| $router->post('/customers/import/upload', [CustomerController::class, 'importUpload']) ->middleware('auth'); | |||
| $router->post('/customers/import/preview', [CustomerController::class, 'importPreview']) ->middleware('auth'); | |||
| $router->post('/customers/import/approve', [CustomerController::class, 'importApprove']) ->middleware('auth'); | |||
| $router->get('/customers/{id}/edit', [CustomerController::class, 'edit']) ->middleware('auth'); | |||
| $router->post('/customers/{id}/update', [CustomerController::class, 'update']) ->middleware('auth'); | |||
| $router->post('/customers/{id}/delete', [CustomerController::class, 'destroy']) ->middleware('auth'); | |||
| // ── Customer Types ──────────────────────────────────────────────────────────── | |||
| $router->get('/customer-types', [CustomerTypeController::class, 'index']) ->middleware('auth'); | |||
Powered by TurnKey Linux.