From 91155613d2d18ed3f778364607077fa503b6539a Mon Sep 17 00:00:00 2001 From: Daniel Covington Date: Wed, 20 May 2026 11:48:14 -0400 Subject: [PATCH] =?UTF-8?q?=E2=97=8F=20Add=20CSV=20import=20feature=20to?= =?UTF-8?q?=20the=20Customer=20create=20screen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Upload a CSV, auto-match columns to customer type attributes, manually assign any unmatched fields via dropdown, preview all rows (ok / duplicate / empty) before any data is written, then approve to bulk-insert with per-row error reporting Files changed: - routes/web.php: three new POST routes (upload, preview, approve) - CustomerController: importUpload / importPreview / importApprove methods + applyMapping / fileImport helpers; reuses FileImportService - customers/create.php: x-data moved to content-stack; CSV import panel (idle → mapping → preview → done) shown after type selection - app.js: customerForm extended with 14 state properties and 10 methods (auto-match, unused-header dropdowns, upload/preview/approve flows) - site.css: mapping table, scrollable preview table, status badges, preview stats bar using existing design tokens --- .claude/settings.local.json | 5 +- AGENTS.md | 2 + CLAUDE.md | 3 + app/Controllers/CustomerController.php | 173 ++++++++++++++++++++++ app/Views/customers/create.php | 180 ++++++++++++++++++++++- public/css/site.css | 153 ++++++++++++++++++++ public/js/app.js | 191 +++++++++++++++++++++++++ routes/web.php | 17 ++- 8 files changed, 714 insertions(+), 10 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 9a03ddb..26764f5 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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)" ] } } diff --git a/AGENTS.md b/AGENTS.md index 8cd361b..c09a8dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,7 @@ # AGENT.md — PHP Coding Standard +before processing If there are any questions to be answered use agent /graphify "" + 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/ diff --git a/CLAUDE.md b/CLAUDE.md index 8cd361b..cf49307 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,8 @@ # AGENT.md — PHP Coding Standard +before processing If there are any questions to be answered use agent /graphify "" + + 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/ diff --git a/app/Controllers/CustomerController.php b/app/Controllers/CustomerController.php index b52d321..a1513f2 100644 --- a/app/Controllers/CustomerController.php +++ b/app/Controllers/CustomerController.php @@ -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()); diff --git a/app/Views/customers/create.php b/app/Views/customers/create.php index f2ad9d8..cdc3a0f 100644 --- a/app/Views/customers/create.php +++ b/app/Views/customers/create.php @@ -4,7 +4,7 @@ window.__initialCtId = form['customer_type_id'], JSON_ window.__initialCtVals = form['attribute_values'], JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>; -
+
@@ -20,7 +20,7 @@ window.__initialCtVals = form['attribute_values'], JSON_
-
+
errors['_token'])): ?>
errors['_token'][0]) ?>
@@ -132,6 +132,182 @@ window.__initialCtVals = form['attribute_values'], JSON_
+ + +
+
+
+

Import from CSV

+

Upload a CSV file, map columns to attributes, preview, then approve the import.

+
+
+ + +
+
+ +
+
+ + Reading file… +
+
+ + +
+

+ Auto-matched columns are shown in green. Use the dropdowns to assign any unmatched attributes. +

+
+ + + + + + + + + + + +
AttributeCSV Column
+
+
+ + + Previewing… +
+
+ + +
+
+ + will import + + + duplicate + + + empty (skipped) + +
+ +
+ + + + + + + + + + + +
#Status
+
+ +
+ + + Importing… +
+
+ + +
+
+ +
+ View Customers + +
+
+ + +
+ +
+
diff --git a/public/css/site.css b/public/css/site.css index 8ff082d..ee496f6 100644 --- a/public/css/site.css +++ b/public/css/site.css @@ -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; diff --git a/public/js/app.js b/public/js/app.js index 375ae92..5239621 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -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 = ''; } + }, }; }; diff --git a/routes/web.php b/routes/web.php index 7b1826a..f393574 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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');