Customers en main hace 2 semanas
| @@ -0,0 +1,273 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace App\Controllers; | |||
| use App\Models\Customer; | |||
| use App\Repositories\CustomerAuditRepository; | |||
| use App\Repositories\CustomerRepository; | |||
| use App\Repositories\CustomerTypeRepository; | |||
| use App\ViewModels\CustomerViewModel; | |||
| use Core\Controller; | |||
| use Core\Request; | |||
| use Core\Response; | |||
| class CustomerController extends Controller | |||
| { | |||
| public function index(): Response | |||
| { | |||
| $request = Request::capture(); | |||
| $model = new CustomerViewModel(); | |||
| $model->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()); | |||
| } | |||
| } | |||
| @@ -0,0 +1,276 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace App\Controllers; | |||
| use App\Models\CustomerType; | |||
| use App\Repositories\CustomerTypeAuditRepository; | |||
| use App\Repositories\CustomerTypeRepository; | |||
| use App\ViewModels\CustomerTypeViewModel; | |||
| use Core\Controller; | |||
| use Core\Request; | |||
| use Core\Response; | |||
| use Core\Validator; | |||
| class CustomerTypeController extends Controller | |||
| { | |||
| public function index(): Response | |||
| { | |||
| $request = Request::capture(); | |||
| $model = new CustomerTypeViewModel(); | |||
| $model->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()); | |||
| } | |||
| } | |||
| @@ -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, | |||
| @@ -0,0 +1,17 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace App\Models; | |||
| class Customer | |||
| { | |||
| public ?int $id = null; | |||
| public int $customerTypeId = 0; | |||
| /** @var array<string, string> */ | |||
| public array $attributeValues = []; | |||
| public ?string $createdAt = null; | |||
| public ?string $updatedAt = null; | |||
| } | |||
| @@ -0,0 +1,17 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace App\Models; | |||
| class CustomerType | |||
| { | |||
| public ?int $id = null; | |||
| public string $name = ''; | |||
| /** @var list<array{name: string, type: string}> */ | |||
| public array $attributes = []; | |||
| public ?string $createdAt = null; | |||
| public ?string $updatedAt = null; | |||
| } | |||
| @@ -0,0 +1,41 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace App\Repositories; | |||
| use Core\Repository; | |||
| /** | |||
| * Action codes: I Insert · U Update · D Delete · R Restore | |||
| */ | |||
| class CustomerAuditRepository extends Repository | |||
| { | |||
| protected string $table = 'customer_audit'; | |||
| protected string $primaryKey = 'audit_id'; | |||
| /** @param array<string, mixed> $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<array<string, mixed>> */ | |||
| 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] | |||
| ); | |||
| } | |||
| } | |||
| @@ -0,0 +1,110 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace App\Repositories; | |||
| use App\Models\Customer; | |||
| use Core\Repository; | |||
| class CustomerRepository extends Repository | |||
| { | |||
| protected string $table = 'customer'; | |||
| protected string $primaryKey = 'id'; | |||
| public function count(): int | |||
| { | |||
| $row = $this->database->first('SELECT COUNT(*) AS total FROM customer'); | |||
| return (int) ($row['total'] ?? 0); | |||
| } | |||
| /** @return list<array<string, mixed>> */ | |||
| 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<array<string, mixed>> */ | |||
| 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<array<string, mixed>> */ | |||
| 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, | |||
| ] | |||
| ); | |||
| } | |||
| } | |||
| @@ -0,0 +1,41 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace App\Repositories; | |||
| use Core\Repository; | |||
| /** | |||
| * Action codes: I Insert · U Update · D Delete · R Restore | |||
| */ | |||
| class CustomerTypeAuditRepository extends Repository | |||
| { | |||
| protected string $table = 'customer_type_audit'; | |||
| protected string $primaryKey = 'audit_id'; | |||
| /** @param array<string, mixed> $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<array<string, mixed>> */ | |||
| 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] | |||
| ); | |||
| } | |||
| } | |||
| @@ -0,0 +1,58 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace App\Repositories; | |||
| use App\Models\CustomerType; | |||
| use Core\Repository; | |||
| class CustomerTypeRepository extends Repository | |||
| { | |||
| protected string $table = 'customer_type'; | |||
| public function count(): int | |||
| { | |||
| $row = $this->database->first('SELECT COUNT(*) AS total FROM customer_type'); | |||
| return (int) ($row['total'] ?? 0); | |||
| } | |||
| /** @return list<array<string, mixed>> */ | |||
| 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, | |||
| ] | |||
| ); | |||
| } | |||
| } | |||
| @@ -0,0 +1,24 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace App\ViewModels; | |||
| class CustomerTypeViewModel | |||
| { | |||
| public string $title = 'Customer Types'; | |||
| public bool $saved = false; | |||
| public bool $deleted = false; | |||
| /** @var array{name: string, attributes: list<array{name: string, type: string}>} */ | |||
| public array $form = [ | |||
| 'name' => '', | |||
| 'attributes' => [], | |||
| ]; | |||
| /** @var array<string, list<string>> */ | |||
| public array $errors = []; | |||
| /** @var array<string, mixed>|null */ | |||
| public ?array $customerType = null; | |||
| } | |||
| @@ -0,0 +1,30 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace App\ViewModels; | |||
| class CustomerViewModel | |||
| { | |||
| public string $title = 'Customers'; | |||
| public bool $saved = false; | |||
| public bool $deleted = false; | |||
| /** @var array{customer_type_id: int|string, attribute_values: array<string, string>} */ | |||
| public array $form = [ | |||
| 'customer_type_id' => 0, | |||
| 'attribute_values' => [], | |||
| ]; | |||
| /** @var array<string, list<string>> */ | |||
| public array $errors = []; | |||
| /** @var array<string, mixed>|null */ | |||
| public ?array $customer = null; | |||
| /** | |||
| * All customer types with attributes decoded. | |||
| * @var list<array{id: int, name: string, attributes: list<array{name: string, type: string}>}> | |||
| */ | |||
| public array $customerTypes = []; | |||
| } | |||
| @@ -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 = []; | |||
| } | |||
| @@ -0,0 +1,136 @@ | |||
| <script>window.__custAttributes = <?= json_encode($model->form['attributes'], JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>;</script> | |||
| <section class="content-stack"> | |||
| <div class="page-toolbar"> | |||
| <div class="section-heading"> | |||
| <h1><?= e($model->title) ?></h1> | |||
| <p>Define a customer type and the attributes that describe it.</p> | |||
| </div> | |||
| <a class="button button-secondary" href="/customer-types">← Back to list</a> | |||
| </div> | |||
| <section class="section-panel" x-data="customerTypeForm(window.__custAttributes)"> | |||
| <?php if (isset($model->errors['_token'])): ?> | |||
| <div class="alert alert-error"><?= e($model->errors['_token'][0]) ?></div> | |||
| <?php endif; ?> | |||
| <form method="post" action="/customer-types" class="ct-form" novalidate> | |||
| <?= csrf_field() ?> | |||
| <div class="form-section"> | |||
| <label class="field field-full"> | |||
| <span>Customer type name <span class="required-mark">*</span></span> | |||
| <input class="input<?= isset($model->errors['name']) ? ' input-error' : '' ?>" | |||
| type="text" name="name" maxlength="255" | |||
| value="<?= e($model->form['name']) ?>" required autofocus> | |||
| <?php if (isset($model->errors['name'])): ?> | |||
| <small class="field-error"><?= e($model->errors['name'][0]) ?></small> | |||
| <?php endif; ?> | |||
| </label> | |||
| </div> | |||
| <div class="form-section"> | |||
| <div class="attributes-header"> | |||
| <h3>Attributes</h3> | |||
| <p class="attributes-hint">Fields that customers of this type will carry.</p> | |||
| </div> | |||
| <div class="attribute-list"> | |||
| <template x-for="(attr, index) in attributes" :key="index"> | |||
| <div class="attribute-row" | |||
| draggable="true" | |||
| x-on:dragstart="dragStart($event, index)" | |||
| x-on:dragover.prevent="dragOver($event, index)" | |||
| x-on:drop="drop($event, index)" | |||
| x-on:dragend="dragEnd()" | |||
| :class="{ 'is-dragging': dragIndex === index, 'is-drag-over': dragOverIndex === index && dragIndex !== index }"> | |||
| <div class="attribute-fields"> | |||
| <span class="attr-drag-handle" title="Drag to reorder">↕</span> | |||
| <label class="field attribute-order-field"> | |||
| <span>Order</span> | |||
| <input class="input" type="number" | |||
| :name="`attribute_order[${index}]`" | |||
| x-model.number="attr.order" min="1"> | |||
| </label> | |||
| <label class="field attribute-name-field"> | |||
| <span>Attribute name</span> | |||
| <input class="input" type="text" :name="`attribute_name[${index}]`" | |||
| x-model="attr.name" placeholder="e.g. Company" maxlength="100"> | |||
| </label> | |||
| <label class="field attribute-alias-field"> | |||
| <span>Alias</span> | |||
| <input class="input" type="text" :name="`attribute_alias[${index}]`" | |||
| x-model="attr.alias" placeholder="e.g. CO" maxlength="255"> | |||
| </label> | |||
| <label class="field attribute-type-field"> | |||
| <span>Type</span> | |||
| <select class="input" :name="`attribute_type[${index}]`" x-model="attr.type"> | |||
| <option value="text">Text</option> | |||
| <option value="number">Number</option> | |||
| <option value="date">Date</option> | |||
| <option value="boolean">True/False</option> | |||
| <option value="api_lookup">API Lookup</option> | |||
| </select> | |||
| </label> | |||
| <div class="attribute-remove"> | |||
| <button type="button" class="button button-danger button-sm" | |||
| x-on:click="removeAttribute(index)" title="Remove">×</button> | |||
| </div> | |||
| </div> | |||
| <div class="api-lookup-config" x-show="attr.type === 'api_lookup'"> | |||
| <label class="field api-lookup-url-field"> | |||
| <span>API URL</span> | |||
| <input class="input" type="url" | |||
| :name="`attribute_api_url[${index}]`" | |||
| x-model="attr.api_url" | |||
| placeholder="https://example.com/api/value"> | |||
| </label> | |||
| <label class="field api-lookup-match-field"> | |||
| <span>Match field name(s)</span> | |||
| <input class="input" type="text" | |||
| :name="`attribute_api_match_field[${index}]`" | |||
| x-model="attr.api_match_field" | |||
| placeholder="e.g. status or name;code"> | |||
| </label> | |||
| <label class="field api-lookup-match-field"> | |||
| <span>Auto-fill attributes (aliases)</span> | |||
| <input class="input" type="text" | |||
| :name="`attribute_api_auto_fill[${index}]`" | |||
| x-model="attr.api_auto_fill" | |||
| placeholder="e.g. productName;price"> | |||
| </label> | |||
| <label class="field"> | |||
| <span>Response format</span> | |||
| <select class="input" :name="`attribute_api_format[${index}]`" x-model="attr.api_format"> | |||
| <option value="json">JSON</option> | |||
| <option value="xml">XML</option> | |||
| </select> | |||
| </label> | |||
| <label class="field"> | |||
| <span>Return value type</span> | |||
| <select class="input" :name="`attribute_api_return_type[${index}]`" x-model="attr.api_return_type"> | |||
| <option value="text">Text</option> | |||
| <option value="number">Number</option> | |||
| <option value="date">Date</option> | |||
| <option value="boolean">True/False</option> | |||
| </select> | |||
| </label> | |||
| </div> | |||
| </div> | |||
| </template> | |||
| </div> | |||
| <button type="button" class="button button-secondary button-sm" x-on:click="addAttribute()"> | |||
| + Add Attribute | |||
| </button> | |||
| </div> | |||
| <div class="form-actions"> | |||
| <button class="button button-primary" type="submit">Save Customer Type</button> | |||
| <a class="button button-secondary" href="/customer-types">Cancel</a> | |||
| </div> | |||
| </form> | |||
| </section> | |||
| </section> | |||
| @@ -0,0 +1,153 @@ | |||
| <?php $customerTypeId = (int) ($model->customerType['id'] ?? 0); ?> | |||
| <script>window.__custAttributes = <?= json_encode($model->form['attributes'], JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>;</script> | |||
| <section class="content-stack"> | |||
| <div class="page-toolbar"> | |||
| <div class="section-heading"> | |||
| <h1><?= e($model->title) ?></h1> | |||
| <p>Update this customer type's name or attributes.</p> | |||
| </div> | |||
| <a class="button button-secondary" href="/customer-types">← Back to list</a> | |||
| </div> | |||
| <?php if ($model->saved): ?> | |||
| <div class="alert alert-success" x-data="{ open: true }" x-show="open" x-transition.opacity x-init="setTimeout(() => open = false, 3500)"> | |||
| Customer type updated successfully. | |||
| </div> | |||
| <?php endif; ?> | |||
| <section class="section-panel" x-data="customerTypeForm(window.__custAttributes)"> | |||
| <?php if (isset($model->errors['_token'])): ?> | |||
| <div class="alert alert-error"><?= e($model->errors['_token'][0]) ?></div> | |||
| <?php endif; ?> | |||
| <form method="post" action="/customer-types/<?= e((string) $customerTypeId) ?>/update" class="ct-form" novalidate> | |||
| <?= csrf_field() ?> | |||
| <div class="form-section"> | |||
| <label class="field field-full"> | |||
| <span>Customer type name <span class="required-mark">*</span></span> | |||
| <input class="input<?= isset($model->errors['name']) ? ' input-error' : '' ?>" | |||
| type="text" name="name" maxlength="255" | |||
| value="<?= e($model->form['name']) ?>" required autofocus> | |||
| <?php if (isset($model->errors['name'])): ?> | |||
| <small class="field-error"><?= e($model->errors['name'][0]) ?></small> | |||
| <?php endif; ?> | |||
| </label> | |||
| </div> | |||
| <div class="form-section"> | |||
| <div class="attributes-header"> | |||
| <h3>Attributes</h3> | |||
| <p class="attributes-hint">Fields that customers of this type will carry.</p> | |||
| </div> | |||
| <div class="attribute-list"> | |||
| <template x-for="(attr, index) in attributes" :key="index"> | |||
| <div class="attribute-row" | |||
| draggable="true" | |||
| x-on:dragstart="dragStart($event, index)" | |||
| x-on:dragover.prevent="dragOver($event, index)" | |||
| x-on:drop="drop($event, index)" | |||
| x-on:dragend="dragEnd()" | |||
| :class="{ 'is-dragging': dragIndex === index, 'is-drag-over': dragOverIndex === index && dragIndex !== index }"> | |||
| <div class="attribute-fields"> | |||
| <span class="attr-drag-handle" title="Drag to reorder">↕</span> | |||
| <label class="field attribute-order-field"> | |||
| <span>Order</span> | |||
| <input class="input" type="number" | |||
| :name="`attribute_order[${index}]`" | |||
| x-model.number="attr.order" min="1"> | |||
| </label> | |||
| <label class="field attribute-name-field"> | |||
| <span>Attribute name</span> | |||
| <input class="input" type="text" :name="`attribute_name[${index}]`" | |||
| x-model="attr.name" placeholder="e.g. Company" maxlength="100"> | |||
| </label> | |||
| <label class="field attribute-alias-field"> | |||
| <span>Alias</span> | |||
| <input class="input" type="text" :name="`attribute_alias[${index}]`" | |||
| x-model="attr.alias" placeholder="e.g. CO" maxlength="255"> | |||
| </label> | |||
| <label class="field attribute-type-field"> | |||
| <span>Type</span> | |||
| <select class="input" :name="`attribute_type[${index}]`" x-model="attr.type"> | |||
| <option value="text">Text</option> | |||
| <option value="number">Number</option> | |||
| <option value="date">Date</option> | |||
| <option value="boolean">True/False</option> | |||
| <option value="api_lookup">API Lookup</option> | |||
| </select> | |||
| </label> | |||
| <div class="attribute-remove"> | |||
| <button type="button" class="button button-danger button-sm" | |||
| x-on:click="removeAttribute(index)" title="Remove">×</button> | |||
| </div> | |||
| </div> | |||
| <div class="api-lookup-config" x-show="attr.type === 'api_lookup'"> | |||
| <label class="field api-lookup-url-field"> | |||
| <span>API URL</span> | |||
| <input class="input" type="url" | |||
| :name="`attribute_api_url[${index}]`" | |||
| x-model="attr.api_url" | |||
| placeholder="https://example.com/api/value"> | |||
| </label> | |||
| <label class="field api-lookup-match-field"> | |||
| <span>Match field name(s)</span> | |||
| <input class="input" type="text" | |||
| :name="`attribute_api_match_field[${index}]`" | |||
| x-model="attr.api_match_field" | |||
| placeholder="e.g. status or name;code"> | |||
| </label> | |||
| <label class="field api-lookup-match-field"> | |||
| <span>Auto-fill attributes (aliases)</span> | |||
| <input class="input" type="text" | |||
| :name="`attribute_api_auto_fill[${index}]`" | |||
| x-model="attr.api_auto_fill" | |||
| placeholder="e.g. productName;price"> | |||
| </label> | |||
| <label class="field"> | |||
| <span>Response format</span> | |||
| <select class="input" :name="`attribute_api_format[${index}]`" x-model="attr.api_format"> | |||
| <option value="json">JSON</option> | |||
| <option value="xml">XML</option> | |||
| </select> | |||
| </label> | |||
| <label class="field"> | |||
| <span>Return value type</span> | |||
| <select class="input" :name="`attribute_api_return_type[${index}]`" x-model="attr.api_return_type"> | |||
| <option value="text">Text</option> | |||
| <option value="number">Number</option> | |||
| <option value="date">Date</option> | |||
| <option value="boolean">True/False</option> | |||
| </select> | |||
| </label> | |||
| </div> | |||
| </div> | |||
| </template> | |||
| </div> | |||
| <button type="button" class="button button-secondary button-sm" x-on:click="addAttribute()"> | |||
| + Add Attribute | |||
| </button> | |||
| </div> | |||
| <div class="form-actions"> | |||
| <button class="button button-primary" type="submit">Update Customer Type</button> | |||
| <a class="button button-secondary" href="/customer-types">Cancel</a> | |||
| </div> | |||
| </form> | |||
| <div class="delete-zone"> | |||
| <h4>Delete this customer type</h4> | |||
| <p>This cannot be undone.</p> | |||
| <form method="post" action="/customer-types/<?= e((string) $customerTypeId) ?>/delete" | |||
| x-on:submit.prevent="confirmDelete($event)"> | |||
| <?= csrf_field() ?> | |||
| <button type="submit" class="button button-danger">Delete Customer Type</button> | |||
| </form> | |||
| </div> | |||
| </section> | |||
| </section> | |||
| @@ -0,0 +1,34 @@ | |||
| <section class="content-stack" x-data="customerTypeTable()"> | |||
| <div class="page-toolbar"> | |||
| <div class="section-heading"> | |||
| <h1><?= e($model->title) ?></h1> | |||
| <p>Manage customer types and their configurable attributes.</p> | |||
| </div> | |||
| <a class="button button-primary" href="/customer-types/create">+ New Customer Type</a> | |||
| </div> | |||
| <?php if ($model->saved): ?> | |||
| <div class="alert alert-success" x-data="{ open: true }" x-show="open" x-transition.opacity x-init="setTimeout(() => open = false, 3500)"> | |||
| Customer type saved successfully. | |||
| </div> | |||
| <?php endif; ?> | |||
| <?php if ($model->deleted): ?> | |||
| <div class="alert alert-success" x-data="{ open: true }" x-show="open" x-transition.opacity x-init="setTimeout(() => open = false, 3500)"> | |||
| Customer type deleted. | |||
| </div> | |||
| <?php endif; ?> | |||
| <section class="section-panel"> | |||
| <div class="panel-header"> | |||
| <div> | |||
| <h2>Customer Type Directory</h2> | |||
| <p>All customer types with their attribute definitions.</p> | |||
| </div> | |||
| <button class="button button-secondary" type="button" x-on:click="reloadTable()">Refresh</button> | |||
| </div> | |||
| <div id="customer-type-table" class="tabulator-host"></div> | |||
| </section> | |||
| </section> | |||
| @@ -0,0 +1,133 @@ | |||
| <script> | |||
| window.__customerTypes = <?= json_encode($model->customerTypes, JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>; | |||
| window.__initialCtId = <?= json_encode($model->form['customer_type_id'], JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>; | |||
| window.__initialCtVals = <?= json_encode($model->form['attribute_values'], JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>; | |||
| </script> | |||
| <section class="content-stack"> | |||
| <div class="page-toolbar"> | |||
| <div class="section-heading"> | |||
| <h1><?= e($model->title) ?></h1> | |||
| <p>Select a customer type, then fill in the attribute values.</p> | |||
| </div> | |||
| <a class="button button-secondary" href="/customers">← Back to list</a> | |||
| </div> | |||
| <?php if (!$model->customerTypes): ?> | |||
| <div class="alert alert-error"> | |||
| No customer types exist yet. <a href="/customer-types/create">Create a customer type</a> before adding customers. | |||
| </div> | |||
| <?php else: ?> | |||
| <section class="section-panel" x-data="customerForm(window.__customerTypes, window.__initialCtId, window.__initialCtVals)"> | |||
| <?php if (isset($model->errors['_token'])): ?> | |||
| <div class="alert alert-error"><?= e($model->errors['_token'][0]) ?></div> | |||
| <?php endif; ?> | |||
| <form method="post" action="/customers" class="ct-form" novalidate> | |||
| <?= csrf_field() ?> | |||
| <div class="form-section"> | |||
| <label class="field field-full"> | |||
| <span>Customer type <span class="required-mark">*</span></span> | |||
| <select class="input<?= isset($model->errors['customer_type_id']) ? ' input-error' : '' ?>" | |||
| name="customer_type_id" x-model="selectedTypeId" x-on:change="onTypeChange()" required> | |||
| <option value="0">— Select a customer type —</option> | |||
| <?php foreach ($model->customerTypes as $ct): ?> | |||
| <option value="<?= e((string) $ct['id']) ?>" | |||
| <?= (int) $model->form['customer_type_id'] === $ct['id'] ? 'selected' : '' ?>> | |||
| <?= e($ct['name']) ?> | |||
| </option> | |||
| <?php endforeach; ?> | |||
| </select> | |||
| <?php if (isset($model->errors['customer_type_id'])): ?> | |||
| <small class="field-error"><?= e($model->errors['customer_type_id'][0]) ?></small> | |||
| <?php endif; ?> | |||
| </label> | |||
| </div> | |||
| <div class="form-section" x-show="currentAttributes.length > 0"> | |||
| <div class="attributes-header"> | |||
| <h3>Attribute values</h3> | |||
| <p class="attributes-hint">Fields defined by the selected customer type.</p> | |||
| </div> | |||
| <div class="form-grid"> | |||
| <template x-for="attr in currentAttributes" :key="attr.name"> | |||
| <label class="field" :class="{ 'api-lookup-label': attr.type === 'api_lookup' }"> | |||
| <span x-text="attr.name"></span> | |||
| <template x-if="attr.type === 'boolean'"> | |||
| <select class="input" | |||
| :name="`attribute_values[${attr.name}]`" | |||
| x-on:change="attributeValues[attr.name] = $event.target.value"> | |||
| <option value="" :selected="!attributeValues[attr.name]">— Select —</option> | |||
| <option value="true" :selected="attributeValues[attr.name] === 'true'">True</option> | |||
| <option value="false" :selected="attributeValues[attr.name] === 'false'">False</option> | |||
| </select> | |||
| </template> | |||
| <template x-if="attr.type === 'api_lookup'"> | |||
| <div class="api-lookup-field"> | |||
| <input class="input" type="text" | |||
| :name="`attribute_values[${attr.name}]`" | |||
| x-model="attributeValues[attr.name]" | |||
| placeholder="Type to search…" | |||
| autocomplete="off" | |||
| x-on:focus="openApiLookup(attr.name)" | |||
| x-on:blur="closeApiLookup(attr.name)" | |||
| x-on:keydown.escape.prevent="closeApiLookup(attr.name)"> | |||
| <div class="api-lookup-dropdown" | |||
| x-show="apiLookupOpen[attr.name]" | |||
| x-on:mousedown.prevent> | |||
| <p class="api-lookup-loading" x-show="apiLookupState[attr.name] === 'loading'">Loading…</p> | |||
| <div class="api-lookup-table" x-show="apiLookupState[attr.name] !== 'loading' && apiLookupState[attr.name] !== 'error'"> | |||
| <div class="api-lookup-thead" | |||
| :style="`grid-template-columns: repeat(${getApiOptions(attr.name).fields.length || 1}, 1fr)`"> | |||
| <template x-for="f in getApiOptions(attr.name).fields" :key="f"> | |||
| <span x-text="f"></span> | |||
| </template> | |||
| </div> | |||
| <div class="api-lookup-tbody"> | |||
| <template x-for="rec in getFilteredRecords(attr.name, attributeValues[attr.name])" :key="rec._primary"> | |||
| <div class="api-lookup-tr" | |||
| :class="{ 'is-selected': attributeValues[attr.name] === rec._primary }" | |||
| :style="`grid-template-columns: repeat(${getApiOptions(attr.name).fields.length || 1}, 1fr)`" | |||
| x-on:click="selectApiOption(attr, rec)"> | |||
| <template x-for="(v, i) in rec._display" :key="i"> | |||
| <span x-text="v"></span> | |||
| </template> | |||
| </div> | |||
| </template> | |||
| <div class="api-lookup-empty" x-show="!getFilteredRecords(attr.name, attributeValues[attr.name]).length">No results.</div> | |||
| </div> | |||
| </div> | |||
| <small class="field-error" x-show="apiLookupState[attr.name] === 'error'" x-text="apiLookupError[attr.name] || 'Fetch failed.'"></small> | |||
| </div> | |||
| </div> | |||
| </template> | |||
| <template x-if="attr.type !== 'boolean' && attr.type !== 'api_lookup'"> | |||
| <input class="input" :type="inputType(attr.type)" | |||
| :name="`attribute_values[${attr.name}]`" | |||
| :value="attributeValues[attr.name] ?? ''" | |||
| x-on:input="attributeValues[attr.name] = $event.target.value"> | |||
| </template> | |||
| </label> | |||
| </template> | |||
| </div> | |||
| </div> | |||
| <p class="attributes-hint" x-show="selectedTypeId && currentAttributes.length === 0"> | |||
| This customer type has no attributes defined. | |||
| </p> | |||
| <div class="form-actions"> | |||
| <button class="button button-primary" type="submit">Save Customer</button> | |||
| <a class="button button-secondary" href="/customers">Cancel</a> | |||
| </div> | |||
| </form> | |||
| </section> | |||
| <?php endif; ?> | |||
| </section> | |||
| @@ -0,0 +1,142 @@ | |||
| <?php $customerId = (int) ($model->customer['id'] ?? 0); ?> | |||
| <script> | |||
| window.__customerTypes = <?= json_encode($model->customerTypes, JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>; | |||
| window.__initialCtId = <?= json_encode($model->form['customer_type_id'], JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>; | |||
| window.__initialCtVals = <?= json_encode($model->form['attribute_values'], JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>; | |||
| </script> | |||
| <section class="content-stack"> | |||
| <div class="page-toolbar"> | |||
| <div class="section-heading"> | |||
| <h1><?= e($model->title) ?></h1> | |||
| <p>Update the customer type or attribute values for this customer.</p> | |||
| </div> | |||
| <a class="button button-secondary" href="/customers">← Back to list</a> | |||
| </div> | |||
| <?php if ($model->saved): ?> | |||
| <div class="alert alert-success" x-data="{ open: true }" x-show="open" x-transition.opacity x-init="setTimeout(() => open = false, 3500)"> | |||
| Customer updated successfully. | |||
| </div> | |||
| <?php endif; ?> | |||
| <section class="section-panel" x-data="customerForm(window.__customerTypes, window.__initialCtId, window.__initialCtVals)"> | |||
| <?php if (isset($model->errors['_token'])): ?> | |||
| <div class="alert alert-error"><?= e($model->errors['_token'][0]) ?></div> | |||
| <?php endif; ?> | |||
| <form method="post" action="/customers/<?= e((string) $customerId) ?>/update" class="ct-form" novalidate> | |||
| <?= csrf_field() ?> | |||
| <div class="form-section"> | |||
| <label class="field field-full"> | |||
| <span>Customer type <span class="required-mark">*</span></span> | |||
| <select class="input<?= isset($model->errors['customer_type_id']) ? ' input-error' : '' ?>" | |||
| name="customer_type_id" x-model="selectedTypeId" x-on:change="onTypeChange()" required> | |||
| <option value="0">— Select a customer type —</option> | |||
| <?php foreach ($model->customerTypes as $ct): ?> | |||
| <option value="<?= e((string) $ct['id']) ?>" | |||
| <?= (int) $model->form['customer_type_id'] === $ct['id'] ? 'selected' : '' ?>> | |||
| <?= e($ct['name']) ?> | |||
| </option> | |||
| <?php endforeach; ?> | |||
| </select> | |||
| <?php if (isset($model->errors['customer_type_id'])): ?> | |||
| <small class="field-error"><?= e($model->errors['customer_type_id'][0]) ?></small> | |||
| <?php endif; ?> | |||
| </label> | |||
| </div> | |||
| <div class="form-section" x-show="currentAttributes.length > 0"> | |||
| <div class="attributes-header"> | |||
| <h3>Attribute values</h3> | |||
| <p class="attributes-hint">Fields defined by the selected customer type.</p> | |||
| </div> | |||
| <div class="form-grid"> | |||
| <template x-for="attr in currentAttributes" :key="attr.name"> | |||
| <label class="field" :class="{ 'api-lookup-label': attr.type === 'api_lookup' }"> | |||
| <span x-text="attr.name"></span> | |||
| <template x-if="attr.type === 'boolean'"> | |||
| <select class="input" | |||
| :name="`attribute_values[${attr.name}]`" | |||
| x-on:change="attributeValues[attr.name] = $event.target.value"> | |||
| <option value="" :selected="!attributeValues[attr.name]">— Select —</option> | |||
| <option value="true" :selected="attributeValues[attr.name] === 'true'">True</option> | |||
| <option value="false" :selected="attributeValues[attr.name] === 'false'">False</option> | |||
| </select> | |||
| </template> | |||
| <template x-if="attr.type === 'api_lookup'"> | |||
| <div class="api-lookup-field"> | |||
| <input class="input" type="text" | |||
| :name="`attribute_values[${attr.name}]`" | |||
| x-model="attributeValues[attr.name]" | |||
| placeholder="Type to search…" | |||
| autocomplete="off" | |||
| x-on:focus="openApiLookup(attr.name)" | |||
| x-on:blur="closeApiLookup(attr.name)" | |||
| x-on:keydown.escape.prevent="closeApiLookup(attr.name)"> | |||
| <div class="api-lookup-dropdown" | |||
| x-show="apiLookupOpen[attr.name]" | |||
| x-on:mousedown.prevent> | |||
| <p class="api-lookup-loading" x-show="apiLookupState[attr.name] === 'loading'">Loading…</p> | |||
| <div class="api-lookup-table" x-show="apiLookupState[attr.name] !== 'loading' && apiLookupState[attr.name] !== 'error'"> | |||
| <div class="api-lookup-thead" | |||
| :style="`grid-template-columns: repeat(${getApiOptions(attr.name).fields.length || 1}, 1fr)`"> | |||
| <template x-for="f in getApiOptions(attr.name).fields" :key="f"> | |||
| <span x-text="f"></span> | |||
| </template> | |||
| </div> | |||
| <div class="api-lookup-tbody"> | |||
| <template x-for="rec in getFilteredRecords(attr.name, attributeValues[attr.name])" :key="rec._primary"> | |||
| <div class="api-lookup-tr" | |||
| :class="{ 'is-selected': attributeValues[attr.name] === rec._primary }" | |||
| :style="`grid-template-columns: repeat(${getApiOptions(attr.name).fields.length || 1}, 1fr)`" | |||
| x-on:click="selectApiOption(attr, rec)"> | |||
| <template x-for="(v, i) in rec._display" :key="i"> | |||
| <span x-text="v"></span> | |||
| </template> | |||
| </div> | |||
| </template> | |||
| <div class="api-lookup-empty" x-show="!getFilteredRecords(attr.name, attributeValues[attr.name]).length">No results.</div> | |||
| </div> | |||
| </div> | |||
| <small class="field-error" x-show="apiLookupState[attr.name] === 'error'" x-text="apiLookupError[attr.name] || 'Fetch failed.'"></small> | |||
| </div> | |||
| </div> | |||
| </template> | |||
| <template x-if="attr.type !== 'boolean' && attr.type !== 'api_lookup'"> | |||
| <input class="input" :type="inputType(attr.type)" | |||
| :name="`attribute_values[${attr.name}]`" | |||
| :value="attributeValues[attr.name] ?? ''" | |||
| x-on:input="attributeValues[attr.name] = $event.target.value"> | |||
| </template> | |||
| </label> | |||
| </template> | |||
| </div> | |||
| </div> | |||
| <p class="attributes-hint" x-show="selectedTypeId && currentAttributes.length === 0"> | |||
| This customer type has no attributes defined. | |||
| </p> | |||
| <div class="form-actions"> | |||
| <button class="button button-primary" type="submit">Update Customer</button> | |||
| <a class="button button-secondary" href="/customers">Cancel</a> | |||
| </div> | |||
| </form> | |||
| <div class="delete-zone"> | |||
| <h4>Delete this customer</h4> | |||
| <p>This cannot be undone.</p> | |||
| <form method="post" action="/customers/<?= e((string) $customerId) ?>/delete" | |||
| x-on:submit.prevent="confirmDelete($event)"> | |||
| <?= csrf_field() ?> | |||
| <button type="submit" class="button button-danger">Delete Customer</button> | |||
| </form> | |||
| </div> | |||
| </section> | |||
| </section> | |||
| @@ -0,0 +1,43 @@ | |||
| <section class="content-stack" x-data="customerTable()"> | |||
| <div class="page-toolbar"> | |||
| <div class="section-heading"> | |||
| <h1><?= e($model->title) ?></h1> | |||
| <p>Manage customers and their attribute values.</p> | |||
| </div> | |||
| <a class="button button-primary" href="/customers/create">+ New Customer</a> | |||
| </div> | |||
| <?php if ($model->saved): ?> | |||
| <div class="alert alert-success" x-data="{ open: true }" x-show="open" x-transition.opacity x-init="setTimeout(() => open = false, 3500)"> | |||
| Customer saved successfully. | |||
| </div> | |||
| <?php endif; ?> | |||
| <?php if ($model->deleted): ?> | |||
| <div class="alert alert-success" x-data="{ open: true }" x-show="open" x-transition.opacity x-init="setTimeout(() => open = false, 3500)"> | |||
| Customer deleted. | |||
| </div> | |||
| <?php endif; ?> | |||
| <section class="section-panel"> | |||
| <div class="panel-header"> | |||
| <div> | |||
| <h2>Customer Directory</h2> | |||
| <p>All customers with their type and attribute data.</p> | |||
| </div> | |||
| <button class="button button-secondary" type="button" x-on:click="reloadTable()">Refresh</button> | |||
| </div> | |||
| <div class="skeleton-rows" x-cloak x-show="isLoading"> | |||
| <div class="skeleton-row"></div> | |||
| <div class="skeleton-row"></div> | |||
| <div class="skeleton-row"></div> | |||
| <div class="skeleton-row"></div> | |||
| <div class="skeleton-row"></div> | |||
| </div> | |||
| <div class="alert alert-error" x-cloak x-show="errorMessage" x-text="errorMessage"></div> | |||
| <div id="customer-table" class="tabulator-host"></div> | |||
| </section> | |||
| </section> | |||
| @@ -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']; | |||
| } | |||
| } | |||
| ?> | |||
| <section class="content-stack"> | |||
| @@ -32,6 +38,14 @@ foreach ($model->campaignsByType as $row) { | |||
| <span>Jobs</span> | |||
| <strong><?= e((string) $model->totalJobs) ?></strong> | |||
| </a> | |||
| <a class="stat-card" href="/customer-types"> | |||
| <span>Customer Types</span> | |||
| <strong><?= e((string) $model->totalCustomerTypes) ?></strong> | |||
| </a> | |||
| <a class="stat-card" href="/customers"> | |||
| <span>Customers</span> | |||
| <strong><?= e((string) $model->totalCustomers) ?></strong> | |||
| </a> | |||
| </div> | |||
| <div class="dashboard-panels"> | |||
| @@ -106,4 +120,76 @@ foreach ($model->campaignsByType as $row) { | |||
| </div> | |||
| <div class="dashboard-panels"> | |||
| <section class="section-panel"> | |||
| <div class="panel-header"> | |||
| <div> | |||
| <h2>Recent Customers</h2> | |||
| <p>The 5 most recently created customers.</p> | |||
| </div> | |||
| <a class="button button-secondary button-sm" href="/customers">View All</a> | |||
| </div> | |||
| <?php if (empty($model->recentCustomers)): ?> | |||
| <div class="empty-state"> | |||
| <p>No customers yet.</p> | |||
| <p><a href="/customers/create">Create your first customer</a></p> | |||
| </div> | |||
| <?php else: ?> | |||
| <table class="dashboard-table"> | |||
| <thead> | |||
| <tr> | |||
| <th>ID</th> | |||
| <th>Type</th> | |||
| <th>Created</th> | |||
| <th></th> | |||
| </tr> | |||
| </thead> | |||
| <tbody> | |||
| <?php foreach ($model->recentCustomers as $row): ?> | |||
| <tr> | |||
| <td class="dashboard-table-id">#<?= e((string) $row['id']) ?></td> | |||
| <td><?= e($row['customer_type_name']) ?></td> | |||
| <td class="dashboard-table-date"><?= e(date('M j, Y', strtotime((string) $row['created_at']))) ?></td> | |||
| <td class="dashboard-table-action"><a href="/customers/<?= e((string) $row['id']) ?>/edit">Edit</a></td> | |||
| </tr> | |||
| <?php endforeach; ?> | |||
| </tbody> | |||
| </table> | |||
| <?php endif; ?> | |||
| </section> | |||
| <section class="section-panel"> | |||
| <div class="panel-header"> | |||
| <div> | |||
| <h2>Customers by Type</h2> | |||
| <p>Customer count per customer type.</p> | |||
| </div> | |||
| <a class="button button-secondary button-sm" href="/customer-types">Manage Types</a> | |||
| </div> | |||
| <?php if (empty($model->customersByType)): ?> | |||
| <div class="empty-state"> | |||
| <p>No customer types yet.</p> | |||
| <p><a href="/customer-types/create">Create your first type</a></p> | |||
| </div> | |||
| <?php else: ?> | |||
| <div class="type-breakdown"> | |||
| <?php foreach ($model->customersByType as $row): ?> | |||
| <?php $pct = $maxCustomerCount > 0 ? round((int) $row['customer_count'] / $maxCustomerCount * 100) : 0; ?> | |||
| <a class="type-breakdown-row" href="/customers"> | |||
| <span class="type-name"><?= e($row['customer_type_name']) ?></span> | |||
| <span class="type-bar-wrap"> | |||
| <span class="type-bar" style="width: <?= e((string) $pct) ?>%"></span> | |||
| </span> | |||
| <span class="type-count"><?= e((string) $row['customer_count']) ?></span> | |||
| </a> | |||
| <?php endforeach; ?> | |||
| </div> | |||
| <?php endif; ?> | |||
| </section> | |||
| </div> | |||
| </section> | |||
| @@ -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' => '<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M7 7h.01M3 3h7.5L21 12l-9 9-10.5-10.5V3z"/></svg>', | |||
| '/jobs' => '<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>', | |||
| '/job-types' => '<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"/></svg>', | |||
| '/customers' => '<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M17 20h5v-2a4 4 0 00-3-3.87M9 20H4v-2a4 4 0 013-3.87m6-2.13a4 4 0 11-8 0 4 4 0 018 0zm6-3a3 3 0 11-6 0 3 3 0 016 0z"/></svg>', | |||
| '/customer-types' => '<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"/></svg>', | |||
| ]; | |||
| $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): ?> | |||
| <span class="nav-sep" aria-hidden="true"></span> | |||
| @@ -0,0 +1,37 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| use Core\Database; | |||
| use Core\Migration; | |||
| return new class extends Migration | |||
| { | |||
| public function up(Database $database): void | |||
| { | |||
| $tableExists = $database->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'); | |||
| } | |||
| }; | |||
| @@ -0,0 +1,42 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| use Core\Database; | |||
| use Core\Migration; | |||
| return new class extends Migration | |||
| { | |||
| public function up(Database $database): void | |||
| { | |||
| $tableExists = $database->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'); | |||
| } | |||
| }; | |||
| @@ -0,0 +1,40 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| use Core\Database; | |||
| use Core\Migration; | |||
| return new class extends Migration | |||
| { | |||
| public function up(Database $database): void | |||
| { | |||
| $tableExists = $database->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'); | |||
| } | |||
| }; | |||
| @@ -0,0 +1,40 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| use Core\Database; | |||
| use Core\Migration; | |||
| return new class extends Migration | |||
| { | |||
| public function up(Database $database): void | |||
| { | |||
| $tableExists = $database->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'); | |||
| } | |||
| }; | |||
| @@ -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 '<a href="/customer-types/' + id + '/edit" class="button button-secondary button-sm">Edit</a> ' + | |||
| '<button onclick="window.deleteCustomerType(' + id + ')" class="button button-danger button-sm">Delete</button>'; | |||
| }, | |||
| }, | |||
| { title: 'Name', field: 'name', minWidth: 200 }, | |||
| { | |||
| title: 'Attributes', | |||
| field: 'attributes_summary', | |||
| minWidth: 240, | |||
| formatter: function (cell) { | |||
| const v = cell.getValue(); | |||
| return v ? '<span class="attr-summary">' + _escapeHtml(v) + '</span>' | |||
| : '<span class="attr-empty">—</span>'; | |||
| }, | |||
| }, | |||
| { 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 '<a href="' + _escapeHtml(url) + '" class="button button-secondary button-sm">Edit</a> ' + | |||
| '<button onclick="window.deleteCustomer(' + id + ')" class="button button-danger button-sm">Delete</button>'; | |||
| }, | |||
| }, | |||
| { 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) : '<span class="attr-empty">—</span>'; | |||
| }, | |||
| }); | |||
| }); | |||
| 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. | |||
| @@ -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'); | |||
Powered by TurnKey Linux.