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'); } // ── 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 repo(): CustomerRepository { return new CustomerRepository(database()); } private function auditRepo(): CustomerAuditRepository { return new CustomerAuditRepository(database()); } private function ctRepo(): CustomerTypeRepository { return new CustomerTypeRepository(database()); } }