Customers в main 2 недель назад
| @@ -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\CampaignRepository; | ||||
| use App\Repositories\CampaignTypeRepository; | use App\Repositories\CampaignTypeRepository; | ||||
| use App\Repositories\CustomerRepository; | |||||
| use App\Repositories\CustomerTypeRepository; | |||||
| use App\Repositories\JobRepository; | use App\Repositories\JobRepository; | ||||
| use App\Repositories\JobTypeRepository; | use App\Repositories\JobTypeRepository; | ||||
| use App\ViewModels\HomeIndexViewModel; | use App\ViewModels\HomeIndexViewModel; | ||||
| @@ -22,8 +24,12 @@ class HomeController extends Controller | |||||
| $model->totalCampaigns = (new CampaignRepository($db))->count(); | $model->totalCampaigns = (new CampaignRepository($db))->count(); | ||||
| $model->totalJobTypes = (new JobTypeRepository($db))->count(); | $model->totalJobTypes = (new JobTypeRepository($db))->count(); | ||||
| $model->totalJobs = (new JobRepository($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->recentCampaigns = (new CampaignRepository($db))->recentWithType(5); | ||||
| $model->campaignsByType = (new CampaignRepository($db))->countByType(); | $model->campaignsByType = (new CampaignRepository($db))->countByType(); | ||||
| $model->recentCustomers = (new CustomerRepository($db))->recentWithType(5); | |||||
| $model->customersByType = (new CustomerRepository($db))->countByType(); | |||||
| return $this->view('home.index', [ | return $this->view('home.index', [ | ||||
| 'model' => $model, | '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 $totalCampaigns = 0; | ||||
| public int $totalJobTypes = 0; | public int $totalJobTypes = 0; | ||||
| public int $totalJobs = 0; | public int $totalJobs = 0; | ||||
| public int $totalCustomerTypes = 0; | |||||
| public int $totalCustomers = 0; | |||||
| public array $recentCampaigns = []; | public array $recentCampaigns = []; | ||||
| public array $campaignsByType = []; | 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']; | $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"> | <section class="content-stack"> | ||||
| @@ -32,6 +38,14 @@ foreach ($model->campaignsByType as $row) { | |||||
| <span>Jobs</span> | <span>Jobs</span> | ||||
| <strong><?= e((string) $model->totalJobs) ?></strong> | <strong><?= e((string) $model->totalJobs) ?></strong> | ||||
| </a> | </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> | ||||
| <div class="dashboard-panels"> | <div class="dashboard-panels"> | ||||
| @@ -106,4 +120,76 @@ foreach ($model->campaignsByType as $row) { | |||||
| </div> | </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> | </section> | ||||
| @@ -8,6 +8,8 @@ $navigationItems = [ | |||||
| ['label' => 'Campaign Types', 'href' => '/campaign-types'], | ['label' => 'Campaign Types', 'href' => '/campaign-types'], | ||||
| ['label' => 'Jobs', 'href' => '/jobs'], | ['label' => 'Jobs', 'href' => '/jobs'], | ||||
| ['label' => 'Job Types', 'href' => '/job-types'], | ['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); | $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>', | '/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>', | '/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>', | '/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; | $prevHref = null; | ||||
| $group1 = ['/']; | $group1 = ['/']; | ||||
| $group2 = ['/campaigns', '/campaign-types']; | $group2 = ['/campaigns', '/campaign-types']; | ||||
| $group3 = ['/jobs', '/job-types']; | $group3 = ['/jobs', '/job-types']; | ||||
| $group4 = ['/customers', '/customer-types']; | |||||
| foreach ($navigationItems as $item): | foreach ($navigationItems as $item): | ||||
| $isActive = $item['href'] === '/' | $isActive = $item['href'] === '/' | ||||
| ? $currentPath === '/' | ? $currentPath === '/' | ||||
| : str_starts_with($currentPath, $item['href']); | : str_starts_with($currentPath, $item['href']); | ||||
| $needsSep = ($prevHref !== null) && ( | $needsSep = ($prevHref !== null) && ( | ||||
| (in_array($prevHref, $group1) && in_array($item['href'], $group2)) || | (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): ?> | if ($needsSep): ?> | ||||
| <span class="nav-sep" aria-hidden="true"></span> | <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 | // 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 | // touched but not yet submitted. Delete forms and the logout form are excluded | ||||
| // because they use different CSS classes and are intentional navigation. | // 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\AuthController; | ||||
| use App\Controllers\CampaignController; | use App\Controllers\CampaignController; | ||||
| use App\Controllers\CampaignTypeController; | use App\Controllers\CampaignTypeController; | ||||
| use App\Controllers\CustomerController; | |||||
| use App\Controllers\CustomerTypeController; | |||||
| use App\Controllers\HealthController; | use App\Controllers\HealthController; | ||||
| use App\Controllers\HomeController; | use App\Controllers\HomeController; | ||||
| use App\Controllers\JobController; | 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->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}/update', [JobTypeController::class, 'update']) ->middleware('auth'); | ||||
| $router->post('/job-types/{id}/delete', [JobTypeController::class, 'destroy'])->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.