Customer_Import合併至 main
| @@ -14,7 +14,10 @@ | |||||
| "Bash(findstr \"^app\")", | "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)", | "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)", | "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 | # 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. | 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/ | Source reference: https://phptherightway.com/ | ||||
| @@ -1,5 +1,8 @@ | |||||
| # AGENT.md — PHP Coding Standard | # 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. | 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/ | Source reference: https://phptherightway.com/ | ||||
| @@ -8,6 +8,7 @@ use App\Models\Customer; | |||||
| use App\Repositories\CustomerAuditRepository; | use App\Repositories\CustomerAuditRepository; | ||||
| use App\Repositories\CustomerRepository; | use App\Repositories\CustomerRepository; | ||||
| use App\Repositories\CustomerTypeRepository; | use App\Repositories\CustomerTypeRepository; | ||||
| use App\Services\FileImportService; | |||||
| use App\ViewModels\CustomerViewModel; | use App\ViewModels\CustomerViewModel; | ||||
| use Core\Controller; | use Core\Controller; | ||||
| use Core\Request; | use Core\Request; | ||||
| @@ -217,6 +218,162 @@ class CustomerController extends Controller | |||||
| return $this->redirect('/customers?deleted=1'); | 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 ─────────────────────────────────────────────────────────────── | // ── Helpers ─────────────────────────────────────────────────────────────── | ||||
| private function loadCustomerTypes(): array | private function loadCustomerTypes(): array | ||||
| @@ -286,6 +443,22 @@ class CustomerController extends Controller | |||||
| return auth()->user()?->username ?? 'system'; | 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 | private function repo(): CustomerRepository | ||||
| { | { | ||||
| return new CustomerRepository(database()); | 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) ?>; | window.__initialCtVals = <?= json_encode($model->form['attribute_values'], JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>; | ||||
| </script> | </script> | ||||
| <section class="content-stack"> | |||||
| <section class="content-stack" x-data="customerForm(window.__customerTypes, window.__initialCtId, window.__initialCtVals)"> | |||||
| <div class="page-toolbar"> | <div class="page-toolbar"> | ||||
| <div class="section-heading"> | <div class="section-heading"> | ||||
| @@ -20,7 +20,7 @@ window.__initialCtVals = <?= json_encode($model->form['attribute_values'], JSON_ | |||||
| </div> | </div> | ||||
| <?php else: ?> | <?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'])): ?> | <?php if (isset($model->errors['_token'])): ?> | ||||
| <div class="alert alert-error"><?= e($model->errors['_token'][0]) ?></div> | <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> | </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; ?> | <?php endif; ?> | ||||
| </section> | </section> | ||||
| @@ -1380,6 +1380,159 @@ a.stat-card:hover::after { | |||||
| flex-wrap: wrap; | 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 table — horizontal scroll inside the panel */ | ||||
| #campaign-jobs-page-table { | #campaign-jobs-page-table { | ||||
| overflow-x: auto; | overflow-x: auto; | ||||
| @@ -2025,6 +2025,21 @@ window.customerForm = function (customerTypes, initialTypeId, initialValues) { | |||||
| apiLookupOptions: {}, | apiLookupOptions: {}, | ||||
| apiLookupOpen: {}, | 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() { | get currentType() { | ||||
| var id = this.selectedTypeId; | var id = this.selectedTypeId; | ||||
| if (!id) return null; | if (!id) return null; | ||||
| @@ -2050,6 +2065,7 @@ window.customerForm = function (customerTypes, initialTypeId, initialValues) { | |||||
| this.apiLookupOptions = {}; | this.apiLookupOptions = {}; | ||||
| this.apiLookupState = {}; | this.apiLookupState = {}; | ||||
| this.apiLookupOpen = {}; | this.apiLookupOpen = {}; | ||||
| this.resetCsvImport(); | |||||
| var self = this; | var self = this; | ||||
| this.$nextTick(function () { | this.$nextTick(function () { | ||||
| self.currentAttributes.forEach(function (attr) { | self.currentAttributes.forEach(function (attr) { | ||||
| @@ -2204,6 +2220,181 @@ window.customerForm = function (customerTypes, initialTypeId, initialValues) { | |||||
| event.target.submit(); | 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'); | $router->post('/job-types/{id}/delete', [JobTypeController::class, 'destroy'])->middleware('auth'); | ||||
| // ── Customers ───────────────────────────────────────────────────────────────── | // ── 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 ──────────────────────────────────────────────────────────── | // ── Customer Types ──────────────────────────────────────────────────────────── | ||||
| $router->get('/customer-types', [CustomerTypeController::class, 'index']) ->middleware('auth'); | $router->get('/customer-types', [CustomerTypeController::class, 'index']) ->middleware('auth'); | ||||
Powered by TurnKey Linux.