saved = $request->input('saved') === '1'; $model->deleted = $request->input('deleted') === '1'; return $this->view('customers.index', [ 'model' => $model, 'pageTitle' => $model->title, ]); } public function data(): Response { $rows = $this->repo()->allWithType(); $data = array_map(static function (array $row): array { $attrValues = !empty($row['attribute_values']) ? (json_decode((string) $row['attribute_values'], true) ?? []) : []; $customerTypeAttributes = !empty($row['customer_type_attributes']) ? (json_decode((string) $row['customer_type_attributes'], true) ?? []) : []; $summary = implode(', ', array_map( static fn($k, $v) => "{$k}: {$v}", array_keys($attrValues), array_values($attrValues) )); return [ 'id' => (int) $row['id'], 'customer_type_id' => (int) $row['customer_type_id'], 'customer_type_name' => (string) $row['customer_type_name'], 'customer_type_attributes' => $customerTypeAttributes, 'attribute_values' => $attrValues, 'attributes_summary' => $summary, 'created_at' => (string) $row['created_at'], ]; }, $rows); return $this->json($data); } public function create(): Response { $model = new CustomerViewModel(); $model->title = 'New Customer'; $model->customerTypes = $this->loadCustomerTypes(); return $this->view('customers.create', [ 'model' => $model, 'pageTitle' => $model->title, ]); } public function store(): Response { $request = Request::capture(); $model = new CustomerViewModel(); $model->title = 'New Customer'; $model->customerTypes = $this->loadCustomerTypes(); [$form, $errors] = $this->validateForm($request, $model->customerTypes); if (!empty($errors)) { $model->form = $form; $model->errors = $errors; return $this->view('customers.create', [ 'model' => $model, 'pageTitle' => $model->title, ]); } $encodedValues = json_encode($form['attribute_values'], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); $duplicate = $this->repo()->findDuplicate((int) $form['customer_type_id'], $encodedValues); if ($duplicate !== null) { $model->form = $form; $model->errors['_duplicate'] = [ 'A customer with these exact values already exists: Customer #' . (int) $duplicate['id'] . ' (' . htmlspecialchars((string) $duplicate['customer_type_name'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ').', ]; return $this->view('customers.create', [ 'model' => $model, 'pageTitle' => $model->title, ]); } $customer = new Customer(); $customer->customerTypeId = (int) $form['customer_type_id']; $customer->attributeValues = $form['attribute_values']; $this->repo()->create($customer); $inserted = $this->repo()->findLatestByType($customer->customerTypeId); if ($inserted !== null) { $this->auditRepo()->log((int) $inserted['id'], 'I', $this->toAuditFields($inserted), $this->currentUsername()); } return $this->redirect('/customers?saved=1'); } public function edit(string $id): Response { $row = $this->repo()->findWithType((int) $id); if ($row === null) { return $this->redirect('/customers'); } $storedValues = !empty($row['attribute_values']) ? (json_decode((string) $row['attribute_values'], true) ?? []) : []; $model = new CustomerViewModel(); $model->title = 'Edit Customer'; $model->customer = $row; $model->saved = Request::capture()->input('saved') === '1'; $model->customerTypes = $this->loadCustomerTypes(); $model->form = [ 'customer_type_id' => (int) $row['customer_type_id'], 'attribute_values' => $storedValues, ]; return $this->view('customers.edit', [ 'model' => $model, 'pageTitle' => $model->title, ]); } public function update(string $id): Response { $before = $this->repo()->findWithType((int) $id); if ($before === null) { return $this->redirect('/customers'); } $request = Request::capture(); $model = new CustomerViewModel(); $model->title = 'Edit Customer'; $model->customer = $before; $model->customerTypes = $this->loadCustomerTypes(); [$form, $errors] = $this->validateForm($request, $model->customerTypes); if (!empty($errors)) { $model->form = $form; $model->errors = $errors; return $this->view('customers.edit', [ 'model' => $model, 'pageTitle' => $model->title, ]); } $encodedValues = json_encode($form['attribute_values'], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); $duplicate = $this->repo()->findDuplicate((int) $form['customer_type_id'], $encodedValues, (int) $id); if ($duplicate !== null) { $model->form = $form; $model->errors['_duplicate'] = [ 'These values are identical to an existing customer: Customer #' . (int) $duplicate['id'] . ' (' . htmlspecialchars((string) $duplicate['customer_type_name'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ').', ]; return $this->view('customers.edit', [ 'model' => $model, 'pageTitle' => $model->title, ]); } $customer = new Customer(); $customer->id = (int) $id; $customer->customerTypeId = (int) $form['customer_type_id']; $customer->attributeValues = $form['attribute_values']; $this->repo()->update($customer); $after = $this->repo()->findWithType((int) $id); $this->auditRepo()->log((int) $id, 'U', [ 'before' => $this->toAuditFields($before), 'after' => $this->toAuditFields($after ?? []), ], $this->currentUsername()); return $this->redirect('/customers/' . $id . '/edit?saved=1'); } public function destroy(string $id): Response { $row = $this->repo()->findWithType((int) $id); if ($row !== null) { $this->repo()->delete((int) $id); $this->auditRepo()->log((int) $row['id'], 'D', $this->toAuditFields($row), $this->currentUsername()); } 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 { return array_map(static function (array $type): array { return [ 'id' => (int) $type['id'], 'name' => (string) $type['name'], 'attributes' => json_decode((string) ($type['attributes'] ?? '[]'), true) ?? [], ]; }, $this->ctRepo()->allOrderedByName()); } private function attributesForType(int $typeId, array $types): array { foreach ($types as $type) { if ($type['id'] === $typeId) return $type['attributes']; } return []; } private function validateForm(Request $request, array $customerTypes): array { $customerTypeId = (int) $request->input('customer_type_id', 0); $submittedValues = (array) ($request->input('attribute_values') ?? []); $errors = []; if (!verify_csrf_token((string) $request->input('_token', ''))) { $errors['_token'][] = 'Your form session expired. Please refresh and try again.'; } if ($customerTypeId === 0) { $errors['customer_type_id'][] = 'Please select a customer type.'; } $typeAttributes = $this->attributesForType($customerTypeId, $customerTypes); $attributeValues = []; foreach ($typeAttributes as $attr) { $attributeValues[$attr['name']] = trim((string) ($submittedValues[$attr['name']] ?? '')); } return [ ['customer_type_id' => $customerTypeId, 'attribute_values' => $attributeValues], $errors, ]; } private function toAuditFields(array $row): array { $attrValues = []; if (!empty($row['attribute_values'])) { $raw = $row['attribute_values']; $attrValues = is_string($raw) ? (json_decode($raw, true) ?? []) : (array) $raw; } return [ 'customer_type_id' => (int) ($row['customer_type_id'] ?? 0), 'customer_type_name' => (string) ($row['customer_type_name'] ?? ''), 'attribute_values' => $attrValues, 'created_at' => (string) ($row['created_at'] ?? ''), 'updated_at' => (string) ($row['updated_at'] ?? ''), ]; } private function currentUsername(): string { 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()); } private function auditRepo(): CustomerAuditRepository { return new CustomerAuditRepository(database()); } private function ctRepo(): CustomerTypeRepository { return new CustomerTypeRepository(database()); } }