#2 Add Customer and Customer Type areas with configurable attributes

Злито
dcovington злито 1 комітів з Customers до main 2 тижднів тому
  1. +273
    -0
      app/Controllers/CustomerController.php
  2. +276
    -0
      app/Controllers/CustomerTypeController.php
  3. +6
    -0
      app/Controllers/HomeController.php
  4. +17
    -0
      app/Models/Customer.php
  5. +17
    -0
      app/Models/CustomerType.php
  6. +41
    -0
      app/Repositories/CustomerAuditRepository.php
  7. +110
    -0
      app/Repositories/CustomerRepository.php
  8. +41
    -0
      app/Repositories/CustomerTypeAuditRepository.php
  9. +58
    -0
      app/Repositories/CustomerTypeRepository.php
  10. +24
    -0
      app/ViewModels/CustomerTypeViewModel.php
  11. +30
    -0
      app/ViewModels/CustomerViewModel.php
  12. +4
    -0
      app/ViewModels/HomeIndexViewModel.php
  13. +136
    -0
      app/Views/customer-types/create.php
  14. +153
    -0
      app/Views/customer-types/edit.php
  15. +34
    -0
      app/Views/customer-types/index.php
  16. +133
    -0
      app/Views/customers/create.php
  17. +142
    -0
      app/Views/customers/edit.php
  18. +43
    -0
      app/Views/customers/index.php
  19. +86
    -0
      app/Views/home/index.php
  20. +7
    -1
      app/Views/partials/header.php
  21. +37
    -0
      database/migrations/20260515_000001_create_customer_type_table.php
  22. +42
    -0
      database/migrations/20260515_000002_create_customer_type_audit_table.php
  23. +40
    -0
      database/migrations/20260515_000003_create_customer_table.php
  24. +40
    -0
      database/migrations/20260515_000004_create_customer_audit_table.php
  25. +487
    -0
      public/js/app.js
  26. +20
    -0
      routes/web.php

+ 273
- 0
app/Controllers/CustomerController.php Переглянути файл

@@ -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());
}
}

+ 276
- 0
app/Controllers/CustomerTypeController.php Переглянути файл

@@ -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
- 0
app/Controllers/HomeController.php Переглянути файл

@@ -6,6 +6,8 @@ namespace App\Controllers;

use App\Repositories\CampaignRepository;
use App\Repositories\CampaignTypeRepository;
use App\Repositories\CustomerRepository;
use App\Repositories\CustomerTypeRepository;
use App\Repositories\JobRepository;
use App\Repositories\JobTypeRepository;
use App\ViewModels\HomeIndexViewModel;
@@ -22,8 +24,12 @@ class HomeController extends Controller
$model->totalCampaigns = (new CampaignRepository($db))->count();
$model->totalJobTypes = (new JobTypeRepository($db))->count();
$model->totalJobs = (new JobRepository($db))->count();
$model->totalCustomerTypes = (new CustomerTypeRepository($db))->count();
$model->totalCustomers = (new CustomerRepository($db))->count();
$model->recentCampaigns = (new CampaignRepository($db))->recentWithType(5);
$model->campaignsByType = (new CampaignRepository($db))->countByType();
$model->recentCustomers = (new CustomerRepository($db))->recentWithType(5);
$model->customersByType = (new CustomerRepository($db))->countByType();

return $this->view('home.index', [
'model' => $model,


+ 17
- 0
app/Models/Customer.php Переглянути файл

@@ -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;
}

+ 17
- 0
app/Models/CustomerType.php Переглянути файл

@@ -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;
}

+ 41
- 0
app/Repositories/CustomerAuditRepository.php Переглянути файл

@@ -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]
);
}
}

+ 110
- 0
app/Repositories/CustomerRepository.php Переглянути файл

@@ -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,
]
);
}
}

+ 41
- 0
app/Repositories/CustomerTypeAuditRepository.php Переглянути файл

@@ -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]
);
}
}

+ 58
- 0
app/Repositories/CustomerTypeRepository.php Переглянути файл

@@ -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,
]
);
}
}

+ 24
- 0
app/ViewModels/CustomerTypeViewModel.php Переглянути файл

@@ -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;
}

+ 30
- 0
app/ViewModels/CustomerViewModel.php Переглянути файл

@@ -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 = [];
}

+ 4
- 0
app/ViewModels/HomeIndexViewModel.php Переглянути файл

@@ -10,6 +10,10 @@ class HomeIndexViewModel
public int $totalCampaigns = 0;
public int $totalJobTypes = 0;
public int $totalJobs = 0;
public int $totalCustomerTypes = 0;
public int $totalCustomers = 0;
public array $recentCampaigns = [];
public array $campaignsByType = [];
public array $recentCustomers = [];
public array $customersByType = [];
}

+ 136
- 0
app/Views/customer-types/create.php Переглянути файл

@@ -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">&larr; 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">&#8597;</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">&times;</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>

+ 153
- 0
app/Views/customer-types/edit.php Переглянути файл

@@ -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">&larr; 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">&#8597;</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">&times;</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>

+ 34
- 0
app/Views/customer-types/index.php Переглянути файл

@@ -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>

+ 133
- 0
app/Views/customers/create.php Переглянути файл

@@ -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">&larr; 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>

+ 142
- 0
app/Views/customers/edit.php Переглянути файл

@@ -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">&larr; 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>

+ 43
- 0
app/Views/customers/index.php Переглянути файл

@@ -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>

+ 86
- 0
app/Views/home/index.php Переглянути файл

@@ -5,6 +5,12 @@ foreach ($model->campaignsByType as $row) {
$maxCount = (int) $row['campaign_count'];
}
}
$maxCustomerCount = 0;
foreach ($model->customersByType as $row) {
if ((int) $row['customer_count'] > $maxCustomerCount) {
$maxCustomerCount = (int) $row['customer_count'];
}
}
?>
<section class="content-stack">

@@ -32,6 +38,14 @@ foreach ($model->campaignsByType as $row) {
<span>Jobs</span>
<strong><?= e((string) $model->totalJobs) ?></strong>
</a>
<a class="stat-card" href="/customer-types">
<span>Customer Types</span>
<strong><?= e((string) $model->totalCustomerTypes) ?></strong>
</a>
<a class="stat-card" href="/customers">
<span>Customers</span>
<strong><?= e((string) $model->totalCustomers) ?></strong>
</a>
</div>

<div class="dashboard-panels">
@@ -106,4 +120,76 @@ foreach ($model->campaignsByType as $row) {

</div>

<div class="dashboard-panels">

<section class="section-panel">
<div class="panel-header">
<div>
<h2>Recent Customers</h2>
<p>The 5 most recently created customers.</p>
</div>
<a class="button button-secondary button-sm" href="/customers">View All</a>
</div>

<?php if (empty($model->recentCustomers)): ?>
<div class="empty-state">
<p>No customers yet.</p>
<p><a href="/customers/create">Create your first customer</a></p>
</div>
<?php else: ?>
<table class="dashboard-table">
<thead>
<tr>
<th>ID</th>
<th>Type</th>
<th>Created</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($model->recentCustomers as $row): ?>
<tr>
<td class="dashboard-table-id">#<?= e((string) $row['id']) ?></td>
<td><?= e($row['customer_type_name']) ?></td>
<td class="dashboard-table-date"><?= e(date('M j, Y', strtotime((string) $row['created_at']))) ?></td>
<td class="dashboard-table-action"><a href="/customers/<?= e((string) $row['id']) ?>/edit">Edit</a></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</section>

<section class="section-panel">
<div class="panel-header">
<div>
<h2>Customers by Type</h2>
<p>Customer count per customer type.</p>
</div>
<a class="button button-secondary button-sm" href="/customer-types">Manage Types</a>
</div>

<?php if (empty($model->customersByType)): ?>
<div class="empty-state">
<p>No customer types yet.</p>
<p><a href="/customer-types/create">Create your first type</a></p>
</div>
<?php else: ?>
<div class="type-breakdown">
<?php foreach ($model->customersByType as $row): ?>
<?php $pct = $maxCustomerCount > 0 ? round((int) $row['customer_count'] / $maxCustomerCount * 100) : 0; ?>
<a class="type-breakdown-row" href="/customers">
<span class="type-name"><?= e($row['customer_type_name']) ?></span>
<span class="type-bar-wrap">
<span class="type-bar" style="width: <?= e((string) $pct) ?>%"></span>
</span>
<span class="type-count"><?= e((string) $row['customer_count']) ?></span>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>

</div>

</section>

+ 7
- 1
app/Views/partials/header.php Переглянути файл

@@ -8,6 +8,8 @@ $navigationItems = [
['label' => 'Campaign Types', 'href' => '/campaign-types'],
['label' => 'Jobs', 'href' => '/jobs'],
['label' => 'Job Types', 'href' => '/job-types'],
['label' => 'Customers', 'href' => '/customers'],
['label' => 'Customer Types', 'href' => '/customer-types'],
];

$currentPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
@@ -51,18 +53,22 @@ $jsVersion = filemtime(__DIR__ . '/../../../public/js/app.js') ?: time();
'/campaign-types' => '<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M7 7h.01M3 3h7.5L21 12l-9 9-10.5-10.5V3z"/></svg>',
'/jobs' => '<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>',
'/job-types' => '<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"/></svg>',
'/customers' => '<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M17 20h5v-2a4 4 0 00-3-3.87M9 20H4v-2a4 4 0 013-3.87m6-2.13a4 4 0 11-8 0 4 4 0 018 0zm6-3a3 3 0 11-6 0 3 3 0 016 0z"/></svg>',
'/customer-types' => '<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"/></svg>',
];
$prevHref = null;
$group1 = ['/'];
$group2 = ['/campaigns', '/campaign-types'];
$group3 = ['/jobs', '/job-types'];
$group4 = ['/customers', '/customer-types'];
foreach ($navigationItems as $item):
$isActive = $item['href'] === '/'
? $currentPath === '/'
: str_starts_with($currentPath, $item['href']);
$needsSep = ($prevHref !== null) && (
(in_array($prevHref, $group1) && in_array($item['href'], $group2)) ||
(in_array($prevHref, $group2) && in_array($item['href'], $group3))
(in_array($prevHref, $group2) && in_array($item['href'], $group3)) ||
(in_array($prevHref, $group3) && in_array($item['href'], $group4))
);
if ($needsSep): ?>
<span class="nav-sep" aria-hidden="true"></span>


+ 37
- 0
database/migrations/20260515_000001_create_customer_type_table.php Переглянути файл

@@ -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');
}
};

+ 42
- 0
database/migrations/20260515_000002_create_customer_type_audit_table.php Переглянути файл

@@ -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');
}
};

+ 40
- 0
database/migrations/20260515_000003_create_customer_table.php Переглянути файл

@@ -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');
}
};

+ 40
- 0
database/migrations/20260515_000004_create_customer_audit_table.php Переглянути файл

@@ -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');
}
};

+ 487
- 0
public/js/app.js Переглянути файл

@@ -1592,6 +1592,493 @@ window.jobForm = function (jobTypes, initialTypeId, initialValues) {
};
};

// ── Customer Type ─────────────────────────────────────────────────────────────

window.customerTypeTable = function () {
return {
table: null,

init() {
this.initTable();
},

initTable() {
const el = document.getElementById('customer-type-table');
if (!el || typeof Tabulator === 'undefined') {
return;
}

this.table = new Tabulator(el, {
ajaxURL: '/customer-types/data',
layout: 'fitColumns',
responsiveLayout: 'collapse',
pagination: true,
paginationMode: 'local',
paginationSize: 10,
paginationSizeSelector: PAGE_SIZES,
movableColumns: true,
placeholder: 'No customer types found.',
initialSort: [{ column: 'name', dir: 'asc' }],
columns: [
{
title: 'Actions',
field: 'id',
width: 160,
hozAlign: 'center',
headerSort: false,
formatter: function (cell) {
const id = cell.getValue();
return '<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">&mdash;</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">&mdash;</span>';
},
});
});

columns.push(
{ title: 'Created', field: 'created_at', minWidth: 160, headerFilter: 'input' }
);

return columns;
},

reloadTable() {
this.loadTable();
},
};
};

window.deleteCustomer = function (id) {
if (!confirm('Delete this customer? This cannot be undone.')) {
return;
}
_postDelete('/customers/' + id + '/delete');
};

window.customerForm = function (customerTypes, initialTypeId, initialValues) {
return {
customerTypes: customerTypes,
selectedTypeId: String(initialTypeId || ''),
attributeValues: Object.assign({}, initialValues || {}),
apiLookupState: {},
apiLookupError: {},
apiLookupOptions: {},
apiLookupOpen: {},

get currentType() {
var id = this.selectedTypeId;
if (!id) return null;
return this.customerTypes.find(function (t) { return String(t.id) === String(id); }) || null;
},

get currentAttributes() {
if (!this.currentType) return [];
return this.currentType.attributes.slice().sort(function (a, b) {
return (a.order || 0) - (b.order || 0);
});
},

init() {
var self = this;
this.currentAttributes.forEach(function (attr) {
if (attr.type === 'api_lookup') { self.fetchApiValue(attr); }
});
},

onTypeChange() {
this.attributeValues = {};
this.apiLookupOptions = {};
this.apiLookupState = {};
this.apiLookupOpen = {};
var self = this;
this.$nextTick(function () {
self.currentAttributes.forEach(function (attr) {
if (attr.type === 'api_lookup') { self.fetchApiValue(attr); }
});
});
},

inputType(attrType) {
return ['number', 'date'].includes(attrType) ? attrType : 'text';
},

getApiOptions(name) {
return this.apiLookupOptions[name] || { fields: [], records: [] };
},

openApiLookup(name) {
var o = {}; o[name] = true;
this.apiLookupOpen = Object.assign({}, this.apiLookupOpen, o);
},

closeApiLookup(name) {
var o = {}; o[name] = false;
this.apiLookupOpen = Object.assign({}, this.apiLookupOpen, o);
},

getFilteredRecords(name, search) {
var records = this.getApiOptions(name).records;
if (!search) return records;
var term = String(search).toLowerCase();
return records.filter(function (rec) {
return rec._display.some(function (v) {
return String(v).toLowerCase().indexOf(term) !== -1;
});
});
},

selectApiOption(attr, rec) {
var newValues = Object.assign({}, this.attributeValues);
newValues[attr.name] = rec._primary;

var autoFill = (attr.api_auto_fill || '').split(';').map(function (s) { return s.trim(); }).filter(Boolean);
var attrs = this.currentAttributes;
autoFill.forEach(function (alias) {
var target = null;
for (var i = 0; i < attrs.length; i++) {
if (attrs[i].alias === alias) { target = attrs[i]; break; }
}
if (!target) return;
var rowVal = rec._row[alias];
if (rowVal !== undefined && rowVal !== null) {
newValues[target.name] = String(rowVal);
}
});

this.attributeValues = newValues;
this.closeApiLookup(attr.name);
},

fetchApiValue(attr) {
var self = this;
if (!attr.api_url) return;

var resolvedUrl = attr.api_url.replace(/\{alias\}/g, encodeURIComponent(attr.alias || ''));

var s = {}; s[attr.name] = 'loading';
var e = {}; e[attr.name] = '';
var o = {}; o[attr.name] = { fields: [], records: [] };
self.apiLookupState = Object.assign({}, self.apiLookupState, s);
self.apiLookupError = Object.assign({}, self.apiLookupError, e);
self.apiLookupOptions = Object.assign({}, self.apiLookupOptions, o);

var matchFields = (attr.api_match_field || '')
.split(';')
.map(function (s) { return s.trim(); })
.filter(Boolean);

fetch('/api/proxy?url=' + encodeURIComponent(resolvedUrl))
.then(function (res) { return res.json(); })
.then(function (envelope) {
if (envelope.error) {
var se = {}; se[attr.name] = envelope.error;
var ss = {}; ss[attr.name] = 'error';
self.apiLookupError = Object.assign({}, self.apiLookupError, se);
self.apiLookupState = Object.assign({}, self.apiLookupState, ss);
return;
}
var body = envelope.body || '';
var result = { fields: matchFields.slice(), records: [] };
var seenRows = [];

function addRow(rawRow) {
if (seenRows.indexOf(rawRow) !== -1) return;
seenRows.push(rawRow);
var display = result.fields.map(function (f) {
var v = rawRow[f]; return (v !== undefined && v !== null) ? String(v) : '';
});
result.records.push({ _primary: display[0] || '', _display: display, _row: rawRow });
}

if (attr.api_format === 'xml') {
try {
var doc = new DOMParser().parseFromString(body, 'text/xml');
if (result.fields.length === 0) { result.fields = [doc.documentElement.tagName]; }
var firstField = result.fields[0];
var els = doc.getElementsByTagName(firstField);
for (var xi = 0; xi < els.length; xi++) {
var row = {};
var par = els[xi].parentNode;
if (par) {
for (var xc = 0; xc < par.childNodes.length; xc++) {
var cn = par.childNodes[xc];
if (cn.nodeType === 1) { row[cn.tagName] = cn.textContent.trim(); }
}
}
addRow(row);
}
} catch (e) { /* leave records empty */ }
} else {
try {
var parsed = JSON.parse(body);
if (result.fields.length > 0) {
_deepFindRows(parsed, result.fields[0]).forEach(function (hit) { addRow(hit.row); });
} else if (Array.isArray(parsed)) {
result.fields = ['Value'];
parsed.forEach(function (item) {
if (typeof item !== 'object') { addRow({ Value: String(item) }); }
});
} else if (typeof parsed === 'string' || typeof parsed === 'number' || typeof parsed === 'boolean') {
result.fields = ['Value'];
addRow({ Value: String(parsed) });
}
} catch (e) { /* leave records empty */ }
}

var oo = {}; oo[attr.name] = result;
var os = {}; os[attr.name] = 'idle';
self.apiLookupOptions = Object.assign({}, self.apiLookupOptions, oo);
self.apiLookupState = Object.assign({}, self.apiLookupState, os);
})
.catch(function (err) {
console.error('[api-lookup] fetch failed:', err);
var ce = {}; ce[attr.name] = 'Network error — see browser console.';
var cs = {}; cs[attr.name] = 'error';
self.apiLookupError = Object.assign({}, self.apiLookupError, ce);
self.apiLookupState = Object.assign({}, self.apiLookupState, cs);
});
},

confirmDelete(event) {
if (confirm('Delete this customer? This cannot be undone.')) {
event.target.submit();
}
},
};
};

// Unsaved-changes guard — fires beforeunload warning when a .ct-form has been
// touched but not yet submitted. Delete forms and the logout form are excluded
// because they use different CSS classes and are intentional navigation.


+ 20
- 0
routes/web.php Переглянути файл

@@ -6,6 +6,8 @@ use App\Controllers\ApiProxyController;
use App\Controllers\AuthController;
use App\Controllers\CampaignController;
use App\Controllers\CampaignTypeController;
use App\Controllers\CustomerController;
use App\Controllers\CustomerTypeController;
use App\Controllers\HealthController;
use App\Controllers\HomeController;
use App\Controllers\JobController;
@@ -68,3 +70,21 @@ $router->post('/job-types', [JobTypeController::class, 'store']) ->
$router->get('/job-types/{id}/edit', [JobTypeController::class, 'edit']) ->middleware('auth');
$router->post('/job-types/{id}/update', [JobTypeController::class, 'update']) ->middleware('auth');
$router->post('/job-types/{id}/delete', [JobTypeController::class, 'destroy'])->middleware('auth');

// ── Customers ─────────────────────────────────────────────────────────────────
$router->get('/customers', [CustomerController::class, 'index']) ->middleware('auth');
$router->get('/customers/data', [CustomerController::class, 'data']) ->middleware('auth');
$router->get('/customers/create', [CustomerController::class, 'create']) ->middleware('auth');
$router->post('/customers', [CustomerController::class, 'store']) ->middleware('auth');
$router->get('/customers/{id}/edit', [CustomerController::class, 'edit']) ->middleware('auth');
$router->post('/customers/{id}/update', [CustomerController::class, 'update']) ->middleware('auth');
$router->post('/customers/{id}/delete', [CustomerController::class, 'destroy'])->middleware('auth');

// ── Customer Types ────────────────────────────────────────────────────────────
$router->get('/customer-types', [CustomerTypeController::class, 'index']) ->middleware('auth');
$router->get('/customer-types/data', [CustomerTypeController::class, 'data']) ->middleware('auth');
$router->get('/customer-types/create', [CustomerTypeController::class, 'create']) ->middleware('auth');
$router->post('/customer-types', [CustomerTypeController::class, 'store']) ->middleware('auth');
$router->get('/customer-types/{id}/edit', [CustomerTypeController::class, 'edit']) ->middleware('auth');
$router->post('/customer-types/{id}/update', [CustomerTypeController::class, 'update']) ->middleware('auth');
$router->post('/customer-types/{id}/delete', [CustomerTypeController::class, 'destroy'])->middleware('auth');

Завантаження…
Відмінити
Зберегти

Powered by TurnKey Linux.