- 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 viewspull/2/head
| @@ -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…)" | |||
| ] | |||
| } | |||
| } | |||
| @@ -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 })" | |||
| ] | |||
| } | |||
| } | |||
| @@ -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 | |||
| @@ -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. | |||
| @@ -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]); | |||
| } | |||
| } | |||
| @@ -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']); | |||
| @@ -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">↕</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">×</button> | |||
| <div class="attribute-fields"> | |||
| <span class="attr-drag-handle" title="Drag to reorder">↕</span> | |||
| <label class="field attribute-order-field"> | |||
| <span>Order</span> | |||
| <input class="input" type="number" | |||
| :name="`attribute_order[${index}]`" | |||
| x-model.number="attr.order" min="1"> | |||
| </label> | |||
| <label class="field attribute-name-field"> | |||
| <span>Attribute name</span> | |||
| <input class="input" type="text" :name="`attribute_name[${index}]`" | |||
| x-model="attr.name" placeholder="e.g. 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">×</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> | |||
| @@ -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">↕</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">×</button> | |||
| <div class="attribute-fields"> | |||
| <span class="attr-drag-handle" title="Drag to reorder">↕</span> | |||
| <label class="field attribute-order-field"> | |||
| <span>Order</span> | |||
| <input class="input" type="number" | |||
| :name="`attribute_order[${index}]`" | |||
| x-model.number="attr.order" min="1"> | |||
| </label> | |||
| <label class="field attribute-name-field"> | |||
| <span>Attribute name</span> | |||
| <input class="input" type="text" :name="`attribute_name[${index}]`" | |||
| x-model="attr.name" placeholder="e.g. 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">×</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> | |||
| @@ -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] ?? ''" | |||
| @@ -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] ?? ''" | |||
| @@ -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> | |||
| @@ -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)." } | |||
| @@ -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; | |||
| @@ -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">—</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">—</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(); | |||
| } | |||
| }()); | |||
| @@ -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']); | |||
Powered by TurnKey Linux.