Przeglądaj źródła

● Add CSV import feature to the Customer create screen

- 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
pull/5/head
Daniel Covington 2 tygodni temu
rodzic
commit
91155613d2
8 zmienionych plików z 714 dodań i 10 usunięć
  1. +4
    -1
      .claude/settings.local.json
  2. +2
    -0
      AGENTS.md
  3. +3
    -0
      CLAUDE.md
  4. +173
    -0
      app/Controllers/CustomerController.php
  5. +178
    -2
      app/Views/customers/create.php
  6. +153
    -0
      public/css/site.css
  7. +191
    -0
      public/js/app.js
  8. +10
    -7
      routes/web.php

+ 4
- 1
.claude/settings.local.json Wyświetl plik

@@ -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)"
]
}
}

+ 2
- 0
AGENTS.md Wyświetl plik

@@ -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/


+ 3
- 0
CLAUDE.md Wyświetl plik

@@ -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/


+ 173
- 0
app/Controllers/CustomerController.php Wyświetl plik

@@ -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());


+ 178
- 2
app/Views/customers/create.php Wyświetl plik

@@ -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)">&#215;</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'">
&larr; 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>

+ 153
- 0
public/css/site.css Wyświetl plik

@@ -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;


+ 191
- 0
public/js/app.js Wyświetl plik

@@ -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 = ''; }
},
};
};



+ 10
- 7
routes/web.php Wyświetl plik

@@ -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');


Ładowanie…
Anuluj
Zapisz

Powered by TurnKey Linux.