Daniel Covington 2 veckor sedan
förälder
incheckning
c9f0f138c6
7 ändrade filer med 394 tillägg och 4 borttagningar
  1. +8
    -4
      .gitignore
  2. +30
    -0
      app/Controllers/CustomerController.php
  3. +18
    -0
      app/Repositories/CustomerRepository.php
  4. +4
    -0
      app/Views/customers/create.php
  5. +4
    -0
      app/Views/customers/edit.php
  6. +4
    -0
      docs/README.md
  7. +326
    -0
      docs/USAGE.md

+ 8
- 4
.gitignore Visa fil

@@ -46,7 +46,11 @@ node_modules/
npm-debug.log*
yarn-error.log*

.claude/
.abacusai/

graphify-out/*
.claude/*
.abacusai/*
graphify-out/*
.graphify_ast_extract.py
.graphify_ast.json
.graphify_detect.json
.graphify_python
.graphify_uncached.txt

+ 30
- 0
app/Controllers/CustomerController.php Visa fil

@@ -91,6 +91,21 @@ class CustomerController extends Controller
]);
}

$encodedValues = json_encode($form['attribute_values'], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
$duplicate = $this->repo()->findDuplicate((int) $form['customer_type_id'], $encodedValues);

if ($duplicate !== null) {
$model->form = $form;
$model->errors['_duplicate'] = [
'A customer with these exact values already exists: <a href="/customers/' . (int) $duplicate['id'] . '/edit">Customer #' . (int) $duplicate['id'] . ' (' . htmlspecialchars((string) $duplicate['customer_type_name'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ')</a>.',
];

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'];
@@ -159,6 +174,21 @@ class CustomerController extends Controller
]);
}

$encodedValues = json_encode($form['attribute_values'], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
$duplicate = $this->repo()->findDuplicate((int) $form['customer_type_id'], $encodedValues, (int) $id);

if ($duplicate !== null) {
$model->form = $form;
$model->errors['_duplicate'] = [
'These values are identical to an existing customer: <a href="/customers/' . (int) $duplicate['id'] . '/edit">Customer #' . (int) $duplicate['id'] . ' (' . htmlspecialchars((string) $duplicate['customer_type_name'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ')</a>.',
];

return $this->view('customers.edit', [
'model' => $model,
'pageTitle' => $model->title,
]);
}

$customer = new Customer();
$customer->id = (int) $id;
$customer->customerTypeId = (int) $form['customer_type_id'];


+ 18
- 0
app/Repositories/CustomerRepository.php Visa fil

@@ -88,6 +88,24 @@ class CustomerRepository extends Repository
);
}

/** Returns an existing customer with the same type and attribute values, excluding $excludeId (use for update). */
public function findDuplicate(int $typeId, string $attributeValuesJson, int $excludeId = 0): ?array
{
$sql = 'SELECT TOP (1) c.id, ct.name AS customer_type_name
FROM customer c
INNER JOIN customer_type ct ON c.customer_type_id = ct.id
WHERE c.customer_type_id = :type_id
AND c.attribute_values = :attribute_values';
$params = ['type_id' => $typeId, 'attribute_values' => $attributeValuesJson];

if ($excludeId > 0) {
$sql .= ' AND c.id != :exclude_id';
$params['exclude_id'] = $excludeId;
}

return $this->database->first($sql, $params);
}

/** Used after INSERT to recover the generated id for audit logging. */
public function findLatestByType(int $typeId): ?array
{


+ 4
- 0
app/Views/customers/create.php Visa fil

@@ -26,6 +26,10 @@ window.__initialCtVals = <?= json_encode($model->form['attribute_values'], JSON_
<div class="alert alert-error"><?= e($model->errors['_token'][0]) ?></div>
<?php endif; ?>

<?php if (isset($model->errors['_duplicate'])): ?>
<div class="alert alert-error"><?= $model->errors['_duplicate'][0] ?></div>
<?php endif; ?>

<form method="post" action="/customers" class="ct-form" novalidate>
<?= csrf_field() ?>



+ 4
- 0
app/Views/customers/edit.php Visa fil

@@ -27,6 +27,10 @@ window.__initialCtVals = <?= json_encode($model->form['attribute_values'], JSON_
<div class="alert alert-error"><?= e($model->errors['_token'][0]) ?></div>
<?php endif; ?>

<?php if (isset($model->errors['_duplicate'])): ?>
<div class="alert alert-error"><?= $model->errors['_duplicate'][0] ?></div>
<?php endif; ?>

<form method="post" action="/customers/<?= e((string) $customerId) ?>/update" class="ct-form" novalidate>
<?= csrf_field() ?>



+ 4
- 0
docs/README.md Visa fil

@@ -62,3 +62,7 @@ Browser → public/index.php → Request → Dispatcher → Router → Route →
## Flow chart

See [`REQUEST_FLOW.md`](./REQUEST_FLOW.md) for a chart of how requests and responses move through the framework.

## Usage guide

See [`USAGE.md`](./USAGE.md) for a user guide that explains the app’s main features and workflows.

+ 326
- 0
docs/USAGE.md Visa fil

@@ -0,0 +1,326 @@
# KCI Campaign Tracker — User Guide

This guide explains how to use the Campaign Tracker application day to day.

## What the app does

Campaign Tracker helps you manage:

- **Campaign Types** — templates or categories for campaigns
- **Campaigns** — the main records you create and manage
- **Job Types** — templates or categories for jobs
- **Jobs** — work items connected to campaigns
- **Customer Types** — templates or categories for customers
- **Customers** — people or accounts tied to your workflow

The dashboard gives you a quick overview of totals, recent items, and counts by type.

---

## Getting started

### Open the app

In the default Docker setup, open:

- `http://localhost:8801`

### Sign in

If authentication is enabled, click **Login** and sign in through the configured **Keycloak** server.

If you are running locally outside Docker, make sure your Keycloak values are set in your environment first.

---

## Main navigation

The top navigation takes you to the main areas of the app:

- **Home** — dashboard and quick stats
- **Campaigns** — manage campaigns
- **Campaign Types** — manage campaign definitions
- **Jobs** — manage all jobs
- **Job Types** — manage job definitions
- **Customers** — manage customer records
- **Customer Types** — manage customer definitions

---

## Typical workflow

A common setup flow looks like this:

1. Create **Campaign Types**
2. Create **Job Types**
3. Create **Customer Types**
4. Create **Campaigns**
5. Add **Jobs** to campaigns
6. Create or update **Customers**

You can work in a different order, but the type records usually come first because they define the available attributes for the main records.

---

## Dashboard

The home page shows:

- totals for each entity type
- recent campaigns
- recent customers
- count breakdowns by type

Use the dashboard to quickly jump into the area you need.

---

## Campaign Types

Use **Campaign Types** when you want to define reusable campaign templates.

### What you can do

- view all campaign types
- create a new campaign type
- edit an existing campaign type
- delete a campaign type

### Why it matters

Campaign types define the attributes that campaigns can use. For example, a campaign type might include fields like budget, owner, region, or status.

---

## Campaigns

Use **Campaigns** to create and manage the main campaign records.

### What you can do

- view the campaign directory
- create a new campaign
- edit campaign details
- delete a campaign
- open the campaign’s job list
- refresh the table

### Campaign jobs

Each campaign can have jobs attached to it. From the campaign view, you can:

- see jobs for that campaign
- import jobs from Google Sheets
- import jobs from a file
- filter jobs by the selected campaign

---

## Job Types

Use **Job Types** to define reusable job templates.

### What you can do

- create a job type
- edit a job type
- delete a job type

### Why it matters

Job types define the attributes used when creating jobs. This makes it easier to keep job records consistent.

---

## Jobs

Use **Jobs** to track work items across campaigns.

### What you can do

- view all jobs in the directory
- create a new job
- edit a job
- delete a job
- refresh the jobs table

### Campaign-specific jobs

You can also open the job list for a single campaign to focus on just that campaign’s work.

---

## Customer Types

Use **Customer Types** to define reusable customer templates.

### What you can do

- create a customer type
- edit a customer type
- delete a customer type

### Why it matters

Customer types define the attributes available when you create customer records.

---

## Customers

Use **Customers** to manage customer or account records.

### What you can do

- view all customers in the directory
- create a new customer
- edit a customer
- delete a customer
- refresh the table

---

## Data tables

The list pages use interactive tables.

Common table features include:

- sorting
- searching
- row selection
- drill-down into related records
- refresh buttons

If a table looks empty, try refreshing the page or the table itself.

---

## Importing jobs from Google Sheets

The app includes import tools for campaign jobs.

### Basic flow

1. Open a campaign
2. Open the campaign’s jobs page
3. Provide a Google Sheets URL
4. Choose the sheet and job type
5. Run the import

### What the importer does

The importer looks for sheet columns that match the attribute names on the selected job type.

If the headers do not match, the import can fail or skip rows.

### Tips

- Make sure the Google Sheet is accessible
- Make sure the columns match the job type attributes
- Use consistent header names

---

## Importing jobs from a file

The campaign job page also supports file-based import.

Typical steps:

1. Open the campaign jobs page
2. Choose a file source
3. Pick the sheet or tab if needed
4. Choose the job type
5. Import the rows

This is useful when the data already exists in a spreadsheet file instead of a published Google Sheet.

---

## Public API endpoints

The app exposes a few public JSON endpoints:

- `GET /api/customers`
- `GET /api/customers/{id}`
- `GET /api/customer-types`

There is also an authenticated proxy endpoint:

- `GET /api/proxy?url=https://example.com/data.json`

The proxy requires login and only accepts valid `http` or `https` URLs.

---

## Health check

You can verify the app is running with:

- `GET /health`

In the default Docker setup, that is:

- `http://localhost:8801/health`

---

## Common troubleshooting

### Login does not work

Check the Keycloak settings in your environment:

- `KEYCLOAK_BASE_URL`
- `KEYCLOAK_REALM`
- `KEYCLOAK_CLIENT_ID`
- `KEYCLOAK_CLIENT_SECRET`
- `KEYCLOAK_REDIRECT_URI`
- `KEYCLOAK_LOGOUT_REDIRECT_URI`

### Database errors on startup

If migrations fail, wait a few seconds and try again. SQL Server may still be starting.

### Empty or missing table data

Run migrations and make sure the database is initialized:

```bash
docker compose exec campaign-tracker-app php scripts/migrate.php up
```

### Import errors

If an import fails, check:

- campaign exists
- job type exists
- sheet URL is valid
- sheet headers match the selected type’s attributes

---

## Setup summary for local development

```bash
docker compose up -d --build
docker compose exec campaign-tracker-app composer install
docker compose exec campaign-tracker-app php scripts/migrate.php up
```

Then open:

- `http://localhost:8801`

---

## Where to look in the code

- `routes/web.php` — route definitions
- `app/Controllers/` — request handling
- `app/Repositories/` — database access
- `app/Views/` — templates
- `database/migrations/` — schema setup
- `scripts/migrate.php` — migration CLI

Laddar…
Avbryt
Spara

Powered by TurnKey Linux.