#5 ● Add CSV import feature to the Customer create screen

已合併
dcovington 2 週之前 將 1 次代碼提交從 Customer_Import合併至 main
  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 查看文件

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

+ 2
- 0
AGENTS.md 查看文件

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


+ 3
- 0
CLAUDE.md 查看文件

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


+ 173
- 0
app/Controllers/CustomerController.php 查看文件

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


+ 178
- 2
app/Views/customers/create.php 查看文件

@@ -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)">&#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; ?> <?php endif; ?>


</section> </section>

+ 153
- 0
public/css/site.css 查看文件

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


+ 191
- 0
public/js/app.js 查看文件

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




+ 10
- 7
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'); $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');


Loading…
取消
儲存

Powered by TurnKey Linux.