Browse Source

add api lookup attributes and dynamic job table columns

- add API Lookup attribute type with alias, URL, format, match fields, and auto-fill config
- update job type create/edit forms to configure API lookup attributes
- add authenticated /api/proxy route for external lookup requests
- enhance job create/edit flow to load and select API lookup values
- rebuild jobs table columns dynamically from job type attributes
- add unsaved-changes guard for ct-form forms
- include related UI/style updates across job and job-type views
pull/2/head
Daniel Covington 2 weeks ago
parent
commit
17d3c4cfa2
16 changed files with 2160 additions and 105 deletions
  1. +9
    -0
      .abacusai/config.json
  2. +2
    -1
      .claude/settings.local.json
  3. +19
    -0
      .env_prod
  4. +18
    -1
      AGENTS.md
  5. +1331
    -0
      CLAUDE.md
  6. +48
    -0
      app/Controllers/ApiProxyController.php
  7. +25
    -2
      app/Controllers/JobTypeController.php
  8. +71
    -24
      app/Views/job-types/create.php
  9. +71
    -24
      app/Views/job-types/edit.php
  10. +41
    -2
      app/Views/jobs/create.php
  11. +41
    -2
      app/Views/jobs/edit.php
  12. +8
    -0
      app/Views/jobs/index.php
  13. +3
    -3
      docker-publish.ps1
  14. +109
    -0
      public/css/site.css
  15. +360
    -46
      public/js/app.js
  16. +4
    -0
      routes/web.php

+ 9
- 0
.abacusai/config.json View File

@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(git *)",
"Bash(git --no-pager log --oneline -8 2>&1 || true)",
"Bash(git --no-pager diff -- app/Controllers/ApiProxyController.php routes/web.php app/Controllers/JobTypeController.php app/V…)"
]
}
}

+ 2
- 1
.claude/settings.local.json View File

@@ -13,7 +13,8 @@
"Bash(dir /b /s)",
"Bash(findstr \"^app\")",
"PowerShell(cd \"d:\\\\Development\\\\PHP\\\\Campaign-Tracker\"; Get-ChildItem -Recurse -Directory | Select-Object -ExpandProperty FullName | Where-Object { $_ -notmatch '\\\\.git|\\\\.claude|node_modules' } | Sort-Object | Select-Object -First 30)",
"Bash(git -C \"d:\\\\Development\\\\PHP\\\\Campaign-Tracker\" remote get-url origin)"
"Bash(git -C \"d:\\\\Development\\\\PHP\\\\Campaign-Tracker\" remote get-url origin)",
"PowerShell(Get-ChildItem -Path \"d:\\\\Development\\\\PHP\\\\Campaign-Tracker\" -Recurse -Directory -ErrorAction SilentlyContinue | Select-Object -First 30 | ForEach-Object { $_.FullName })"
]
}
}

+ 19
- 0
.env_prod View File

@@ -0,0 +1,19 @@
APP_ENV=local
APP_DEBUG=true

DB_HOST=sqlserver
DB_PORT=1433
DB_DATABASE=Campaign_Tracker
DB_USERNAME=sa
DB_PASSWORD=Dev_Password123!

# ── Keycloak ───────────────────────────────────────────────────────────────────
# KEYCLOAK_BASE_URL: Base URL of your Keycloak server.
# Keycloak 17+ (no /auth prefix): http://localhost:8080
# Keycloak < 17 (has /auth prefix): http://localhost:8080/auth
KEYCLOAK_BASE_URL=http://kci-app01.ntp.kentcommunications.com:8180/
KEYCLOAK_REALM=KCI
KEYCLOAK_CLIENT_ID=canopy-web
KEYCLOAK_CLIENT_SECRET=LHWXp5UUuES00Dz2iCjTJJgX9su6co0y
KEYCLOAK_REDIRECT_URI=http://192.168.1.200:8801/auth/callback
KEYCLOAK_LOGOUT_REDIRECT_URI=http://192.168.1.200:8801/login

+ 18
- 1
AGENTS.md View File

@@ -1311,4 +1311,21 @@ class MyDashboardViewModel
}
```

Populate it in the controller using private `repo()` methods or inline `new Repository(database())` calls — consistent with how other controllers are written in this project.
Populate it in the controller using private `repo()` methods or inline `new Repository(database())` calls — consistent with how other controllers are written in this project.

---

## Clean JavaScript Practices (Distilled)

Source: https://medium.com/@onix_react/best-practices-for-writing-clean-javascript-code-a4e5755de69a

- Prefer `const` and `let` over `var` to avoid function-scoped surprises.
- Keep scope tight and avoid globals to prevent hidden coupling and collisions.
- Use small, focused functions and clear names to make intent obvious.
- Prefer arrow functions when lexical `this` and concise syntax improve clarity.
- Use `async/await` for async flows and handle failures with `try/catch`.
- Fail safely: validate inputs, handle exceptions, and log actionable error details.
- Use array helpers (`map`, `filter`, `reduce`, `forEach`) where they improve readability.
- Minimize direct DOM writes; batch updates, cache selectors, and use delegation when possible.
- Keep formatting and naming conventions consistent across the project.
- Document non-obvious decisions briefly; avoid redundant comments that restate code.

+ 1331
- 0
CLAUDE.md
File diff suppressed because it is too large
View File


+ 48
- 0
app/Controllers/ApiProxyController.php View File

@@ -0,0 +1,48 @@
<?php

declare(strict_types=1);

namespace App\Controllers;

use Core\Controller;
use Core\Request;
use Core\Response;

class ApiProxyController extends Controller
{
public function fetch(): Response
{
$request = Request::capture();
$url = trim((string) ($request->input('url') ?? ''));

if ($url === '' ||
!filter_var($url, FILTER_VALIDATE_URL) ||
!in_array(parse_url($url, PHP_URL_SCHEME), ['http', 'https'], true)
) {
return Response::json(['error' => 'Invalid or missing URL.'], 400);
}

$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 3,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_USERAGENT => 'CampaignTracker-ApiProxy/1.0',
CURLOPT_HTTPHEADER => ['Accept: application/json, application/xml, text/xml, text/plain, */*'],
]);

$body = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErr = curl_error($ch);
curl_close($ch);

if ($body === false) {
return Response::json(['error' => $curlErr ?: 'Outbound request failed.'], 502);
}

return Response::json(['body' => (string) $body, 'http_status' => $httpCode]);
}
}

+ 25
- 2
app/Controllers/JobTypeController.php View File

@@ -218,19 +218,42 @@ class JobTypeController extends Controller
->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;
$attributes[] = [

$validatedType = in_array($attrType, ['text', 'number', 'date', 'boolean', 'api_lookup'], true) ? $attrType : 'text';

$attr = [
'name' => $attrName,
'type' => in_array($attrType, ['text', 'number', 'date', 'boolean'], true) ? $attrType : 'text',
'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']);


+ 71
- 24
app/Views/job-types/create.php View File

@@ -45,30 +45,77 @@
x-on:drop="drop($event, index)"
x-on:dragend="dragEnd()"
:class="{ 'is-dragging': dragIndex === index, 'is-drag-over': dragOverIndex === index && dragIndex !== index }">
<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. Priority" maxlength="100">
</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>
</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 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. Priority" 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. PROD" 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>


+ 71
- 24
app/Views/job-types/edit.php View File

@@ -52,30 +52,77 @@
x-on:drop="drop($event, index)"
x-on:dragend="dragEnd()"
:class="{ 'is-dragging': dragIndex === index, 'is-drag-over': dragOverIndex === index && dragIndex !== index }">
<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. Priority" maxlength="100">
</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>
</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 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. Priority" 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. PROD" 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>


+ 41
- 2
app/Views/jobs/create.php View File

@@ -76,7 +76,7 @@ window.__initialJtVals = <?= json_encode($model->form['attribute_values'], JSON_
</div>
<div class="form-grid">
<template x-for="attr in currentAttributes" :key="attr.name">
<label class="field">
<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"
@@ -87,7 +87,46 @@ window.__initialJtVals = <?= json_encode($model->form['attribute_values'], JSON_
<option value="false" :selected="attributeValues[attr.name] === 'false'">False</option>
</select>
</template>
<template x-if="attr.type !== 'boolean'">
<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] ?? ''"


+ 41
- 2
app/Views/jobs/edit.php View File

@@ -73,7 +73,7 @@ window.__initialJtVals = <?= json_encode($model->form['attribute_values'], JSON_
</div>
<div class="form-grid">
<template x-for="attr in currentAttributes" :key="attr.name">
<label class="field">
<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"
@@ -84,7 +84,46 @@ window.__initialJtVals = <?= json_encode($model->form['attribute_values'], JSON_
<option value="false" :selected="attributeValues[attr.name] === 'false'">False</option>
</select>
</template>
<template x-if="attr.type !== 'boolean'">
<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] ?? ''"


+ 8
- 0
app/Views/jobs/index.php View File

@@ -28,6 +28,14 @@
</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="job-table" class="tabulator-host"></div>
</section>



+ 3
- 3
docker-publish.ps1 View File

@@ -36,11 +36,11 @@ function Get-BaseArgs {
}

# ---------------------------------------------------------------------------
# Step 1 — copy .env (first password prompt)
# Step 1 — copy .env_prod as .env (first password prompt)
# ---------------------------------------------------------------------------
Write-Step "Copying .env to $SSH_USER@$SSH_HOST"
Write-Step "Copying .env_prod to $SSH_USER@$SSH_HOST as .env"
$scpArgs = Get-BaseArgs
$scpArgs += ".env", "${SSH_USER}@${SSH_HOST}:${REPO_PATH}/.env"
$scpArgs += ".env_prod", "${SSH_USER}@${SSH_HOST}:${REPO_PATH}/.env"
scp @scpArgs
if ($LASTEXITCODE -ne 0) { Write-Error "scp failed (exit $LASTEXITCODE)." }



+ 109
- 0
public/css/site.css View File

@@ -1123,12 +1123,116 @@ a.stat-card:hover::after {
}

.attribute-row {
display: grid;
gap: 6px;
}

.attribute-fields {
display: flex;
align-items: flex-end;
gap: 8px;
flex-wrap: wrap;
}

.api-lookup-config {
display: flex;
gap: 8px;
flex-wrap: wrap;
padding-left: 26px;
}

.api-lookup-url-field {
flex: 3;
min-width: 240px;
}

.api-lookup-match-field {
flex: 2;
min-width: 180px;
}

.api-lookup-config .field {
flex: 1;
min-width: 130px;
}

.api-lookup-label {
grid-column: 1 / -1;
}

.api-lookup-field {
position: relative;
}

.api-lookup-dropdown {
position: absolute;
top: calc(100% + 3px);
left: 0;
right: 0;
z-index: 200;
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
box-shadow: var(--shadow);
overflow: hidden;
}

.api-lookup-loading {
font-size: 13px;
color: var(--text-muted);
margin: 0;
padding: 8px 10px;
}

.api-lookup-table {
font-size: 13px;
}

.api-lookup-thead {
display: grid;
background: var(--surface-raised);
border-bottom: 1px solid var(--border);
font-weight: 600;
color: var(--text-secondary);
}

.api-lookup-thead span,
.api-lookup-tr span {
padding: 5px 10px;
}

.api-lookup-tbody {
max-height: 220px;
overflow-y: auto;
}

.api-lookup-tr {
display: grid;
cursor: pointer;
border-bottom: 1px solid var(--border);
transition: background 80ms ease;
}

.api-lookup-tr:last-child {
border-bottom: none;
}

.api-lookup-tr:hover {
background: var(--info-bg);
}

.api-lookup-tr.is-selected {
background: var(--primary-light);
color: var(--primary);
font-weight: 500;
}

.api-lookup-empty {
padding: 8px 10px;
font-size: 13px;
color: var(--text-muted);
}

.attr-drag-handle {
cursor: grab;
user-select: none;
@@ -1188,6 +1292,11 @@ a.stat-card:hover::after {
min-width: 160px;
}

.attribute-alias-field {
flex: 1;
min-width: 100px;
}

.attribute-type-field {
flex: 1;
min-width: 110px;


+ 360
- 46
public/js/app.js View File

@@ -19,6 +19,26 @@ function _postDelete(action) {
form.submit();
}

// Returns every {value, row} pair where `key` has a primitive value anywhere in the tree.
// `row` is the nearest plain-object ancestor that directly owns `key`.
function _deepFindRows(obj, key, out) {
out = out || [];
if (obj === null || typeof obj !== 'object') return out;
if (Array.isArray(obj)) {
for (var i = 0; i < obj.length; i++) { _deepFindRows(obj[i], key, out); }
return out;
}
if (Object.prototype.hasOwnProperty.call(obj, key)) {
var v = obj[key];
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') {
out.push({ value: String(v), row: obj });
}
}
for (var k in obj) {
if (Object.prototype.hasOwnProperty.call(obj, k)) { _deepFindRows(obj[k], key, out); }
}
return out;}

function _escapeHtml(value) {
return String(value).replace(/[&<>"']/g, function (char) {
return {
@@ -1158,7 +1178,7 @@ window.jobTypeForm = function (initialAttributes) {
dragOverIndex: null,

addAttribute() {
this.attributes.push({ name: '', type: 'text', order: this.attributes.length + 1 });
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) {
@@ -1207,64 +1227,165 @@ window.jobTypeForm = function (initialAttributes) {
window.jobTable = function () {
return {
table: null,
isLoading: false,
errorMessage: '',

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

initTable() {
async loadTable() {
const el = document.getElementById('job-table');
if (!el || typeof Tabulator === 'undefined') {
if (!el || typeof Tabulator === 'undefined' || this.isLoading) {
return;
}

this.table = new Tabulator(el, {
ajaxURL: '/jobs/data',
layout: 'fitColumns',
responsiveLayout: 'collapse',
pagination: true,
paginationMode: 'local',
paginationSize: 10,
paginationSizeSelector: PAGE_SIZES,
movableColumns: true,
placeholder: 'No jobs found.',
initialSort: [{ column: 'job_type_name', dir: 'asc' }],
columns: [
{
title: 'Actions',
field: 'id',
width: 160,
hozAlign: 'center',
headerSort: false,
formatter: function (cell) {
const id = cell.getValue();
return '<a href="/jobs/' + id + '/edit" class="button button-secondary button-sm">Edit</a> ' +
'<button onclick="window.deleteJob(' + id + ')" class="button button-danger button-sm">Delete</button>';
},
this.isLoading = true;
this.errorMessage = '';

try {
const response = await fetch('/jobs/data', {
headers: { Accept: 'application/json' },
});

if (!response.ok) {
throw new Error('Unable to load jobs.');
}

const rows = await response.json();
const jobRows = Array.isArray(rows) ? rows : [];
const attributes = this.attributeColumnsForRows(jobRows);
const tableRows = this.formatRows(jobRows, 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 jobs found.',
initialSort: [{ column: 'job_type_name', dir: 'asc' }],
columns: columns,
});
} catch (error) {
this.errorMessage = error.message || 'Unable to load jobs.';
} finally {
this.isLoading = false;
}
},

attributeColumnsForRows(rows) {
const attributes = [];

rows.forEach((row) => {
this.normalizeAttributes(row.job_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: '/jobs/' + encodeURIComponent(row.id) + '/edit',
campaign_id: row.campaign_id || '',
campaign_type_name: row.campaign_type_name || '',
job_type_id: row.job_type_id || '',
job_type_name: row.job_type_name || '',
created_at: row.created_at || '',
updated_at: row.updated_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.deleteJob(' + id + ')" class="button button-danger button-sm">Delete</button>';
},
{ title: 'Campaign', field: 'campaign_type_name', minWidth: 160 },
{ title: 'Job Type', field: 'job_type_name', minWidth: 160 },
{
title: 'Attributes',
field: 'attributes_summary',
minWidth: 220,
formatter: function (cell) {
const v = cell.getValue();
return v ? '<span class="attr-summary">' + _escapeHtml(v) + '</span>'
: '<span class="attr-empty">&mdash;</span>';
},
},
{ title: 'Job ID', field: 'id', width: 90, hozAlign: 'center', headerFilter: 'input' },
{ title: 'Campaign', field: 'campaign_type_name', minWidth: 160, headerFilter: 'input' },
{ title: 'Job Type', field: 'job_type_name', minWidth: 160, 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>';
},
{ title: 'Created', field: 'created_at', minWidth: 160 },
],
});
});

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

return columns;
},

reloadTable() {
if (!this.table) {
this.initTable();
return;
}
this.table.setData('/jobs/data');
this.loadTable();
},
};
};
@@ -1281,6 +1402,10 @@ window.jobForm = function (jobTypes, initialTypeId, initialValues) {
jobTypes: jobTypes,
selectedTypeId: String(initialTypeId || ''),
attributeValues: Object.assign({}, initialValues || {}),
apiLookupState: {},
apiLookupError: {},
apiLookupOptions: {},
apiLookupOpen: {},

get currentType() {
var id = this.selectedTypeId;
@@ -1295,14 +1420,170 @@ window.jobForm = function (jobTypes, initialTypeId, initialValues) {
});
},

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

onTypeChange() {
this.attributeValues = {};
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 || ''));

// Reassign whole objects so Alpine sees new references and re-renders nested x-for
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]; }
// Collect sibling-based rows keyed by the first match field element
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) {
// Collect unique parent rows that own the first match field
_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 job? This cannot be undone.')) {
event.target.submit();
@@ -1310,3 +1591,36 @@ window.jobForm = function (jobTypes, initialTypeId, initialValues) {
},
};
};

// 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.
(function () {
function initDirtyFormGuard() {
var forms = document.querySelectorAll('form.ct-form');
if (!forms.length) return;

var dirty = false;

function markDirty() { dirty = true; }
function markClean() { dirty = false; }

forms.forEach(function (form) {
form.addEventListener('input', markDirty);
form.addEventListener('change', markDirty);
form.addEventListener('submit', markClean);
});

window.addEventListener('beforeunload', function (e) {
if (!dirty) return;
e.preventDefault();
e.returnValue = '';
});
}

if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initDirtyFormGuard);
} else {
initDirtyFormGuard();
}
}());

+ 4
- 0
routes/web.php View File

@@ -2,6 +2,7 @@

declare(strict_types=1);

use App\Controllers\ApiProxyController;
use App\Controllers\AuthController;
use App\Controllers\CampaignController;
use App\Controllers\CampaignTypeController;
@@ -10,6 +11,9 @@ use App\Controllers\HomeController;
use App\Controllers\JobController;
use App\Controllers\JobTypeController;

// ── API Proxy ─────────────────────────────────────────────────────────────────
$router->get('/api/proxy', [ApiProxyController::class, 'fetch'])->middleware('auth');

// ── Auth (public) ─────────────────────────────────────────────────────────────
$router->get('/login', [AuthController::class, 'login']);
$router->get('/auth/callback', [AuthController::class, 'callback']);


Loading…
Cancel
Save

Powered by TurnKey Linux.