From 349ee1cfad7f15869bfcf22bdeee08657b8ca135 Mon Sep 17 00:00:00 2001 From: Daniel Covington Date: Fri, 15 May 2026 14:22:46 -0400 Subject: [PATCH] Add Customer and Customer Type areas with configurable attributes Mirrors the Campaign/CampaignType pattern: Customer has a customer_type_id plus a JSON attribute_values blob, with the same attribute builder used by Job Types (text/number/date/boolean/api_lookup with alias and auto-fill). - Migrations: customer_type, customer_type_audit, customer, customer_audit (SQL Server, audit tables use I/U/D/R action codes) - Models, ViewModels, and Repositories for Customer and CustomerType - CustomerController and CustomerTypeController with full CRUD and audit logging on insert, update, and delete - Views for index/create/edit on both entities, using the existing attribute builder, skeleton loaders, and sticky save bar patterns - Routes registered under /customers and /customer-types - Navigation: new fourth group (Customers, Customer Types) with icons and a separator after Jobs/Job Types - Dashboard: two new stat cards plus a second row of panels for Recent Customers and Customers by Type - JS: customerTypeTable, customerTypeForm, customerTable, customerForm, and delete helpers added to public/js/app.js --- app/Controllers/CustomerController.php | 273 ++++++++++ app/Controllers/CustomerTypeController.php | 276 ++++++++++ app/Controllers/HomeController.php | 6 + app/Models/Customer.php | 17 + app/Models/CustomerType.php | 17 + app/Repositories/CustomerAuditRepository.php | 41 ++ app/Repositories/CustomerRepository.php | 110 ++++ .../CustomerTypeAuditRepository.php | 41 ++ app/Repositories/CustomerTypeRepository.php | 58 +++ app/ViewModels/CustomerTypeViewModel.php | 24 + app/ViewModels/CustomerViewModel.php | 30 ++ app/ViewModels/HomeIndexViewModel.php | 4 + app/Views/customer-types/create.php | 136 +++++ app/Views/customer-types/edit.php | 153 ++++++ app/Views/customer-types/index.php | 34 ++ app/Views/customers/create.php | 133 +++++ app/Views/customers/edit.php | 142 +++++ app/Views/customers/index.php | 43 ++ app/Views/home/index.php | 86 ++++ app/Views/partials/header.php | 8 +- ...0515_000001_create_customer_type_table.php | 37 ++ ...00002_create_customer_type_audit_table.php | 42 ++ .../20260515_000003_create_customer_table.php | 40 ++ ...515_000004_create_customer_audit_table.php | 40 ++ public/js/app.js | 487 ++++++++++++++++++ routes/web.php | 20 + 26 files changed, 2297 insertions(+), 1 deletion(-) create mode 100644 app/Controllers/CustomerController.php create mode 100644 app/Controllers/CustomerTypeController.php create mode 100644 app/Models/Customer.php create mode 100644 app/Models/CustomerType.php create mode 100644 app/Repositories/CustomerAuditRepository.php create mode 100644 app/Repositories/CustomerRepository.php create mode 100644 app/Repositories/CustomerTypeAuditRepository.php create mode 100644 app/Repositories/CustomerTypeRepository.php create mode 100644 app/ViewModels/CustomerTypeViewModel.php create mode 100644 app/ViewModels/CustomerViewModel.php create mode 100644 app/Views/customer-types/create.php create mode 100644 app/Views/customer-types/edit.php create mode 100644 app/Views/customer-types/index.php create mode 100644 app/Views/customers/create.php create mode 100644 app/Views/customers/edit.php create mode 100644 app/Views/customers/index.php create mode 100644 database/migrations/20260515_000001_create_customer_type_table.php create mode 100644 database/migrations/20260515_000002_create_customer_type_audit_table.php create mode 100644 database/migrations/20260515_000003_create_customer_table.php create mode 100644 database/migrations/20260515_000004_create_customer_audit_table.php diff --git a/app/Controllers/CustomerController.php b/app/Controllers/CustomerController.php new file mode 100644 index 0000000..37e82a4 --- /dev/null +++ b/app/Controllers/CustomerController.php @@ -0,0 +1,273 @@ +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, + ]); + } + + $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, + ]); + } + + $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()); + } +} diff --git a/app/Controllers/CustomerTypeController.php b/app/Controllers/CustomerTypeController.php new file mode 100644 index 0000000..59d6e9d --- /dev/null +++ b/app/Controllers/CustomerTypeController.php @@ -0,0 +1,276 @@ +saved = $request->input('saved') === '1'; + $model->deleted = $request->input('deleted') === '1'; + + return $this->view('customer-types.index', [ + 'model' => $model, + 'pageTitle' => $model->title, + ]); + } + + public function data(): Response + { + $rows = $this->repo()->allOrderedByName(); + + $data = array_map(static function (array $row): array { + $attrs = !empty($row['attributes']) + ? (json_decode((string) $row['attributes'], true) ?? []) + : []; + + return [ + 'id' => (int) $row['id'], + 'name' => (string) $row['name'], + 'attribute_count' => count($attrs), + 'attributes_summary' => implode(', ', array_column($attrs, 'name')), + 'created_at' => (string) $row['created_at'], + ]; + }, $rows); + + return $this->json($data); + } + + public function create(): Response + { + $model = new CustomerTypeViewModel(); + $model->title = 'New Customer Type'; + + return $this->view('customer-types.create', [ + 'model' => $model, + 'pageTitle' => $model->title, + ]); + } + + public function store(): Response + { + $request = Request::capture(); + [$form, $errors] = $this->validateForm($request); + + if (empty($errors) && $this->repo()->findByName($form['name']) !== null) { + $errors['name'][] = 'A customer type with that name already exists.'; + } + + if (!empty($errors)) { + $model = new CustomerTypeViewModel(); + $model->title = 'New Customer Type'; + $model->form = $form; + $model->errors = $errors; + + return $this->view('customer-types.create', [ + 'model' => $model, + 'pageTitle' => $model->title, + ]); + } + + $customerType = new CustomerType(); + $customerType->name = $form['name']; + $customerType->attributes = $form['attributes']; + + $this->repo()->create($customerType); + + $inserted = $this->repo()->findByName($form['name']); + if ($inserted !== null) { + $this->auditRepo()->log((int) $inserted['id'], 'I', $this->toAuditFields($inserted), $this->currentUsername()); + } + + return $this->redirect('/customer-types?saved=1'); + } + + public function edit(string $id): Response + { + $row = $this->repo()->find((int) $id); + + if ($row === null) { + return $this->redirect('/customer-types'); + } + + $model = new CustomerTypeViewModel(); + $model->title = 'Edit Customer Type'; + $model->customerType = $row; + $model->saved = Request::capture()->input('saved') === '1'; + $model->form = [ + 'name' => (string) $row['name'], + 'attributes' => json_decode((string) ($row['attributes'] ?? '[]'), true) ?? [], + ]; + + return $this->view('customer-types.edit', [ + 'model' => $model, + 'pageTitle' => $model->title, + ]); + } + + public function update(string $id): Response + { + $before = $this->repo()->find((int) $id); + + if ($before === null) { + return $this->redirect('/customer-types'); + } + + $request = Request::capture(); + [$form, $errors] = $this->validateForm($request); + + if (empty($errors)) { + $existing = $this->repo()->findByName($form['name']); + if ($existing !== null && (int) $existing['id'] !== (int) $id) { + $errors['name'][] = 'A customer type with that name already exists.'; + } + } + + if (!empty($errors)) { + $model = new CustomerTypeViewModel(); + $model->title = 'Edit Customer Type'; + $model->customerType = $before; + $model->form = $form; + $model->errors = $errors; + + return $this->view('customer-types.edit', [ + 'model' => $model, + 'pageTitle' => $model->title, + ]); + } + + $customerType = new CustomerType(); + $customerType->id = (int) $id; + $customerType->name = $form['name']; + $customerType->attributes = $form['attributes']; + + $this->repo()->update($customerType); + + $after = $this->repo()->find((int) $id); + $this->auditRepo()->log((int) $id, 'U', [ + 'before' => $this->toAuditFields($before), + 'after' => $this->toAuditFields($after ?? []), + ], $this->currentUsername()); + + return $this->redirect('/customer-types/' . $id . '/edit?saved=1'); + } + + public function destroy(string $id): Response + { + $row = $this->repo()->find((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('/customer-types?deleted=1'); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private function toAuditFields(array $row): array + { + $attrs = []; + if (!empty($row['attributes'])) { + $raw = $row['attributes']; + $attrs = is_string($raw) ? (json_decode($raw, true) ?? []) : (array) $raw; + } + + return [ + 'name' => (string) ($row['name'] ?? ''), + 'attributes' => $attrs, + 'created_at' => (string) ($row['created_at'] ?? ''), + 'updated_at' => (string) ($row['updated_at'] ?? ''), + ]; + } + + private function currentUsername(): string + { + return auth()->user()?->username ?? 'system'; + } + + private function validateForm(Request $request): array + { + $name = trim((string) $request->input('name', '')); + $attributeNames = (array) ($request->input('attribute_name') ?? []); + $attributeTypes = (array) ($request->input('attribute_type') ?? []); + $attributeOrders = (array) ($request->input('attribute_order') ?? []); + $errors = []; + + if (!verify_csrf_token((string) $request->input('_token', ''))) { + $errors['_token'][] = 'Your form session expired. Please refresh and try again.'; + } + + $errors = array_merge($errors, (new Validator()) + ->required('name', $name, 'Customer type name is required.') + ->maxLength('name', $name, 255, 'Name must be 255 characters or fewer.') + ->errors()); + + $attributeAliases = (array) ($request->input('attribute_alias') ?? []); + $attributeApiUrls = (array) ($request->input('attribute_api_url') ?? []); + $attributeApiFormats = (array) ($request->input('attribute_api_format') ?? []); + $attributeApiReturnTypes = (array) ($request->input('attribute_api_return_type') ?? []); + $attributeApiMatchFields = (array) ($request->input('attribute_api_match_field') ?? []); + $attributeApiAutoFills = (array) ($request->input('attribute_api_auto_fill') ?? []); + + $attributes = []; + + foreach ($attributeNames as $i => $attrName) { + $attrName = trim((string) $attrName); + $attrType = trim((string) ($attributeTypes[$i] ?? 'text')); + if ($attrName === '') continue; + + $validatedType = in_array($attrType, ['text', 'number', 'date', 'boolean', 'api_lookup'], true) ? $attrType : 'text'; + + $attr = [ + 'name' => $attrName, + 'type' => $validatedType, + 'alias' => trim((string) ($attributeAliases[$i] ?? '')), + 'order' => isset($attributeOrders[$i]) && (string) $attributeOrders[$i] !== '' + ? max(1, (int) $attributeOrders[$i]) + : count($attributes) + 1, + ]; + + if ($validatedType === 'api_lookup') { + $rawFormat = trim((string) ($attributeApiFormats[$i] ?? '')); + $rawReturnType = trim((string) ($attributeApiReturnTypes[$i] ?? '')); + $attr['api_url'] = trim((string) ($attributeApiUrls[$i] ?? '')); + $attr['api_format'] = in_array($rawFormat, ['json', 'xml'], true) ? $rawFormat : 'json'; + $attr['api_return_type'] = in_array($rawReturnType, ['text', 'number', 'date', 'boolean'], true) ? $rawReturnType : 'text'; + $attr['api_match_field'] = trim((string) ($attributeApiMatchFields[$i] ?? '')); + $attr['api_auto_fill'] = trim((string) ($attributeApiAutoFills[$i] ?? '')); + } + + $attributes[] = $attr; + } + + usort($attributes, static fn(array $a, array $b): int => $a['order'] <=> $b['order']); + foreach ($attributes as $seq => &$attr) { + $attr['order'] = $seq + 1; + } + unset($attr); + + return [['name' => $name, 'attributes' => $attributes], $errors]; + } + + private function repo(): CustomerTypeRepository + { + return new CustomerTypeRepository(database()); + } + + private function auditRepo(): CustomerTypeAuditRepository + { + return new CustomerTypeAuditRepository(database()); + } +} diff --git a/app/Controllers/HomeController.php b/app/Controllers/HomeController.php index 12efc7e..ad74532 100644 --- a/app/Controllers/HomeController.php +++ b/app/Controllers/HomeController.php @@ -6,6 +6,8 @@ namespace App\Controllers; use App\Repositories\CampaignRepository; use App\Repositories\CampaignTypeRepository; +use App\Repositories\CustomerRepository; +use App\Repositories\CustomerTypeRepository; use App\Repositories\JobRepository; use App\Repositories\JobTypeRepository; use App\ViewModels\HomeIndexViewModel; @@ -22,8 +24,12 @@ class HomeController extends Controller $model->totalCampaigns = (new CampaignRepository($db))->count(); $model->totalJobTypes = (new JobTypeRepository($db))->count(); $model->totalJobs = (new JobRepository($db))->count(); + $model->totalCustomerTypes = (new CustomerTypeRepository($db))->count(); + $model->totalCustomers = (new CustomerRepository($db))->count(); $model->recentCampaigns = (new CampaignRepository($db))->recentWithType(5); $model->campaignsByType = (new CampaignRepository($db))->countByType(); + $model->recentCustomers = (new CustomerRepository($db))->recentWithType(5); + $model->customersByType = (new CustomerRepository($db))->countByType(); return $this->view('home.index', [ 'model' => $model, diff --git a/app/Models/Customer.php b/app/Models/Customer.php new file mode 100644 index 0000000..d4188b8 --- /dev/null +++ b/app/Models/Customer.php @@ -0,0 +1,17 @@ + */ + public array $attributeValues = []; + + public ?string $createdAt = null; + public ?string $updatedAt = null; +} diff --git a/app/Models/CustomerType.php b/app/Models/CustomerType.php new file mode 100644 index 0000000..805b250 --- /dev/null +++ b/app/Models/CustomerType.php @@ -0,0 +1,17 @@ + */ + public array $attributes = []; + + public ?string $createdAt = null; + public ?string $updatedAt = null; +} diff --git a/app/Repositories/CustomerAuditRepository.php b/app/Repositories/CustomerAuditRepository.php new file mode 100644 index 0000000..d709c31 --- /dev/null +++ b/app/Repositories/CustomerAuditRepository.php @@ -0,0 +1,41 @@ + $fields */ + public function log(int $customerId, string $action, array $fields, string $username): void + { + $this->database->execute( + "INSERT INTO customer_audit (id, action, fields, username) + VALUES (:id, :action, :fields, :username)", + [ + 'id' => $customerId, + 'action' => $action, + 'fields' => json_encode($fields, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'username' => $username, + ] + ); + } + + /** @return list> */ + public function forRecord(int $customerId): array + { + return $this->database->query( + 'SELECT audit_id, id, action, fields, username, created_at + FROM customer_audit WHERE id = :id ORDER BY audit_id ASC', + ['id' => $customerId] + ); + } +} diff --git a/app/Repositories/CustomerRepository.php b/app/Repositories/CustomerRepository.php new file mode 100644 index 0000000..a2d2309 --- /dev/null +++ b/app/Repositories/CustomerRepository.php @@ -0,0 +1,110 @@ +database->first('SELECT COUNT(*) AS total FROM customer'); + return (int) ($row['total'] ?? 0); + } + + /** @return list> */ + public function recentWithType(int $limit = 5): array + { + return $this->database->query( + "SELECT TOP ({$limit}) c.id, c.created_at, ct.name AS customer_type_name + FROM customer c + INNER JOIN customer_type ct ON c.customer_type_id = ct.id + ORDER BY c.id DESC" + ); + } + + /** @return list> */ + public function countByType(): array + { + return $this->database->query( + 'SELECT ct.name AS customer_type_name, COUNT(c.id) AS customer_count + FROM customer_type ct + LEFT JOIN customer c ON c.customer_type_id = ct.id + GROUP BY ct.id, ct.name + ORDER BY customer_count DESC, ct.name ASC' + ); + } + + /** @return list> */ + public function allWithType(): array + { + return $this->database->query( + 'SELECT c.id, c.customer_type_id, c.attribute_values, + c.created_at, c.updated_at, + ct.name AS customer_type_name, + ct.attributes AS customer_type_attributes + FROM customer c + INNER JOIN customer_type ct ON c.customer_type_id = ct.id + ORDER BY c.id DESC' + ); + } + + public function findWithType(int $id): ?array + { + return $this->database->first( + 'SELECT c.id, c.customer_type_id, c.attribute_values, + c.created_at, c.updated_at, + ct.name AS customer_type_name, + ct.attributes AS customer_type_attributes + FROM customer c + INNER JOIN customer_type ct ON c.customer_type_id = ct.id + WHERE c.id = :id', + ['id' => $id] + ); + } + + /** Used after INSERT to recover the generated id for audit logging. */ + public function findLatestByType(int $typeId): ?array + { + return $this->database->first( + 'SELECT TOP (1) * FROM customer + WHERE customer_type_id = :type_id + ORDER BY id DESC', + ['type_id' => $typeId] + ); + } + + public function create(Customer $customer): bool + { + return $this->database->execute( + 'INSERT INTO customer (customer_type_id, attribute_values) + VALUES (:customer_type_id, :attribute_values)', + [ + 'customer_type_id' => $customer->customerTypeId, + 'attribute_values' => json_encode($customer->attributeValues, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), + ] + ); + } + + public function update(Customer $customer): bool + { + return $this->database->execute( + 'UPDATE customer + SET customer_type_id = :customer_type_id, + attribute_values = :attribute_values, + updated_at = CURRENT_TIMESTAMP + WHERE id = :id', + [ + 'customer_type_id' => $customer->customerTypeId, + 'attribute_values' => json_encode($customer->attributeValues, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), + 'id' => $customer->id, + ] + ); + } +} diff --git a/app/Repositories/CustomerTypeAuditRepository.php b/app/Repositories/CustomerTypeAuditRepository.php new file mode 100644 index 0000000..f63665a --- /dev/null +++ b/app/Repositories/CustomerTypeAuditRepository.php @@ -0,0 +1,41 @@ + $fields */ + public function log(int $customerTypeId, string $action, array $fields, string $username): void + { + $this->database->execute( + "INSERT INTO customer_type_audit (id, action, fields, username) + VALUES (:id, :action, :fields, :username)", + [ + 'id' => $customerTypeId, + 'action' => $action, + 'fields' => json_encode($fields, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'username' => $username, + ] + ); + } + + /** @return list> */ + public function forRecord(int $customerTypeId): array + { + return $this->database->query( + 'SELECT audit_id, id, action, fields, username, created_at + FROM customer_type_audit WHERE id = :id ORDER BY audit_id ASC', + ['id' => $customerTypeId] + ); + } +} diff --git a/app/Repositories/CustomerTypeRepository.php b/app/Repositories/CustomerTypeRepository.php new file mode 100644 index 0000000..4ba3b43 --- /dev/null +++ b/app/Repositories/CustomerTypeRepository.php @@ -0,0 +1,58 @@ +database->first('SELECT COUNT(*) AS total FROM customer_type'); + return (int) ($row['total'] ?? 0); + } + + /** @return list> */ + public function allOrderedByName(): array + { + return $this->database->query('SELECT * FROM customer_type ORDER BY name ASC'); + } + + public function findByName(string $name): ?array + { + return $this->database->first( + 'SELECT * FROM customer_type WHERE name = :name', + ['name' => $name] + ); + } + + public function create(CustomerType $customerType): bool + { + return $this->database->execute( + 'INSERT INTO customer_type (name, attributes) VALUES (:name, :attributes)', + [ + 'name' => $customerType->name, + 'attributes' => json_encode($customerType->attributes, JSON_THROW_ON_ERROR), + ] + ); + } + + public function update(CustomerType $customerType): bool + { + return $this->database->execute( + 'UPDATE customer_type + SET name = :name, attributes = :attributes, updated_at = CURRENT_TIMESTAMP + WHERE id = :id', + [ + 'name' => $customerType->name, + 'attributes' => json_encode($customerType->attributes, JSON_THROW_ON_ERROR), + 'id' => $customerType->id, + ] + ); + } +} diff --git a/app/ViewModels/CustomerTypeViewModel.php b/app/ViewModels/CustomerTypeViewModel.php new file mode 100644 index 0000000..cb46871 --- /dev/null +++ b/app/ViewModels/CustomerTypeViewModel.php @@ -0,0 +1,24 @@ +} */ + public array $form = [ + 'name' => '', + 'attributes' => [], + ]; + + /** @var array> */ + public array $errors = []; + + /** @var array|null */ + public ?array $customerType = null; +} diff --git a/app/ViewModels/CustomerViewModel.php b/app/ViewModels/CustomerViewModel.php new file mode 100644 index 0000000..e06176d --- /dev/null +++ b/app/ViewModels/CustomerViewModel.php @@ -0,0 +1,30 @@ +} */ + public array $form = [ + 'customer_type_id' => 0, + 'attribute_values' => [], + ]; + + /** @var array> */ + public array $errors = []; + + /** @var array|null */ + public ?array $customer = null; + + /** + * All customer types with attributes decoded. + * @var list}> + */ + public array $customerTypes = []; +} diff --git a/app/ViewModels/HomeIndexViewModel.php b/app/ViewModels/HomeIndexViewModel.php index fea0a45..19fef3c 100644 --- a/app/ViewModels/HomeIndexViewModel.php +++ b/app/ViewModels/HomeIndexViewModel.php @@ -10,6 +10,10 @@ class HomeIndexViewModel public int $totalCampaigns = 0; public int $totalJobTypes = 0; public int $totalJobs = 0; + public int $totalCustomerTypes = 0; + public int $totalCustomers = 0; public array $recentCampaigns = []; public array $campaignsByType = []; + public array $recentCustomers = []; + public array $customersByType = []; } diff --git a/app/Views/customer-types/create.php b/app/Views/customer-types/create.php new file mode 100644 index 0000000..a0a1f22 --- /dev/null +++ b/app/Views/customer-types/create.php @@ -0,0 +1,136 @@ + + +
+ +
+
+

title) ?>

+

Define a customer type and the attributes that describe it.

+
+ ← Back to list +
+ +
+ + errors['_token'])): ?> +
errors['_token'][0]) ?>
+ + +
+ + +
+ +
+ +
+
+

Attributes

+

Fields that customers of this type will carry.

+
+
+ +
+ +
+ +
+ + Cancel +
+
+ +
+ +
diff --git a/app/Views/customer-types/edit.php b/app/Views/customer-types/edit.php new file mode 100644 index 0000000..847684e --- /dev/null +++ b/app/Views/customer-types/edit.php @@ -0,0 +1,153 @@ +customerType['id'] ?? 0); ?> + + +
+ +
+
+

title) ?>

+

Update this customer type's name or attributes.

+
+ ← Back to list +
+ + saved): ?> +
+ Customer type updated successfully. +
+ + +
+ + errors['_token'])): ?> +
errors['_token'][0]) ?>
+ + +
+ + +
+ +
+ +
+
+

Attributes

+

Fields that customers of this type will carry.

+
+
+ +
+ +
+ +
+ + Cancel +
+
+ +
+

Delete this customer type

+

This cannot be undone.

+
+ + +
+
+ +
+ +
diff --git a/app/Views/customer-types/index.php b/app/Views/customer-types/index.php new file mode 100644 index 0000000..ee0dea5 --- /dev/null +++ b/app/Views/customer-types/index.php @@ -0,0 +1,34 @@ +
+ +
+
+

title) ?>

+

Manage customer types and their configurable attributes.

+
+ + New Customer Type +
+ + saved): ?> +
+ Customer type saved successfully. +
+ + + deleted): ?> +
+ Customer type deleted. +
+ + +
+
+
+

Customer Type Directory

+

All customer types with their attribute definitions.

+
+ +
+
+
+ +
diff --git a/app/Views/customers/create.php b/app/Views/customers/create.php new file mode 100644 index 0000000..c8d38c0 --- /dev/null +++ b/app/Views/customers/create.php @@ -0,0 +1,133 @@ + + +
+ +
+
+

title) ?>

+

Select a customer type, then fill in the attribute values.

+
+ ← Back to list +
+ + customerTypes): ?> +
+ No customer types exist yet. Create a customer type before adding customers. +
+ + +
+ + errors['_token'])): ?> +
errors['_token'][0]) ?>
+ + +
+ + +
+ +
+ +
+
+

Attribute values

+

Fields defined by the selected customer type.

+
+
+ +
+
+ +

+ This customer type has no attributes defined. +

+ +
+ + Cancel +
+
+ +
+ + + +
diff --git a/app/Views/customers/edit.php b/app/Views/customers/edit.php new file mode 100644 index 0000000..0e73b26 --- /dev/null +++ b/app/Views/customers/edit.php @@ -0,0 +1,142 @@ +customer['id'] ?? 0); ?> + + +
+ +
+
+

title) ?>

+

Update the customer type or attribute values for this customer.

+
+ ← Back to list +
+ + saved): ?> +
+ Customer updated successfully. +
+ + +
+ + errors['_token'])): ?> +
errors['_token'][0]) ?>
+ + +
+ + +
+ +
+ +
+
+

Attribute values

+

Fields defined by the selected customer type.

+
+
+ +
+
+ +

+ This customer type has no attributes defined. +

+ +
+ + Cancel +
+
+ +
+

Delete this customer

+

This cannot be undone.

+
+ + +
+
+ +
+ +
diff --git a/app/Views/customers/index.php b/app/Views/customers/index.php new file mode 100644 index 0000000..ef2169c --- /dev/null +++ b/app/Views/customers/index.php @@ -0,0 +1,43 @@ +
+ +
+
+

title) ?>

+

Manage customers and their attribute values.

+
+ + New Customer +
+ + saved): ?> +
+ Customer saved successfully. +
+ + + deleted): ?> +
+ Customer deleted. +
+ + +
+
+
+

Customer Directory

+

All customers with their type and attribute data.

+
+ +
+ +
+
+
+
+
+
+
+
+
+
+ +
diff --git a/app/Views/home/index.php b/app/Views/home/index.php index ac63ec3..49061c4 100644 --- a/app/Views/home/index.php +++ b/app/Views/home/index.php @@ -5,6 +5,12 @@ foreach ($model->campaignsByType as $row) { $maxCount = (int) $row['campaign_count']; } } +$maxCustomerCount = 0; +foreach ($model->customersByType as $row) { + if ((int) $row['customer_count'] > $maxCustomerCount) { + $maxCustomerCount = (int) $row['customer_count']; + } +} ?>
@@ -32,6 +38,14 @@ foreach ($model->campaignsByType as $row) { Jobs totalJobs) ?> + + Customer Types + totalCustomerTypes) ?> + + + Customers + totalCustomers) ?> +
@@ -106,4 +120,76 @@ foreach ($model->campaignsByType as $row) {
+
+ +
+
+
+

Recent Customers

+

The 5 most recently created customers.

+
+ View All +
+ + recentCustomers)): ?> +
+

No customers yet.

+

Create your first customer

+
+ + + + + + + + + + + + recentCustomers as $row): ?> + + + + + + + + +
IDTypeCreated
#Edit
+ +
+ +
+
+
+

Customers by Type

+

Customer count per customer type.

+
+ Manage Types +
+ + customersByType)): ?> +
+

No customer types yet.

+

Create your first type

+
+ +
+ customersByType as $row): ?> + 0 ? round((int) $row['customer_count'] / $maxCustomerCount * 100) : 0; ?> + + + + + + + + +
+ +
+ +
+
diff --git a/app/Views/partials/header.php b/app/Views/partials/header.php index 50f0e49..19e5e15 100644 --- a/app/Views/partials/header.php +++ b/app/Views/partials/header.php @@ -8,6 +8,8 @@ $navigationItems = [ ['label' => 'Campaign Types', 'href' => '/campaign-types'], ['label' => 'Jobs', 'href' => '/jobs'], ['label' => 'Job Types', 'href' => '/job-types'], + ['label' => 'Customers', 'href' => '/customers'], + ['label' => 'Customer Types', 'href' => '/customer-types'], ]; $currentPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH); @@ -51,18 +53,22 @@ $jsVersion = filemtime(__DIR__ . '/../../../public/js/app.js') ?: time(); '/campaign-types' => '', '/jobs' => '', '/job-types' => '', + '/customers' => '', + '/customer-types' => '', ]; $prevHref = null; $group1 = ['/']; $group2 = ['/campaigns', '/campaign-types']; $group3 = ['/jobs', '/job-types']; + $group4 = ['/customers', '/customer-types']; foreach ($navigationItems as $item): $isActive = $item['href'] === '/' ? $currentPath === '/' : str_starts_with($currentPath, $item['href']); $needsSep = ($prevHref !== null) && ( (in_array($prevHref, $group1) && in_array($item['href'], $group2)) || - (in_array($prevHref, $group2) && in_array($item['href'], $group3)) + (in_array($prevHref, $group2) && in_array($item['href'], $group3)) || + (in_array($prevHref, $group3) && in_array($item['href'], $group4)) ); if ($needsSep): ?> diff --git a/database/migrations/20260515_000001_create_customer_type_table.php b/database/migrations/20260515_000001_create_customer_type_table.php new file mode 100644 index 0000000..24b10e5 --- /dev/null +++ b/database/migrations/20260515_000001_create_customer_type_table.php @@ -0,0 +1,37 @@ +first( + "SELECT 1 AS tbl FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'customer_type'" + ); + + if ($tableExists) { + return; + } + + $database->execute( + 'CREATE TABLE customer_type ( + id INT IDENTITY(1,1) NOT NULL, + name NVARCHAR(255) NOT NULL, + attributes NVARCHAR(MAX) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT PK_customer_type PRIMARY KEY (id), + CONSTRAINT UQ_customer_type_name UNIQUE (name) + )' + ); + } + + public function down(Database $database): void + { + $database->execute('DROP TABLE IF EXISTS customer_type'); + } +}; diff --git a/database/migrations/20260515_000002_create_customer_type_audit_table.php b/database/migrations/20260515_000002_create_customer_type_audit_table.php new file mode 100644 index 0000000..1761c8c --- /dev/null +++ b/database/migrations/20260515_000002_create_customer_type_audit_table.php @@ -0,0 +1,42 @@ +first( + "SELECT 1 AS tbl FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'customer_type_audit'" + ); + + if ($tableExists) { + return; + } + + $database->execute( + "CREATE TABLE customer_type_audit ( + audit_id INT IDENTITY(1,1) NOT NULL, + id INT NOT NULL, + action CHAR(1) NOT NULL, + fields NVARCHAR(MAX) NOT NULL, + username NVARCHAR(255) NOT NULL DEFAULT 'system', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT PK_customer_type_audit PRIMARY KEY (audit_id), + CONSTRAINT CHK_customer_type_audit_action CHECK (action IN ('I','U','D','R')) + )" + ); + + $database->execute( + 'CREATE INDEX IX_customer_type_audit_id ON customer_type_audit (id)' + ); + } + + public function down(Database $database): void + { + $database->execute('DROP TABLE IF EXISTS customer_type_audit'); + } +}; diff --git a/database/migrations/20260515_000003_create_customer_table.php b/database/migrations/20260515_000003_create_customer_table.php new file mode 100644 index 0000000..43437b8 --- /dev/null +++ b/database/migrations/20260515_000003_create_customer_table.php @@ -0,0 +1,40 @@ +first( + "SELECT 1 AS tbl FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'customer'" + ); + + if ($tableExists) { + return; + } + + $database->execute( + 'CREATE TABLE customer ( + id INT IDENTITY(1,1) NOT NULL, + customer_type_id INT NOT NULL, + attribute_values NVARCHAR(MAX) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT PK_customer PRIMARY KEY (id), + CONSTRAINT FK_customer_customer_type FOREIGN KEY (customer_type_id) + REFERENCES customer_type (id) ON UPDATE NO ACTION ON DELETE NO ACTION + )' + ); + + $database->execute('CREATE INDEX IX_customer_customer_type_id ON customer (customer_type_id)'); + } + + public function down(Database $database): void + { + $database->execute('DROP TABLE IF EXISTS customer'); + } +}; diff --git a/database/migrations/20260515_000004_create_customer_audit_table.php b/database/migrations/20260515_000004_create_customer_audit_table.php new file mode 100644 index 0000000..b679683 --- /dev/null +++ b/database/migrations/20260515_000004_create_customer_audit_table.php @@ -0,0 +1,40 @@ +first( + "SELECT 1 AS tbl FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'customer_audit'" + ); + + if ($tableExists) { + return; + } + + $database->execute( + "CREATE TABLE customer_audit ( + audit_id INT IDENTITY(1,1) NOT NULL, + id INT NOT NULL, + action CHAR(1) NOT NULL, + fields NVARCHAR(MAX) NOT NULL, + username NVARCHAR(255) NOT NULL DEFAULT 'system', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT PK_customer_audit PRIMARY KEY (audit_id), + CONSTRAINT CHK_customer_audit_action CHECK (action IN ('I','U','D','R')) + )" + ); + + $database->execute('CREATE INDEX IX_customer_audit_id ON customer_audit (id)'); + } + + public function down(Database $database): void + { + $database->execute('DROP TABLE IF EXISTS customer_audit'); + } +}; diff --git a/public/js/app.js b/public/js/app.js index 6fa9d40..3d4dc7e 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -1592,6 +1592,493 @@ window.jobForm = function (jobTypes, initialTypeId, initialValues) { }; }; +// ── Customer Type ───────────────────────────────────────────────────────────── + +window.customerTypeTable = function () { + return { + table: null, + + init() { + this.initTable(); + }, + + initTable() { + const el = document.getElementById('customer-type-table'); + if (!el || typeof Tabulator === 'undefined') { + return; + } + + this.table = new Tabulator(el, { + ajaxURL: '/customer-types/data', + layout: 'fitColumns', + responsiveLayout: 'collapse', + pagination: true, + paginationMode: 'local', + paginationSize: 10, + paginationSizeSelector: PAGE_SIZES, + movableColumns: true, + placeholder: 'No customer types found.', + initialSort: [{ column: 'name', dir: 'asc' }], + columns: [ + { + title: 'Actions', + field: 'id', + width: 160, + hozAlign: 'center', + headerSort: false, + formatter: function (cell) { + const id = cell.getValue(); + return 'Edit ' + + ''; + }, + }, + { title: 'Name', field: 'name', minWidth: 200 }, + { + title: 'Attributes', + field: 'attributes_summary', + minWidth: 240, + formatter: function (cell) { + const v = cell.getValue(); + return v ? '' + _escapeHtml(v) + '' + : ''; + }, + }, + { title: 'Count', field: 'attribute_count', hozAlign: 'center', width: 80 }, + { title: 'Created', field: 'created_at', minWidth: 160 }, + ], + }); + }, + + reloadTable() { + if (!this.table) { + this.initTable(); + return; + } + this.table.setData('/customer-types/data'); + }, + }; +}; + +window.deleteCustomerType = function (id) { + if (!confirm('Delete this customer type? This cannot be undone.')) { + return; + } + _postDelete('/customer-types/' + id + '/delete'); +}; + +window.customerTypeForm = function (initialAttributes) { + return { + attributes: Array.isArray(initialAttributes) ? initialAttributes : [], + dragIndex: null, + dragOverIndex: null, + + addAttribute() { + this.attributes.push({ name: '', type: 'text', alias: '', order: this.attributes.length + 1, api_url: '', api_match_field: '', api_auto_fill: '', api_format: 'json', api_return_type: 'text' }); + }, + + removeAttribute(index) { + this.attributes.splice(index, 1); + this.renumberOrder(); + }, + + renumberOrder() { + this.attributes.forEach(function (attr, i) { attr.order = i + 1; }); + }, + + dragStart(event, index) { + this.dragIndex = index; + event.dataTransfer.effectAllowed = 'move'; + }, + + dragOver(event, index) { + this.dragOverIndex = index; + }, + + drop(event, index) { + if (this.dragIndex !== null && this.dragIndex !== index) { + var moved = this.attributes.splice(this.dragIndex, 1)[0]; + this.attributes.splice(index, 0, moved); + this.renumberOrder(); + } + this.dragIndex = null; + this.dragOverIndex = null; + }, + + dragEnd() { + this.dragIndex = null; + this.dragOverIndex = null; + }, + + confirmDelete(event) { + if (confirm('Delete this customer type? This cannot be undone.')) { + event.target.submit(); + } + }, + }; +}; + +// ── Customer ────────────────────────────────────────────────────────────────── + +window.customerTable = function () { + return { + table: null, + isLoading: false, + errorMessage: '', + + init() { + this.loadTable(); + }, + + async loadTable() { + const el = document.getElementById('customer-table'); + if (!el || typeof Tabulator === 'undefined' || this.isLoading) { + return; + } + + this.isLoading = true; + this.errorMessage = ''; + + try { + const response = await fetch('/customers/data', { + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + throw new Error('Unable to load customers.'); + } + + const rows = await response.json(); + const customerRows = Array.isArray(rows) ? rows : []; + const attributes = this.attributeColumnsForRows(customerRows); + const tableRows = this.formatRows(customerRows, attributes); + const columns = this.columnsForAttributes(attributes); + + if (this.table) { + this.table.destroy(); + this.table = null; + } + + this.table = new Tabulator(el, { + data: tableRows, + layout: 'fitData', + pagination: true, + paginationMode: 'local', + paginationSize: 10, + paginationSizeSelector: PAGE_SIZES, + movableColumns: true, + placeholder: 'No customers found.', + initialSort: [{ column: 'customer_type_name', dir: 'asc' }], + columns: columns, + }); + } catch (error) { + this.errorMessage = error.message || 'Unable to load customers.'; + } finally { + this.isLoading = false; + } + }, + + attributeColumnsForRows(rows) { + const attributes = []; + + rows.forEach((row) => { + this.normalizeAttributes(row.customer_type_attributes || []).forEach((attr) => { + if (!attributes.some((existing) => existing.name === attr.name)) { + attributes.push(attr); + } + }); + + Object.keys(row.attribute_values || {}).forEach((name) => { + if (!attributes.some((existing) => existing.name === name)) { + attributes.push({ name: name, type: 'text', order: attributes.length + 1 }); + } + }); + }); + + return attributes; + }, + + normalizeAttributes(attributes) { + return attributes + .filter((attr) => attr && attr.name) + .slice() + .sort((a, b) => (a.order || 0) - (b.order || 0)); + }, + + formatRows(rows, attributes) { + return rows.map((row) => { + const attributeValues = row.attribute_values || {}; + const tableRow = { + id: row.id, + edit_url: '/customers/' + encodeURIComponent(row.id) + '/edit', + customer_type_id: row.customer_type_id || '', + customer_type_name: row.customer_type_name || '', + created_at: row.created_at || '', + }; + + attributes.forEach((attr, index) => { + tableRow['attr_' + index] = this.formatAttributeValue(attributeValues[attr.name] ?? ''); + }); + + return tableRow; + }); + }, + + formatAttributeValue(value) { + if (value === null || value === undefined) { + return ''; + } + + if (Array.isArray(value) || typeof value === 'object') { + return JSON.stringify(value); + } + + return String(value); + }, + + columnsForAttributes(attributes) { + const columns = [ + { + title: 'Actions', + field: 'edit_url', + width: 160, + hozAlign: 'center', + headerSort: false, + formatter: function (cell) { + const url = cell.getValue(); + const id = cell.getRow().getData().id; + return 'Edit ' + + ''; + }, + }, + { title: 'Customer ID', field: 'id', width: 110, hozAlign: 'center', headerFilter: 'input' }, + { title: 'Customer Type', field: 'customer_type_name', minWidth: 180, headerFilter: 'input' }, + ]; + + attributes.forEach((attr, index) => { + columns.push({ + title: attr.name, + field: 'attr_' + index, + minWidth: 150, + headerFilter: 'input', + formatter: function (cell) { + const value = cell.getValue(); + return value ? _escapeHtml(value) : ''; + }, + }); + }); + + columns.push( + { title: 'Created', field: 'created_at', minWidth: 160, headerFilter: 'input' } + ); + + return columns; + }, + + reloadTable() { + this.loadTable(); + }, + }; +}; + +window.deleteCustomer = function (id) { + if (!confirm('Delete this customer? This cannot be undone.')) { + return; + } + _postDelete('/customers/' + id + '/delete'); +}; + +window.customerForm = function (customerTypes, initialTypeId, initialValues) { + return { + customerTypes: customerTypes, + selectedTypeId: String(initialTypeId || ''), + attributeValues: Object.assign({}, initialValues || {}), + apiLookupState: {}, + apiLookupError: {}, + apiLookupOptions: {}, + apiLookupOpen: {}, + + get currentType() { + var id = this.selectedTypeId; + if (!id) return null; + return this.customerTypes.find(function (t) { return String(t.id) === String(id); }) || null; + }, + + get currentAttributes() { + if (!this.currentType) return []; + return this.currentType.attributes.slice().sort(function (a, b) { + return (a.order || 0) - (b.order || 0); + }); + }, + + init() { + var self = this; + this.currentAttributes.forEach(function (attr) { + if (attr.type === 'api_lookup') { self.fetchApiValue(attr); } + }); + }, + + onTypeChange() { + this.attributeValues = {}; + this.apiLookupOptions = {}; + this.apiLookupState = {}; + this.apiLookupOpen = {}; + var self = this; + this.$nextTick(function () { + self.currentAttributes.forEach(function (attr) { + if (attr.type === 'api_lookup') { self.fetchApiValue(attr); } + }); + }); + }, + + inputType(attrType) { + return ['number', 'date'].includes(attrType) ? attrType : 'text'; + }, + + getApiOptions(name) { + return this.apiLookupOptions[name] || { fields: [], records: [] }; + }, + + openApiLookup(name) { + var o = {}; o[name] = true; + this.apiLookupOpen = Object.assign({}, this.apiLookupOpen, o); + }, + + closeApiLookup(name) { + var o = {}; o[name] = false; + this.apiLookupOpen = Object.assign({}, this.apiLookupOpen, o); + }, + + getFilteredRecords(name, search) { + var records = this.getApiOptions(name).records; + if (!search) return records; + var term = String(search).toLowerCase(); + return records.filter(function (rec) { + return rec._display.some(function (v) { + return String(v).toLowerCase().indexOf(term) !== -1; + }); + }); + }, + + selectApiOption(attr, rec) { + var newValues = Object.assign({}, this.attributeValues); + newValues[attr.name] = rec._primary; + + var autoFill = (attr.api_auto_fill || '').split(';').map(function (s) { return s.trim(); }).filter(Boolean); + var attrs = this.currentAttributes; + autoFill.forEach(function (alias) { + var target = null; + for (var i = 0; i < attrs.length; i++) { + if (attrs[i].alias === alias) { target = attrs[i]; break; } + } + if (!target) return; + var rowVal = rec._row[alias]; + if (rowVal !== undefined && rowVal !== null) { + newValues[target.name] = String(rowVal); + } + }); + + this.attributeValues = newValues; + this.closeApiLookup(attr.name); + }, + + fetchApiValue(attr) { + var self = this; + if (!attr.api_url) return; + + var resolvedUrl = attr.api_url.replace(/\{alias\}/g, encodeURIComponent(attr.alias || '')); + + var s = {}; s[attr.name] = 'loading'; + var e = {}; e[attr.name] = ''; + var o = {}; o[attr.name] = { fields: [], records: [] }; + self.apiLookupState = Object.assign({}, self.apiLookupState, s); + self.apiLookupError = Object.assign({}, self.apiLookupError, e); + self.apiLookupOptions = Object.assign({}, self.apiLookupOptions, o); + + var matchFields = (attr.api_match_field || '') + .split(';') + .map(function (s) { return s.trim(); }) + .filter(Boolean); + + fetch('/api/proxy?url=' + encodeURIComponent(resolvedUrl)) + .then(function (res) { return res.json(); }) + .then(function (envelope) { + if (envelope.error) { + var se = {}; se[attr.name] = envelope.error; + var ss = {}; ss[attr.name] = 'error'; + self.apiLookupError = Object.assign({}, self.apiLookupError, se); + self.apiLookupState = Object.assign({}, self.apiLookupState, ss); + return; + } + var body = envelope.body || ''; + var result = { fields: matchFields.slice(), records: [] }; + var seenRows = []; + + function addRow(rawRow) { + if (seenRows.indexOf(rawRow) !== -1) return; + seenRows.push(rawRow); + var display = result.fields.map(function (f) { + var v = rawRow[f]; return (v !== undefined && v !== null) ? String(v) : ''; + }); + result.records.push({ _primary: display[0] || '', _display: display, _row: rawRow }); + } + + if (attr.api_format === 'xml') { + try { + var doc = new DOMParser().parseFromString(body, 'text/xml'); + if (result.fields.length === 0) { result.fields = [doc.documentElement.tagName]; } + var firstField = result.fields[0]; + var els = doc.getElementsByTagName(firstField); + for (var xi = 0; xi < els.length; xi++) { + var row = {}; + var par = els[xi].parentNode; + if (par) { + for (var xc = 0; xc < par.childNodes.length; xc++) { + var cn = par.childNodes[xc]; + if (cn.nodeType === 1) { row[cn.tagName] = cn.textContent.trim(); } + } + } + addRow(row); + } + } catch (e) { /* leave records empty */ } + } else { + try { + var parsed = JSON.parse(body); + if (result.fields.length > 0) { + _deepFindRows(parsed, result.fields[0]).forEach(function (hit) { addRow(hit.row); }); + } else if (Array.isArray(parsed)) { + result.fields = ['Value']; + parsed.forEach(function (item) { + if (typeof item !== 'object') { addRow({ Value: String(item) }); } + }); + } else if (typeof parsed === 'string' || typeof parsed === 'number' || typeof parsed === 'boolean') { + result.fields = ['Value']; + addRow({ Value: String(parsed) }); + } + } catch (e) { /* leave records empty */ } + } + + var oo = {}; oo[attr.name] = result; + var os = {}; os[attr.name] = 'idle'; + self.apiLookupOptions = Object.assign({}, self.apiLookupOptions, oo); + self.apiLookupState = Object.assign({}, self.apiLookupState, os); + }) + .catch(function (err) { + console.error('[api-lookup] fetch failed:', err); + var ce = {}; ce[attr.name] = 'Network error — see browser console.'; + var cs = {}; cs[attr.name] = 'error'; + self.apiLookupError = Object.assign({}, self.apiLookupError, ce); + self.apiLookupState = Object.assign({}, self.apiLookupState, cs); + }); + }, + + confirmDelete(event) { + if (confirm('Delete this customer? This cannot be undone.')) { + event.target.submit(); + } + }, + }; +}; + // Unsaved-changes guard — fires beforeunload warning when a .ct-form has been // touched but not yet submitted. Delete forms and the logout form are excluded // because they use different CSS classes and are intentional navigation. diff --git a/routes/web.php b/routes/web.php index a1a4ee4..ddae3b9 100644 --- a/routes/web.php +++ b/routes/web.php @@ -6,6 +6,8 @@ use App\Controllers\ApiProxyController; use App\Controllers\AuthController; use App\Controllers\CampaignController; use App\Controllers\CampaignTypeController; +use App\Controllers\CustomerController; +use App\Controllers\CustomerTypeController; use App\Controllers\HealthController; use App\Controllers\HomeController; use App\Controllers\JobController; @@ -68,3 +70,21 @@ $router->post('/job-types', [JobTypeController::class, 'store']) -> $router->get('/job-types/{id}/edit', [JobTypeController::class, 'edit']) ->middleware('auth'); $router->post('/job-types/{id}/update', [JobTypeController::class, 'update']) ->middleware('auth'); $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'); + +// ── Customer Types ──────────────────────────────────────────────────────────── +$router->get('/customer-types', [CustomerTypeController::class, 'index']) ->middleware('auth'); +$router->get('/customer-types/data', [CustomerTypeController::class, 'data']) ->middleware('auth'); +$router->get('/customer-types/create', [CustomerTypeController::class, 'create']) ->middleware('auth'); +$router->post('/customer-types', [CustomerTypeController::class, 'store']) ->middleware('auth'); +$router->get('/customer-types/{id}/edit', [CustomerTypeController::class, 'edit']) ->middleware('auth'); +$router->post('/customer-types/{id}/update', [CustomerTypeController::class, 'update']) ->middleware('auth'); +$router->post('/customer-types/{id}/delete', [CustomerTypeController::class, 'destroy'])->middleware('auth');