| @@ -7,7 +7,13 @@ | |||||
| "PowerShell(php -r \"json_decode\\(file_get_contents\\('d:/Development/PHP/Campaign-Tracker/composer.json'\\), true\\) === null ? print\\('INVALID JSON'\\) : print\\('JSON OK'\\);\")", | "PowerShell(php -r \"json_decode\\(file_get_contents\\('d:/Development/PHP/Campaign-Tracker/composer.json'\\), true\\) === null ? print\\('INVALID JSON'\\) : print\\('JSON OK'\\);\")", | ||||
| "PowerShell(php -l \"d:\\\\Development\\\\PHP\\\\Campaign-Tracker\\\\core\\\\Database.php\")", | "PowerShell(php -l \"d:\\\\Development\\\\PHP\\\\Campaign-Tracker\\\\core\\\\Database.php\")", | ||||
| "PowerShell(php -l \"d:\\\\Development\\\\PHP\\\\Campaign-Tracker\\\\app\\\\Controllers\\\\AuthController.php\")", | "PowerShell(php -l \"d:\\\\Development\\\\PHP\\\\Campaign-Tracker\\\\app\\\\Controllers\\\\AuthController.php\")", | ||||
| "PowerShell(docker compose exec campaign-tracker-app php scripts/debug_sheets.php 2>&1)" | |||||
| "PowerShell(docker compose exec campaign-tracker-app php scripts/debug_sheets.php 2>&1)", | |||||
| "Bash(Select-Object -First 30)", | |||||
| "Bash(Format-Table FullName)", | |||||
| "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)" | |||||
| ] | ] | ||||
| } | } | ||||
| } | } | ||||
| @@ -5,15 +5,15 @@ DB_HOST=sqlserver | |||||
| DB_PORT=1433 | DB_PORT=1433 | ||||
| DB_DATABASE=Campaign_Tracker | DB_DATABASE=Campaign_Tracker | ||||
| DB_USERNAME=sa | DB_USERNAME=sa | ||||
| DB_PASSWORD=Dev_Password123! | |||||
| DB_PASSWORD= | |||||
| # ── Keycloak ─────────────────────────────────────────────────────────────────── | # ── Keycloak ─────────────────────────────────────────────────────────────────── | ||||
| # KEYCLOAK_BASE_URL: Base URL of your Keycloak server. | # KEYCLOAK_BASE_URL: Base URL of your Keycloak server. | ||||
| # Keycloak 17+ (no /auth prefix): http://localhost:8080 | # Keycloak 17+ (no /auth prefix): http://localhost:8080 | ||||
| # Keycloak < 17 (has /auth prefix): http://localhost:8080/auth | # 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_BASE_URL= | |||||
| KEYCLOAK_REALM= | |||||
| KEYCLOAK_CLIENT_ID= | |||||
| KEYCLOAK_CLIENT_SECRET= | |||||
| KEYCLOAK_REDIRECT_URI=http://localhost:8801/auth/callback | KEYCLOAK_REDIRECT_URI=http://localhost:8801/auth/callback | ||||
| KEYCLOAK_LOGOUT_REDIRECT_URI=http://localhost:8801/login | KEYCLOAK_LOGOUT_REDIRECT_URI=http://localhost:8801/login | ||||
| @@ -997,5 +997,318 @@ Visual rhythm: | |||||
| - Dense-data readability: | - Dense-data readability: | ||||
| - Preserve minimum font sizes and row heights that remain readable during long operational sessions | - Preserve minimum font sizes and row heights that remain readable during long operational sessions | ||||
| # UI desgin | |||||
| - Refer to template info in the files docs\ux-color-themes.html and docs\ux-design-directions.html | |||||
| # UI Design | |||||
| - Refer to template info in the files docs\ux-color-themes.html and docs\ux-design-directions.html | |||||
| --- | |||||
| ## UI/UX Component Rules | |||||
| These rules define the established UI patterns for this project. Follow them exactly when building new pages, views, or components. Do not invent new patterns — extend the existing ones. | |||||
| --- | |||||
| ### Navigation | |||||
| **Link structure** | |||||
| Every nav link must be an `<a class="nav-link">` with: | |||||
| - An inline SVG icon (14×14, `fill="none"`, `stroke="currentColor"`, `stroke-width="1.75"`, Heroicons-style path). | |||||
| - The label text immediately after the icon. | |||||
| - `aria-hidden="true"` on the SVG. | |||||
| ```html | |||||
| <a class="nav-link" href="/campaigns"> | |||||
| <svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24" aria-hidden="true"> | |||||
| <path stroke-linecap="round" stroke-linejoin="round" d="..."/> | |||||
| </svg> | |||||
| Campaigns | |||||
| </a> | |||||
| ``` | |||||
| **Group separators** | |||||
| Nav links are divided into logical groups separated by `<span class="nav-sep" aria-hidden="true"></span>`: | |||||
| - Group 1: Home | |||||
| - Group 2: Campaigns, Campaign Types | |||||
| - Group 3: Jobs, Job Types | |||||
| - Then a separator before the logged-in user name and logout button. | |||||
| Add a separator between groups and before the auth area. Never put a separator at the very start or end of the nav. | |||||
| **Active state** | |||||
| Add `is-active` class to the current page's link. Use exact match for `/`, prefix match (`str_starts_with`) for all others. | |||||
| **Logout button** | |||||
| The logout button must use `class="nav-logout-btn"` — never `button button-secondary`. It is a ghost outlined button styled for the dark header background. Do not use the standard button classes here. | |||||
| **Contrast** | |||||
| Nav link text uses `rgba(255, 255, 255, 0.88)`. Never go below `0.85` — the minimum for WCAG AA on the `#1F4E79` header. | |||||
| **Focus rings** | |||||
| All nav links and the logout button have `outline: 2px solid rgba(255, 255, 255, 0.6)` on `:focus-visible`. Do not remove this. | |||||
| --- | |||||
| ### Page Layout | |||||
| **Every page content area** uses a `.content-stack` as its root element. This is a CSS grid with `gap: 24px` that stacks the page toolbar, panels, and other sections vertically. | |||||
| ```html | |||||
| <section class="content-stack"> | |||||
| <div class="page-toolbar">...</div> | |||||
| <section class="section-panel">...</section> | |||||
| </section> | |||||
| ``` | |||||
| **Page toolbar** (`.page-toolbar`) always contains: | |||||
| - A `.section-heading` div with an `<h1>` and optional `<p>` description on the left. | |||||
| - A primary action button (or back link) on the right. | |||||
| ```html | |||||
| <div class="page-toolbar"> | |||||
| <div class="section-heading"> | |||||
| <h1>Page Title</h1> | |||||
| <p>One-line description of what this page manages.</p> | |||||
| </div> | |||||
| <a class="button button-primary" href="/resource/create">+ New Resource</a> | |||||
| </div> | |||||
| ``` | |||||
| **Page h1 size** is 32px / 40px line-height (set in `.section-heading h1`). Do not override this inline. | |||||
| --- | |||||
| ### Section Panels | |||||
| Every content block on a page must be wrapped in `<section class="section-panel">`. Key rules: | |||||
| - The left edge always has `border-left: 3px solid var(--primary)` — this is set globally in CSS and must not be removed or overridden. | |||||
| - Every panel that has a header must use `.panel-header`, which automatically adds a `border-bottom` separator below it. | |||||
| - `.panel-header` contains a left `<div>` with `<h2>` + optional `<p>`, and a right side for action buttons. | |||||
| - Panel `<h2>` is 18px. Do not make it larger than the page `<h1>`. | |||||
| ```html | |||||
| <section class="section-panel"> | |||||
| <div class="panel-header"> | |||||
| <div> | |||||
| <h2>Section Title</h2> | |||||
| <p>Brief description of this section's content.</p> | |||||
| </div> | |||||
| <button class="button button-secondary" type="button">Action</button> | |||||
| </div> | |||||
| <!-- panel body content here --> | |||||
| </section> | |||||
| ``` | |||||
| --- | |||||
| ### Stat Cards (Dashboard Metrics) | |||||
| Use `.stat-card` for any numeric summary. Rules: | |||||
| - When a stat card navigates somewhere, use `<a class="stat-card" href="...">` — not a `<div>`. The CSS `a.stat-card` handles hover lift and the "View all →" affordance automatically. | |||||
| - When a stat card is purely informational (no destination), use `<div class="stat-card">`. | |||||
| - Numbers display at 38px / font-weight 700 in `var(--primary)`. | |||||
| - Labels use the `.stat-card span` style: 11px uppercase, `var(--text-secondary)`. | |||||
| - Never put more than one number in a stat card. | |||||
| - Place stat cards in a `.stats-grid` container. For 4 cards use `.stats-grid.stats-grid-4`. For 3 use `.stats-grid` alone (default 3-column). | |||||
| ```html | |||||
| <div class="stats-grid stats-grid-4"> | |||||
| <a class="stat-card" href="/campaigns"> | |||||
| <span>Campaigns</span> | |||||
| <strong><?= e((string) $model->totalCampaigns) ?></strong> | |||||
| </a> | |||||
| <!-- ... --> | |||||
| </div> | |||||
| ``` | |||||
| --- | |||||
| ### Dashboard Pages | |||||
| When a page is an overview/metrics page (like the home dashboard): | |||||
| 1. Start with a `.stats-grid` of stat cards covering the main record counts. | |||||
| 2. Follow with a `.dashboard-panels` grid of two side-by-side `.section-panel` blocks. | |||||
| 3. Left panel: a recent-records list using `.dashboard-table`. | |||||
| 4. Right panel: a breakdown or summary using `.type-breakdown`. | |||||
| **Dashboard table** (`.dashboard-table`) rules: | |||||
| - Columns: ID (`.dashboard-table-id`), name/type, date (`.dashboard-table-date`), action link (`.dashboard-table-action`). | |||||
| - No JavaScript — server-rendered, static HTML table. | |||||
| - Always include a link column to the resource's edit page. | |||||
| - Always include an empty state (`.empty-state`) with a helpful create link for when there is no data. | |||||
| **Type breakdown** (`.type-breakdown`) rules: | |||||
| - Each row is a `.type-breakdown-row` showing: label, proportional bar, count. | |||||
| - When the rows link somewhere use `<a class="type-breakdown-row" href="...">`. The CSS handles hover and focus-visible. | |||||
| - Calculate the bar width as `round(count / max * 100)%`. Pre-calculate `$max` before the loop. | |||||
| --- | |||||
| ### Forms | |||||
| **Structure** | |||||
| All multi-section forms use `.ct-form` (a CSS grid with `gap: 24px`) containing one or more `.form-section` blocks, ending with `.form-actions`. | |||||
| ```html | |||||
| <form method="post" action="..." class="ct-form" novalidate> | |||||
| <?= csrf_field() ?> | |||||
| <div class="form-section"> | |||||
| <label class="field field-full"> | |||||
| <span>Field label <span class="required-mark">*</span></span> | |||||
| <input class="input" type="text" name="name" required> | |||||
| </label> | |||||
| </div> | |||||
| <div class="form-actions"> | |||||
| <button class="button button-primary" type="submit">Save</button> | |||||
| <a class="button button-secondary" href="/resource">Cancel</a> | |||||
| </div> | |||||
| </form> | |||||
| ``` | |||||
| **Sticky save bar** | |||||
| `.ct-form .form-actions` is `position: sticky; bottom: 0` — the save bar sticks to the viewport bottom when the form is taller than the screen. This is set globally in CSS. Do not override or move form-actions outside `.ct-form`. | |||||
| **Native validation** | |||||
| Use HTML5 `required`, `maxlength`, `pattern`, `min`, `max` on inputs. The CSS rule `.input:invalid:not(:placeholder-shown)` automatically shows an error border without JavaScript. Use `novalidate` on the form tag to prevent browser-native popups while still benefiting from the CSS pseudo-class. | |||||
| **Required mark** | |||||
| Mark required fields with `<span class="required-mark">*</span>` inside the label. Color is `var(--error)` via CSS. | |||||
| **Error messages** | |||||
| Server-side validation errors go in `<small class="field-error">` immediately after the input. Add `input-error` class to the input when it has a server error. | |||||
| --- | |||||
| ### Attribute Builder (Drag-Reorder Lists) | |||||
| When a form includes a reorderable list of items (like campaign type attributes): | |||||
| - Each row is `.attribute-row` with `draggable="true"` and Alpine.js drag event handlers. | |||||
| - The first element of every row must be `<span class="attr-drag-handle" title="Drag to reorder">↕</span>`. | |||||
| The CSS hides the unicode character with `font-size: 0` and replaces it with a `::before` radial-gradient dot grid — this is automatic. Do not change the HTML to use an SVG or other icon. | |||||
| - `is-dragging` and `is-drag-over` classes are applied by Alpine and styled by CSS — do not add custom drag styles. | |||||
| --- | |||||
| ### Loading States | |||||
| **Never use plain text** for loading indicators in content areas. Use `.skeleton-rows` with `.skeleton-row` children. | |||||
| ```html | |||||
| <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> | |||||
| ``` | |||||
| Rules: | |||||
| - Use 5 skeleton rows for full-page table loaders. | |||||
| - Use 3 skeleton rows for smaller/drilldown table loaders. | |||||
| - Always pair with `x-cloak` and `x-show` on Alpine-controlled sections so the skeleton only shows while loading. | |||||
| - The shimmer animation and fade-out opacity are set automatically by CSS via `:nth-child` selectors. | |||||
| --- | |||||
| ### Interactive Link Patterns | |||||
| **Cards, rows, and panels that navigate** must be `<a>` elements, not `<div>` elements with `onclick`. This applies to: | |||||
| - Stat cards with a destination | |||||
| - Type breakdown rows | |||||
| - Any list row that is entirely clickable | |||||
| Use the appropriate CSS modifier class (`a.stat-card`, `a.type-breakdown-row`) — the hover and focus styles are already defined. | |||||
| **Inline table action links** (Edit, Delete) in Tabulator formatter functions must use these conventions: | |||||
| - Edit: `class="button button-secondary button-sm"` | |||||
| - Delete: `class="button button-danger button-sm"` with a `window.confirmXxx()` guard function | |||||
| - Jobs/drill-down: `class="button button-primary button-sm"` | |||||
| --- | |||||
| ### Accessibility | |||||
| **Focus rings** | |||||
| - All content-area links get `outline: 2px solid var(--accent); outline-offset: 2px` on `:focus-visible` — set globally in CSS via `.page-content a:focus-visible`. Do not suppress this. | |||||
| - All buttons already have `outline: 2px solid var(--accent); outline-offset: 2px` via `.button:focus-visible`. | |||||
| - Nav links and logout button have white outline rings — see the Navigation section above. | |||||
| **Contrast minimums** | |||||
| - Normal body text: minimum 4.5:1 against its background. | |||||
| - Nav link text: minimum `rgba(255,255,255, 0.85)` against `#1F4E79`. | |||||
| - Secondary text (`var(--text-secondary): #4B5563`) on white: ~7.2:1 — acceptable. | |||||
| - Do not use `var(--text-muted)` (`#6B7280`) for body text — only for supplementary labels and counts. | |||||
| **ARIA** | |||||
| - All SVG icons in nav links must have `aria-hidden="true"`. | |||||
| - All `.nav-sep` elements must have `aria-hidden="true"`. | |||||
| - Empty states must be descriptive — include both what is missing and a create action link. | |||||
| --- | |||||
| ### Repository Patterns for Metrics | |||||
| When a page needs aggregate data (counts, recent records, breakdowns), add methods to the relevant repository. Do not inline aggregate SQL in the controller. | |||||
| **Count method pattern** | |||||
| ```php | |||||
| public function count(): int | |||||
| { | |||||
| $row = $this->database->first('SELECT COUNT(*) AS total FROM table_name'); | |||||
| return (int) ($row['total'] ?? 0); | |||||
| } | |||||
| ``` | |||||
| **Recent records pattern** | |||||
| Inline the `TOP` limit directly — SQL Server does not accept bound parameters in `TOP` expressions. | |||||
| ```php | |||||
| public function recentWithType(int $limit = 5): array | |||||
| { | |||||
| return $this->database->query( | |||||
| "SELECT TOP ({$limit}) t.id, t.created_at, ref.name AS type_name | |||||
| FROM main_table t | |||||
| INNER JOIN ref_table ref ON t.ref_id = ref.id | |||||
| ORDER BY t.id DESC" | |||||
| ); | |||||
| } | |||||
| ``` | |||||
| **Grouped count pattern** | |||||
| Use `LEFT JOIN` so that reference rows with zero records still appear. | |||||
| ```php | |||||
| public function countByType(): array | |||||
| { | |||||
| return $this->database->query( | |||||
| 'SELECT ref.name AS type_name, COUNT(t.id) AS record_count | |||||
| FROM ref_table ref | |||||
| LEFT JOIN main_table t ON t.ref_id = ref.id | |||||
| GROUP BY ref.id, ref.name | |||||
| ORDER BY record_count DESC, ref.name ASC' | |||||
| ); | |||||
| } | |||||
| ``` | |||||
| --- | |||||
| ### ViewModels for Dashboard Pages | |||||
| Dashboard pages use a dedicated ViewModel. Metric fields use typed defaults so the view is never working with `null`. | |||||
| ```php | |||||
| class MyDashboardViewModel | |||||
| { | |||||
| public int $totalThings = 0; | |||||
| public int $totalTypes = 0; | |||||
| public array $recentItems = []; | |||||
| public array $itemsByType = []; | |||||
| } | |||||
| ``` | |||||
| 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. | |||||
| @@ -4,6 +4,10 @@ declare(strict_types=1); | |||||
| namespace App\Controllers; | namespace App\Controllers; | ||||
| use App\Repositories\CampaignRepository; | |||||
| use App\Repositories\CampaignTypeRepository; | |||||
| use App\Repositories\JobRepository; | |||||
| use App\Repositories\JobTypeRepository; | |||||
| use App\ViewModels\HomeIndexViewModel; | use App\ViewModels\HomeIndexViewModel; | ||||
| use Core\Controller; | use Core\Controller; | ||||
| @@ -11,15 +15,19 @@ class HomeController extends Controller | |||||
| { | { | ||||
| public function index() | public function index() | ||||
| { | { | ||||
| $db = database(); | |||||
| $model = new HomeIndexViewModel(); | $model = new HomeIndexViewModel(); | ||||
| $model->title = 'Campaign Tracker'; | |||||
| $model->eyebrow = 'PHP MVC application'; | |||||
| $model->message = 'Manage campaign types and their configurable attributes using a lightweight PHP MVC stack backed by SQL Server.'; | |||||
| $model->routeExample = '/campaign-types'; | |||||
| $model->totalCampaignTypes = (new CampaignTypeRepository($db))->count(); | |||||
| $model->totalCampaigns = (new CampaignRepository($db))->count(); | |||||
| $model->totalJobTypes = (new JobTypeRepository($db))->count(); | |||||
| $model->totalJobs = (new JobRepository($db))->count(); | |||||
| $model->recentCampaigns = (new CampaignRepository($db))->recentWithType(5); | |||||
| $model->campaignsByType = (new CampaignRepository($db))->countByType(); | |||||
| return $this->view('home.index', [ | return $this->view('home.index', [ | ||||
| 'model' => $model, | 'model' => $model, | ||||
| 'pageTitle' => $model->title, | |||||
| 'pageTitle' => 'Dashboard', | |||||
| ]); | ]); | ||||
| } | } | ||||
| @@ -12,6 +12,35 @@ class CampaignRepository extends Repository | |||||
| protected string $table = 'campaign'; | protected string $table = 'campaign'; | ||||
| protected string $primaryKey = 'id'; | protected string $primaryKey = 'id'; | ||||
| public function count(): int | |||||
| { | |||||
| $row = $this->database->first('SELECT COUNT(*) AS total FROM campaign'); | |||||
| return (int) ($row['total'] ?? 0); | |||||
| } | |||||
| /** @return list<array<string, mixed>> */ | |||||
| public function recentWithType(int $limit = 5): array | |||||
| { | |||||
| return $this->database->query( | |||||
| "SELECT TOP ({$limit}) c.id, c.created_at, ct.name AS campaign_type_name | |||||
| FROM campaign c | |||||
| INNER JOIN campaign_type ct ON c.campaign_type_id = ct.id | |||||
| ORDER BY c.id DESC" | |||||
| ); | |||||
| } | |||||
| /** @return list<array<string, mixed>> */ | |||||
| public function countByType(): array | |||||
| { | |||||
| return $this->database->query( | |||||
| 'SELECT ct.name AS campaign_type_name, COUNT(c.id) AS campaign_count | |||||
| FROM campaign_type ct | |||||
| LEFT JOIN campaign c ON c.campaign_type_id = ct.id | |||||
| GROUP BY ct.id, ct.name | |||||
| ORDER BY campaign_count DESC, ct.name ASC' | |||||
| ); | |||||
| } | |||||
| /** | /** | ||||
| * All campaigns joined with their campaign type name, ordered by id desc. | * All campaigns joined with their campaign type name, ordered by id desc. | ||||
| * | * | ||||
| @@ -14,6 +14,12 @@ class CampaignTypeRepository extends Repository | |||||
| /** | /** | ||||
| * @return list<array<string, mixed>> | * @return list<array<string, mixed>> | ||||
| */ | */ | ||||
| public function count(): int | |||||
| { | |||||
| $row = $this->database->first('SELECT COUNT(*) AS total FROM campaign_type'); | |||||
| return (int) ($row['total'] ?? 0); | |||||
| } | |||||
| public function allOrderedByName(): array | public function allOrderedByName(): array | ||||
| { | { | ||||
| return $this->database->query( | return $this->database->query( | ||||
| @@ -12,6 +12,12 @@ class JobRepository extends Repository | |||||
| protected string $table = 'job'; | protected string $table = 'job'; | ||||
| protected string $primaryKey = 'id'; | protected string $primaryKey = 'id'; | ||||
| public function count(): int | |||||
| { | |||||
| $row = $this->database->first('SELECT COUNT(*) AS total FROM job'); | |||||
| return (int) ($row['total'] ?? 0); | |||||
| } | |||||
| /** @return list<array<string, mixed>> */ | /** @return list<array<string, mixed>> */ | ||||
| public function allWithDetails(): array | public function allWithDetails(): array | ||||
| { | { | ||||
| @@ -11,6 +11,12 @@ class JobTypeRepository extends Repository | |||||
| { | { | ||||
| protected string $table = 'job_type'; | protected string $table = 'job_type'; | ||||
| public function count(): int | |||||
| { | |||||
| $row = $this->database->first('SELECT COUNT(*) AS total FROM job_type'); | |||||
| return (int) ($row['total'] ?? 0); | |||||
| } | |||||
| /** @return list<array<string, mixed>> */ | /** @return list<array<string, mixed>> */ | ||||
| public function allOrderedByName(): array | public function allOrderedByName(): array | ||||
| { | { | ||||
| @@ -6,8 +6,10 @@ namespace App\ViewModels; | |||||
| class HomeIndexViewModel | class HomeIndexViewModel | ||||
| { | { | ||||
| public string $title = ''; | |||||
| public string $eyebrow = ''; | |||||
| public string $message = ''; | |||||
| public string $routeExample = ''; | |||||
| public int $totalCampaignTypes = 0; | |||||
| public int $totalCampaigns = 0; | |||||
| public int $totalJobTypes = 0; | |||||
| public int $totalJobs = 0; | |||||
| public array $recentCampaigns = []; | |||||
| public array $campaignsByType = []; | |||||
| } | } | ||||
| @@ -29,7 +29,13 @@ | |||||
| <button class="button button-secondary" type="button" x-on:click="reloadTable()">Refresh</button> | <button class="button button-secondary" type="button" x-on:click="reloadTable()">Refresh</button> | ||||
| </div> | </div> | ||||
| <div class="inline-indicator" x-cloak x-show="isLoading">Loading campaigns...</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 class="alert alert-error" x-cloak x-show="errorMessage" x-text="errorMessage"></div> | ||||
| <div id="campaign-table" class="tabulator-host"></div> | <div id="campaign-table" class="tabulator-host"></div> | ||||
| </section> | </section> | ||||
| @@ -46,7 +52,11 @@ | |||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| <div class="inline-indicator" x-show="isJobsLoading">Loading jobs...</div> | |||||
| <div class="skeleton-rows" x-show="isJobsLoading"> | |||||
| <div class="skeleton-row"></div> | |||||
| <div class="skeleton-row"></div> | |||||
| <div class="skeleton-row"></div> | |||||
| </div> | |||||
| <div class="alert alert-error" x-show="jobsErrorMessage" x-text="jobsErrorMessage"></div> | <div class="alert alert-error" x-show="jobsErrorMessage" x-text="jobsErrorMessage"></div> | ||||
| <div id="campaign-jobs-drilldown-table" class="tabulator-host"></div> | <div id="campaign-jobs-drilldown-table" class="tabulator-host"></div> | ||||
| </section> | </section> | ||||
| @@ -1,39 +1,109 @@ | |||||
| <section class="hero"> | |||||
| <div class="hero-copy"> | |||||
| <span class="eyebrow"><?= e($model->eyebrow) ?></span> | |||||
| <h1><?= e($model->title) ?></h1> | |||||
| <p class="hero-text"><?= e($model->message) ?></p> | |||||
| <div class="hero-actions"> | |||||
| <a class="button button-primary" href="<?= e($model->routeExample) ?>">Open Campaign Types</a> | |||||
| <a class="button button-secondary" href="#framework-highlights">See Highlights</a> | |||||
| <?php | |||||
| $maxCount = 0; | |||||
| foreach ($model->campaignsByType as $row) { | |||||
| if ((int) $row['campaign_count'] > $maxCount) { | |||||
| $maxCount = (int) $row['campaign_count']; | |||||
| } | |||||
| } | |||||
| ?> | |||||
| <section class="content-stack"> | |||||
| <div class="page-toolbar"> | |||||
| <div class="section-heading"> | |||||
| <h1>Dashboard</h1> | |||||
| <p>Overview of your campaign tracking data.</p> | |||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| <aside class="hero-panel" aria-label="Framework route example"> | |||||
| <p class="panel-label">Request Flow</p> | |||||
| <code>Browser -> public/index.php -> Dispatcher -> Router -> Controller -> View</code> | |||||
| <div class="stats-grid stats-grid-4"> | |||||
| <a class="stat-card" href="/campaign-types"> | |||||
| <span>Campaign Types</span> | |||||
| <strong><?= e((string) $model->totalCampaignTypes) ?></strong> | |||||
| </a> | |||||
| <a class="stat-card" href="/campaigns"> | |||||
| <span>Campaigns</span> | |||||
| <strong><?= e((string) $model->totalCampaigns) ?></strong> | |||||
| </a> | |||||
| <a class="stat-card" href="/job-types"> | |||||
| <span>Job Types</span> | |||||
| <strong><?= e((string) $model->totalJobTypes) ?></strong> | |||||
| </a> | |||||
| <a class="stat-card" href="/jobs"> | |||||
| <span>Jobs</span> | |||||
| <strong><?= e((string) $model->totalJobs) ?></strong> | |||||
| </a> | |||||
| </div> | |||||
| <div class="route-callout"> | |||||
| <span>Campaign types</span> | |||||
| <a href="<?= e($model->routeExample) ?>"><?= e($model->routeExample) ?></a> | |||||
| </div> | |||||
| </aside> | |||||
| </section> | |||||
| <div class="dashboard-panels"> | |||||
| <section class="section-panel"> | |||||
| <div class="panel-header"> | |||||
| <div> | |||||
| <h2>Recent Campaigns</h2> | |||||
| <p>The 5 most recently created campaigns.</p> | |||||
| </div> | |||||
| <a class="button button-secondary button-sm" href="/campaigns">View All</a> | |||||
| </div> | |||||
| <?php if (empty($model->recentCampaigns)): ?> | |||||
| <div class="empty-state"> | |||||
| <p>No campaigns yet.</p> | |||||
| <p><a href="/campaigns/create">Create your first campaign</a></p> | |||||
| </div> | |||||
| <?php else: ?> | |||||
| <table class="dashboard-table"> | |||||
| <thead> | |||||
| <tr> | |||||
| <th>ID</th> | |||||
| <th>Type</th> | |||||
| <th>Created</th> | |||||
| <th></th> | |||||
| </tr> | |||||
| </thead> | |||||
| <tbody> | |||||
| <?php foreach ($model->recentCampaigns as $row): ?> | |||||
| <tr> | |||||
| <td class="dashboard-table-id">#<?= e((string) $row['id']) ?></td> | |||||
| <td><?= e($row['campaign_type_name']) ?></td> | |||||
| <td class="dashboard-table-date"><?= e(date('M j, Y', strtotime((string) $row['created_at']))) ?></td> | |||||
| <td class="dashboard-table-action"><a href="/campaigns/<?= e((string) $row['id']) ?>/edit">Edit</a></td> | |||||
| </tr> | |||||
| <?php endforeach; ?> | |||||
| </tbody> | |||||
| </table> | |||||
| <?php endif; ?> | |||||
| </section> | |||||
| <section class="section-panel"> | |||||
| <div class="panel-header"> | |||||
| <div> | |||||
| <h2>Campaigns by Type</h2> | |||||
| <p>Campaign count per campaign type.</p> | |||||
| </div> | |||||
| <a class="button button-secondary button-sm" href="/campaign-types">Manage Types</a> | |||||
| </div> | |||||
| <?php if (empty($model->campaignsByType)): ?> | |||||
| <div class="empty-state"> | |||||
| <p>No campaign types yet.</p> | |||||
| <p><a href="/campaign-types/create">Create your first type</a></p> | |||||
| </div> | |||||
| <?php else: ?> | |||||
| <div class="type-breakdown"> | |||||
| <?php foreach ($model->campaignsByType as $row): ?> | |||||
| <?php $pct = $maxCount > 0 ? round((int) $row['campaign_count'] / $maxCount * 100) : 0; ?> | |||||
| <a class="type-breakdown-row" href="/campaigns"> | |||||
| <span class="type-name"><?= e($row['campaign_type_name']) ?></span> | |||||
| <span class="type-bar-wrap"> | |||||
| <span class="type-bar" style="width: <?= e((string) $pct) ?>%"></span> | |||||
| </span> | |||||
| <span class="type-count"><?= e((string) $row['campaign_count']) ?></span> | |||||
| </a> | |||||
| <?php endforeach; ?> | |||||
| </div> | |||||
| <?php endif; ?> | |||||
| </section> | |||||
| </div> | |||||
| <section class="feature-grid" id="framework-highlights"> | |||||
| <article class="feature-card"> | |||||
| <h2>Readable by design</h2> | |||||
| <p>Small files, explicit routing, and plain PHP views keep the framework approachable for day-to-day work.</p> | |||||
| </article> | |||||
| <article class="feature-card"> | |||||
| <h2>Classic MVC feel</h2> | |||||
| <p>Controllers, repositories, and view models stay separate so request handling remains predictable and easy to follow.</p> | |||||
| </article> | |||||
| <article class="feature-card"> | |||||
| <h2>SQL Server ready</h2> | |||||
| <p>Typed PHP 8.3 code, Composer autoloading, PDO access, and migration support make the project feel current without becoming heavyweight.</p> | |||||
| </article> | |||||
| </section> | </section> | ||||
| @@ -20,6 +20,9 @@ $jsVersion = filemtime(__DIR__ . '/../../../public/js/app.js') ?: time(); | |||||
| <meta charset="UTF-8"> | <meta charset="UTF-8"> | ||||
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||||
| <title><?= e($pageTitle ?? 'Campaign Tracker') ?></title> | <title><?= e($pageTitle ?? 'Campaign Tracker') ?></title> | ||||
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |||||
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |||||
| <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600&family=IBM+Plex+Sans:wght@400;600&family=Public+Sans:wght@400;600;700;800&display=swap"> | |||||
| <link rel="stylesheet" href="https://unpkg.com/tabulator-tables@6.3.1/dist/css/tabulator.min.css"> | <link rel="stylesheet" href="https://unpkg.com/tabulator-tables@6.3.1/dist/css/tabulator.min.css"> | ||||
| <link rel="stylesheet" href="<?= e(asset('css/site.css')) ?>"> | <link rel="stylesheet" href="<?= e(asset('css/site.css')) ?>"> | ||||
| <script>window.__csrf = '<?= e(csrf_token()) ?>';</script> | <script>window.__csrf = '<?= e(csrf_token()) ?>';</script> | ||||
| @@ -41,22 +44,42 @@ $jsVersion = filemtime(__DIR__ . '/../../../public/js/app.js') ?: time(); | |||||
| </a> | </a> | ||||
| <nav class="site-nav" aria-label="Primary navigation"> | <nav class="site-nav" aria-label="Primary navigation"> | ||||
| <?php foreach ($navigationItems as $item): ?> | |||||
| <?php | |||||
| <?php | |||||
| $navIcons = [ | |||||
| '/' => '<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/></svg>', | |||||
| '/campaigns' => '<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2M9 12h6m-6 4h4"/></svg>', | |||||
| '/campaign-types' => '<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M7 7h.01M3 3h7.5L21 12l-9 9-10.5-10.5V3z"/></svg>', | |||||
| '/jobs' => '<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>', | |||||
| '/job-types' => '<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"/></svg>', | |||||
| ]; | |||||
| $prevHref = null; | |||||
| $group1 = ['/']; | |||||
| $group2 = ['/campaigns', '/campaign-types']; | |||||
| $group3 = ['/jobs', '/job-types']; | |||||
| foreach ($navigationItems as $item): | |||||
| $isActive = $item['href'] === '/' | $isActive = $item['href'] === '/' | ||||
| ? $currentPath === '/' | ? $currentPath === '/' | ||||
| : str_starts_with($currentPath, $item['href']); | : str_starts_with($currentPath, $item['href']); | ||||
| ?> | |||||
| $needsSep = ($prevHref !== null) && ( | |||||
| (in_array($prevHref, $group1) && in_array($item['href'], $group2)) || | |||||
| (in_array($prevHref, $group2) && in_array($item['href'], $group3)) | |||||
| ); | |||||
| if ($needsSep): ?> | |||||
| <span class="nav-sep" aria-hidden="true"></span> | |||||
| <?php endif; ?> | |||||
| <a class="nav-link<?= $isActive ? ' is-active' : '' ?>" href="<?= e($item['href']) ?>"> | <a class="nav-link<?= $isActive ? ' is-active' : '' ?>" href="<?= e($item['href']) ?>"> | ||||
| <?= $navIcons[$item['href']] ?? '' ?> | |||||
| <?= e($item['label']) ?> | <?= e($item['label']) ?> | ||||
| </a> | </a> | ||||
| <?php endforeach; ?> | |||||
| <?php $prevHref = $item['href']; | |||||
| endforeach; ?> | |||||
| <?php if (auth()->check()): ?> | <?php if (auth()->check()): ?> | ||||
| <span class="nav-sep" aria-hidden="true"></span> | |||||
| <span class="nav-user"><?= e(auth()->user()?->displayName ?: auth()->user()?->username ?? '') ?></span> | <span class="nav-user"><?= e(auth()->user()?->displayName ?: auth()->user()?->username ?? '') ?></span> | ||||
| <form method="post" action="/logout" class="nav-logout-form"> | <form method="post" action="/logout" class="nav-logout-form"> | ||||
| <?= csrf_field() ?> | <?= csrf_field() ?> | ||||
| <button type="submit" class="button button-secondary button-sm">Log out</button> | |||||
| <button type="submit" class="nav-logout-btn">Log out</button> | |||||
| </form> | </form> | ||||
| <?php endif; ?> | <?php endif; ?> | ||||
| </nav> | </nav> | ||||
| @@ -12,6 +12,8 @@ class Session | |||||
| return; | return; | ||||
| } | } | ||||
| session_save_path('/tmp'); | |||||
| $secure = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'; | $secure = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'; | ||||
| session_set_cookie_params([ | session_set_cookie_params([ | ||||
| @@ -43,6 +43,12 @@ class Response | |||||
| public function send(): void | public function send(): void | ||||
| { | { | ||||
| // Flush session to disk before sending headers so it is persisted before | |||||
| // the browser follows any redirect (critical for OAuth state survival). | |||||
| if (session_status() === PHP_SESSION_ACTIVE) { | |||||
| session_write_close(); | |||||
| } | |||||
| http_response_code($this->status); | http_response_code($this->status); | ||||
| foreach ($this->headers as $name => $value) { | foreach ($this->headers as $name => $value) { | ||||
| @@ -1,14 +1,14 @@ | |||||
| #Requires -Version 5.1 | #Requires -Version 5.1 | ||||
| <# | <# | ||||
| .SYNOPSIS | .SYNOPSIS | ||||
| Builds the Campaign Tracker Docker image and pushes it to the private registry. | |||||
| Copies .env to the server, then SSHes in to pull the repo and start the container. | |||||
| .EXAMPLE | .EXAMPLE | ||||
| .\docker-publish.ps1 | .\docker-publish.ps1 | ||||
| .\docker-publish.ps1 -SkipBuild # push a previously built image | |||||
| .\docker-publish.ps1 -SshKey "~/.ssh/id_rsa" | |||||
| #> | #> | ||||
| param( | param( | ||||
| [switch]$SkipBuild | |||||
| [string]$SshKey = "" | |||||
| ) | ) | ||||
| Set-StrictMode -Version Latest | Set-StrictMode -Version Latest | ||||
| @@ -17,9 +17,10 @@ $ErrorActionPreference = "Stop" | |||||
| # --------------------------------------------------------------------------- | # --------------------------------------------------------------------------- | ||||
| # Configuration | # Configuration | ||||
| # --------------------------------------------------------------------------- | # --------------------------------------------------------------------------- | ||||
| $REGISTRY_HOST = "192.168.1.200" # change port if needed, e.g. "192.168.1.200:5000" | |||||
| $IMAGE_NAME = "campaign-tracker" | |||||
| $FULL_TAG = "$REGISTRY_HOST/$IMAGE_NAME`:latest" | |||||
| $SSH_HOST = "192.168.1.200" | |||||
| $SSH_USER = "root" | |||||
| $REPO_PATH = "/root/campaign-tracker" | |||||
| $REPO_URL = "https://onefortheroadgit.sytes.net/dcovington/KCI-CAMPAIGN-TRACKER.git" | |||||
| # --------------------------------------------------------------------------- | # --------------------------------------------------------------------------- | ||||
| # Helpers | # Helpers | ||||
| @@ -28,26 +29,45 @@ function Write-Step([string]$msg) { | |||||
| Write-Host "`n==> $msg" -ForegroundColor Cyan | Write-Host "`n==> $msg" -ForegroundColor Cyan | ||||
| } | } | ||||
| function Assert-DockerRunning { | |||||
| docker info > $null 2>&1 | |||||
| if ($LASTEXITCODE -ne 0) { | |||||
| Write-Error "Docker is not running or not reachable. Start Docker Desktop and retry." | |||||
| } | |||||
| function Get-BaseArgs { | |||||
| $a = @("-o", "StrictHostKeyChecking=accept-new") | |||||
| if ($SshKey -ne "") { $a += @("-i", $SshKey) } | |||||
| return $a | |||||
| } | } | ||||
| # --------------------------------------------------------------------------- | # --------------------------------------------------------------------------- | ||||
| # Main | |||||
| # Step 1 — copy .env (first password prompt) | |||||
| # --------------------------------------------------------------------------- | # --------------------------------------------------------------------------- | ||||
| Assert-DockerRunning | |||||
| Write-Step "Copying .env to $SSH_USER@$SSH_HOST" | |||||
| $scpArgs = Get-BaseArgs | |||||
| $scpArgs += ".env", "${SSH_USER}@${SSH_HOST}:${REPO_PATH}/.env" | |||||
| scp @scpArgs | |||||
| if ($LASTEXITCODE -ne 0) { Write-Error "scp failed (exit $LASTEXITCODE)." } | |||||
| if (-not $SkipBuild) { | |||||
| Write-Step "Building image: $FULL_TAG" | |||||
| docker build --tag $FULL_TAG . | |||||
| if ($LASTEXITCODE -ne 0) { Write-Error "docker build failed (exit $LASTEXITCODE)." } | |||||
| } | |||||
| # --------------------------------------------------------------------------- | |||||
| # Step 2 — deploy (second password prompt) | |||||
| # --------------------------------------------------------------------------- | |||||
| Write-Step "Deploying on $SSH_USER@$SSH_HOST" | |||||
| # Deploy whichever branch is currently checked out locally. | |||||
| $BRANCH = (git rev-parse --abbrev-ref HEAD).Trim() | |||||
| Write-Host "Branch: $BRANCH" -ForegroundColor Yellow | |||||
| # Build as a single semicolon-separated string — no newlines, no CRLF risk. | |||||
| $remoteCmd = "set -e; " | |||||
| $remoteCmd += "if [ -d '$REPO_PATH/.git' ]; then " | |||||
| $remoteCmd += "cd '$REPO_PATH' && git fetch origin && git checkout $BRANCH && git pull origin $BRANCH; " | |||||
| $remoteCmd += "else " | |||||
| $remoteCmd += "mkdir -p '$REPO_PATH' && git clone --branch $BRANCH '$REPO_URL' '$REPO_PATH'; " | |||||
| $remoteCmd += "fi; " | |||||
| $remoteCmd += "docker ps -aq | xargs -r docker rm -f; " | |||||
| $remoteCmd += "docker run --rm -v '$REPO_PATH':/app -w /app composer:latest install --no-dev --optimize-autoloader --no-interaction; " | |||||
| $remoteCmd += "cd '$REPO_PATH' && docker compose up -d --wait; " | |||||
| $remoteCmd += "docker exec campaign-tracker-app php scripts/migrate.php up" | |||||
| Write-Step "Pushing image: $FULL_TAG" | |||||
| docker push $FULL_TAG | |||||
| if ($LASTEXITCODE -ne 0) { Write-Error "docker push failed (exit $LASTEXITCODE)." } | |||||
| $sshArgs = Get-BaseArgs | |||||
| $sshArgs += "$SSH_USER@$SSH_HOST", $remoteCmd | |||||
| ssh @sshArgs | |||||
| if ($LASTEXITCODE -ne 0) { Write-Error "Deployment failed (exit $LASTEXITCODE)." } | |||||
| Write-Host "`nDone. Image available at $FULL_TAG" -ForegroundColor Green | |||||
| Write-Host "`nDone. Campaign Tracker is running on $SSH_HOST." -ForegroundColor Green | |||||
| @@ -1,5 +1,9 @@ | |||||
| // ── Shared util ─────────────────────────────────────────────────────────────── | // ── Shared util ─────────────────────────────────────────────────────────────── | ||||
| const PAGE_SIZES = [10, 25, 50, 100]; | |||||
| const PAGE_SIZES_SM = [5, 10, 25, 50]; | |||||
| function _postDelete(action) { | function _postDelete(action) { | ||||
| const form = document.createElement('form'); | const form = document.createElement('form'); | ||||
| form.method = 'POST'; | form.method = 'POST'; | ||||
| @@ -50,10 +54,23 @@ window.campaignTypeTable = function () { | |||||
| pagination: true, | pagination: true, | ||||
| paginationMode: 'local', | paginationMode: 'local', | ||||
| paginationSize: 10, | paginationSize: 10, | ||||
| paginationSizeSelector: PAGE_SIZES, | |||||
| movableColumns: true, | movableColumns: true, | ||||
| placeholder: 'No campaign types found.', | placeholder: 'No campaign types found.', | ||||
| initialSort: [{ column: 'name', dir: 'asc' }], | initialSort: [{ column: 'name', dir: 'asc' }], | ||||
| columns: [ | columns: [ | ||||
| { | |||||
| title: 'Actions', | |||||
| field: 'id', | |||||
| width: 160, | |||||
| hozAlign: 'center', | |||||
| headerSort: false, | |||||
| formatter: function (cell) { | |||||
| const id = cell.getValue(); | |||||
| return '<a href="/campaign-types/' + id + '/edit" class="button button-secondary button-sm">Edit</a> ' + | |||||
| '<button onclick="window.deleteCampaignType(' + id + ')" class="button button-danger button-sm">Delete</button>'; | |||||
| }, | |||||
| }, | |||||
| { title: 'Name', field: 'name', minWidth: 200 }, | { title: 'Name', field: 'name', minWidth: 200 }, | ||||
| { | { | ||||
| title: 'Attributes', | title: 'Attributes', | ||||
| @@ -67,18 +84,6 @@ window.campaignTypeTable = function () { | |||||
| }, | }, | ||||
| { title: 'Count', field: 'attribute_count', hozAlign: 'center', width: 80 }, | { title: 'Count', field: 'attribute_count', hozAlign: 'center', width: 80 }, | ||||
| { title: 'Created', field: 'created_at', minWidth: 160 }, | { title: 'Created', field: 'created_at', minWidth: 160 }, | ||||
| { | |||||
| title: 'Actions', | |||||
| field: 'id', | |||||
| width: 160, | |||||
| hozAlign: 'center', | |||||
| headerSort: false, | |||||
| formatter: function (cell) { | |||||
| const id = cell.getValue(); | |||||
| return '<a href="/campaign-types/' + id + '/edit" class="button button-secondary button-sm">Edit</a> ' + | |||||
| '<button onclick="window.deleteCampaignType(' + id + ')" class="button button-danger button-sm">Delete</button>'; | |||||
| }, | |||||
| }, | |||||
| ], | ], | ||||
| }); | }); | ||||
| }, | }, | ||||
| @@ -200,6 +205,7 @@ window.campaignTable = function () { | |||||
| pagination: true, | pagination: true, | ||||
| paginationMode: 'local', | paginationMode: 'local', | ||||
| paginationSize: 10, | paginationSize: 10, | ||||
| paginationSizeSelector: PAGE_SIZES, | |||||
| movableColumns: true, | movableColumns: true, | ||||
| placeholder: 'No campaigns found.', | placeholder: 'No campaigns found.', | ||||
| initialSort: [{ column: 'campaign_type_name', dir: 'asc' }], | initialSort: [{ column: 'campaign_type_name', dir: 'asc' }], | ||||
| @@ -275,6 +281,19 @@ window.campaignTable = function () { | |||||
| columnsForAttributes(attributes) { | columnsForAttributes(attributes) { | ||||
| const columns = [ | const columns = [ | ||||
| { | |||||
| title: 'Actions', | |||||
| field: 'id', | |||||
| width: 230, | |||||
| hozAlign: 'center', | |||||
| headerSort: false, | |||||
| formatter: function (cell) { | |||||
| const id = cell.getValue(); | |||||
| return '<a href="/campaigns/' + id + '/jobs" class="button button-primary button-sm">Jobs</a> ' + | |||||
| '<a href="/campaigns/' + id + '/edit" class="button button-secondary button-sm">Edit</a> ' + | |||||
| '<button onclick="window.deleteCampaign(' + id + ')" class="button button-danger button-sm">Delete</button>'; | |||||
| }, | |||||
| }, | |||||
| { | { | ||||
| title: 'Campaign Type', | title: 'Campaign Type', | ||||
| field: 'campaign_type_name', | field: 'campaign_type_name', | ||||
| @@ -297,20 +316,7 @@ window.campaignTable = function () { | |||||
| }); | }); | ||||
| columns.push( | columns.push( | ||||
| { title: 'Created', field: 'created_at', minWidth: 160, headerFilter: 'input' }, | |||||
| { | |||||
| title: 'Actions', | |||||
| field: 'id', | |||||
| width: 230, | |||||
| hozAlign: 'center', | |||||
| headerSort: false, | |||||
| formatter: function (cell) { | |||||
| const id = cell.getValue(); | |||||
| return '<a href="/campaigns/' + id + '/jobs" class="button button-primary button-sm">Jobs</a> ' + | |||||
| '<a href="/campaigns/' + id + '/edit" class="button button-secondary button-sm">Edit</a> ' + | |||||
| '<button onclick="window.deleteCampaign(' + id + ')" class="button button-danger button-sm">Delete</button>'; | |||||
| }, | |||||
| } | |||||
| { title: 'Created', field: 'created_at', minWidth: 160, headerFilter: 'input' } | |||||
| ); | ); | ||||
| return columns; | return columns; | ||||
| @@ -387,6 +393,7 @@ window.campaignTable = function () { | |||||
| pagination: true, | pagination: true, | ||||
| paginationMode: 'local', | paginationMode: 'local', | ||||
| paginationSize: 10, | paginationSize: 10, | ||||
| paginationSizeSelector: PAGE_SIZES, | |||||
| movableColumns: true, | movableColumns: true, | ||||
| placeholder: 'No jobs found for this campaign.', | placeholder: 'No jobs found for this campaign.', | ||||
| initialSort: [{ column: 'job_type_name', dir: 'asc' }], | initialSort: [{ column: 'job_type_name', dir: 'asc' }], | ||||
| @@ -446,6 +453,16 @@ window.campaignTable = function () { | |||||
| jobColumnsForAttributes(attributes) { | jobColumnsForAttributes(attributes) { | ||||
| const columns = [ | const columns = [ | ||||
| { | |||||
| title: 'Actions', | |||||
| field: 'edit_url', | |||||
| width: 90, | |||||
| hozAlign: 'center', | |||||
| headerSort: false, | |||||
| formatter: function (cell) { | |||||
| return '<a href="' + _escapeHtml(cell.getValue()) + '" class="button button-secondary button-sm">Edit</a>'; | |||||
| }, | |||||
| }, | |||||
| { title: 'Job ID', field: 'id', width: 90, hozAlign: 'center', headerFilter: 'input' }, | { title: 'Job ID', field: 'id', width: 90, hozAlign: 'center', headerFilter: 'input' }, | ||||
| { title: 'Campaign ID', field: 'campaign_id', width: 120, hozAlign: 'center', headerFilter: 'input' }, | { title: 'Campaign ID', field: 'campaign_id', width: 120, hozAlign: 'center', headerFilter: 'input' }, | ||||
| { title: 'Job Type ID', field: 'job_type_id', width: 120, hozAlign: 'center', headerFilter: 'input' }, | { title: 'Job Type ID', field: 'job_type_id', width: 120, hozAlign: 'center', headerFilter: 'input' }, | ||||
| @@ -467,17 +484,7 @@ window.campaignTable = function () { | |||||
| columns.push( | columns.push( | ||||
| { title: 'Created', field: 'created_at', minWidth: 160, headerFilter: 'input' }, | { title: 'Created', field: 'created_at', minWidth: 160, headerFilter: 'input' }, | ||||
| { title: 'Updated', field: 'updated_at', minWidth: 160, headerFilter: 'input' }, | |||||
| { | |||||
| title: 'Actions', | |||||
| field: 'edit_url', | |||||
| width: 90, | |||||
| hozAlign: 'center', | |||||
| headerSort: false, | |||||
| formatter: function (cell) { | |||||
| return '<a href="' + _escapeHtml(cell.getValue()) + '" class="button button-secondary button-sm">Edit</a>'; | |||||
| }, | |||||
| } | |||||
| { title: 'Updated', field: 'updated_at', minWidth: 160, headerFilter: 'input' } | |||||
| ); | ); | ||||
| return columns; | return columns; | ||||
| @@ -565,6 +572,7 @@ window.campaignJobsPageTable = function (campaignId, jobTypes) { | |||||
| pagination: true, | pagination: true, | ||||
| paginationMode: 'local', | paginationMode: 'local', | ||||
| paginationSize: 10, | paginationSize: 10, | ||||
| paginationSizeSelector: PAGE_SIZES, | |||||
| movableColumns: true, | movableColumns: true, | ||||
| placeholder: 'No jobs found for this campaign.', | placeholder: 'No jobs found for this campaign.', | ||||
| initialSort: [{ column: 'job_type_name', dir: 'asc' }], | initialSort: [{ column: 'job_type_name', dir: 'asc' }], | ||||
| @@ -644,6 +652,16 @@ window.campaignJobsPageTable = function (campaignId, jobTypes) { | |||||
| columnsForAttributes(attributes) { | columnsForAttributes(attributes) { | ||||
| const columns = [ | const columns = [ | ||||
| { | |||||
| title: 'Actions', | |||||
| field: 'edit_url', | |||||
| width: 90, | |||||
| hozAlign: 'center', | |||||
| headerSort: false, | |||||
| formatter: function (cell) { | |||||
| return '<a href="' + _escapeHtml(cell.getValue()) + '" class="button button-secondary button-sm">Edit</a>'; | |||||
| }, | |||||
| }, | |||||
| { title: 'Job ID', field: 'id', width: 90, hozAlign: 'center', headerFilter: 'input' }, | { title: 'Job ID', field: 'id', width: 90, hozAlign: 'center', headerFilter: 'input' }, | ||||
| { title: 'Campaign ID', field: 'campaign_id', width: 120, hozAlign: 'center', headerFilter: 'input' }, | { title: 'Campaign ID', field: 'campaign_id', width: 120, hozAlign: 'center', headerFilter: 'input' }, | ||||
| { title: 'Campaign Type', field: 'campaign_type_name', minWidth: 160, headerFilter: 'input' }, | { title: 'Campaign Type', field: 'campaign_type_name', minWidth: 160, headerFilter: 'input' }, | ||||
| @@ -666,17 +684,7 @@ window.campaignJobsPageTable = function (campaignId, jobTypes) { | |||||
| columns.push( | columns.push( | ||||
| { title: 'Created', field: 'created_at', minWidth: 160, headerFilter: 'input' }, | { title: 'Created', field: 'created_at', minWidth: 160, headerFilter: 'input' }, | ||||
| { title: 'Updated', field: 'updated_at', minWidth: 160, headerFilter: 'input' }, | |||||
| { | |||||
| title: 'Actions', | |||||
| field: 'edit_url', | |||||
| width: 90, | |||||
| hozAlign: 'center', | |||||
| headerSort: false, | |||||
| formatter: function (cell) { | |||||
| return '<a href="' + _escapeHtml(cell.getValue()) + '" class="button button-secondary button-sm">Edit</a>'; | |||||
| }, | |||||
| } | |||||
| { title: 'Updated', field: 'updated_at', minWidth: 160, headerFilter: 'input' } | |||||
| ); | ); | ||||
| return columns; | return columns; | ||||
| @@ -1020,6 +1028,7 @@ window.campaignJobsTable = function (campaignId) { | |||||
| pagination: true, | pagination: true, | ||||
| paginationMode: 'local', | paginationMode: 'local', | ||||
| paginationSize: 5, | paginationSize: 5, | ||||
| paginationSizeSelector: PAGE_SIZES_SM, | |||||
| movableColumns: true, | movableColumns: true, | ||||
| placeholder: 'No jobs found for this job type.', | placeholder: 'No jobs found for this job type.', | ||||
| initialSort: [{ column: 'created_at', dir: 'desc' }], | initialSort: [{ column: 'created_at', dir: 'desc' }], | ||||
| @@ -1029,7 +1038,18 @@ window.campaignJobsTable = function (campaignId) { | |||||
| }, | }, | ||||
| columnsForGroup(group) { | columnsForGroup(group) { | ||||
| const columns = group.attributes.map((attr, index) => ({ | |||||
| const actions = { | |||||
| title: 'Actions', | |||||
| field: 'edit_url', | |||||
| width: 90, | |||||
| hozAlign: 'center', | |||||
| headerSort: false, | |||||
| formatter: function (cell) { | |||||
| return '<a href="' + _escapeHtml(cell.getValue()) + '" class="button button-secondary button-sm">Edit</a>'; | |||||
| }, | |||||
| }; | |||||
| const attrColumns = group.attributes.map((attr, index) => ({ | |||||
| title: attr.name, | title: attr.name, | ||||
| field: 'attr_' + index, | field: 'attr_' + index, | ||||
| minWidth: 150, | minWidth: 150, | ||||
| @@ -1039,25 +1059,11 @@ window.campaignJobsTable = function (campaignId) { | |||||
| }, | }, | ||||
| })); | })); | ||||
| if (columns.length === 0) { | |||||
| columns.push({ title: 'Job ID', field: 'id', width: 90, hozAlign: 'center' }); | |||||
| if (attrColumns.length === 0) { | |||||
| attrColumns.push({ title: 'Job ID', field: 'id', width: 90, hozAlign: 'center' }); | |||||
| } | } | ||||
| columns.push( | |||||
| { title: 'Created', field: 'created_at', minWidth: 160 }, | |||||
| { | |||||
| title: 'Actions', | |||||
| field: 'edit_url', | |||||
| width: 90, | |||||
| hozAlign: 'center', | |||||
| headerSort: false, | |||||
| formatter: function (cell) { | |||||
| return '<a href="' + _escapeHtml(cell.getValue()) + '" class="button button-secondary button-sm">Edit</a>'; | |||||
| }, | |||||
| } | |||||
| ); | |||||
| return columns; | |||||
| return [actions, ...attrColumns, { title: 'Created', field: 'created_at', minWidth: 160 }]; | |||||
| }, | }, | ||||
| destroyTables() { | destroyTables() { | ||||
| @@ -1094,10 +1100,23 @@ window.jobTypeTable = function () { | |||||
| pagination: true, | pagination: true, | ||||
| paginationMode: 'local', | paginationMode: 'local', | ||||
| paginationSize: 10, | paginationSize: 10, | ||||
| paginationSizeSelector: PAGE_SIZES, | |||||
| movableColumns: true, | movableColumns: true, | ||||
| placeholder: 'No job types found.', | placeholder: 'No job types found.', | ||||
| initialSort: [{ column: 'name', dir: 'asc' }], | initialSort: [{ column: 'name', dir: 'asc' }], | ||||
| columns: [ | columns: [ | ||||
| { | |||||
| title: 'Actions', | |||||
| field: 'id', | |||||
| width: 160, | |||||
| hozAlign: 'center', | |||||
| headerSort: false, | |||||
| formatter: function (cell) { | |||||
| const id = cell.getValue(); | |||||
| return '<a href="/job-types/' + id + '/edit" class="button button-secondary button-sm">Edit</a> ' + | |||||
| '<button onclick="window.deleteJobType(' + id + ')" class="button button-danger button-sm">Delete</button>'; | |||||
| }, | |||||
| }, | |||||
| { title: 'Name', field: 'name', minWidth: 200 }, | { title: 'Name', field: 'name', minWidth: 200 }, | ||||
| { | { | ||||
| title: 'Attributes', | title: 'Attributes', | ||||
| @@ -1111,18 +1130,6 @@ window.jobTypeTable = function () { | |||||
| }, | }, | ||||
| { title: 'Count', field: 'attribute_count', hozAlign: 'center', width: 80 }, | { title: 'Count', field: 'attribute_count', hozAlign: 'center', width: 80 }, | ||||
| { title: 'Created', field: 'created_at', minWidth: 160 }, | { title: 'Created', field: 'created_at', minWidth: 160 }, | ||||
| { | |||||
| title: 'Actions', | |||||
| field: 'id', | |||||
| width: 160, | |||||
| hozAlign: 'center', | |||||
| headerSort: false, | |||||
| formatter: function (cell) { | |||||
| const id = cell.getValue(); | |||||
| return '<a href="/job-types/' + id + '/edit" class="button button-secondary button-sm">Edit</a> ' + | |||||
| '<button onclick="window.deleteJobType(' + id + ')" class="button button-danger button-sm">Delete</button>'; | |||||
| }, | |||||
| }, | |||||
| ], | ], | ||||
| }); | }); | ||||
| }, | }, | ||||
| @@ -1218,10 +1225,23 @@ window.jobTable = function () { | |||||
| pagination: true, | pagination: true, | ||||
| paginationMode: 'local', | paginationMode: 'local', | ||||
| paginationSize: 10, | paginationSize: 10, | ||||
| paginationSizeSelector: PAGE_SIZES, | |||||
| movableColumns: true, | movableColumns: true, | ||||
| placeholder: 'No jobs found.', | placeholder: 'No jobs found.', | ||||
| initialSort: [{ column: 'job_type_name', dir: 'asc' }], | initialSort: [{ column: 'job_type_name', dir: 'asc' }], | ||||
| columns: [ | 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>'; | |||||
| }, | |||||
| }, | |||||
| { title: 'Campaign', field: 'campaign_type_name', minWidth: 160 }, | { title: 'Campaign', field: 'campaign_type_name', minWidth: 160 }, | ||||
| { title: 'Job Type', field: 'job_type_name', minWidth: 160 }, | { title: 'Job Type', field: 'job_type_name', minWidth: 160 }, | ||||
| { | { | ||||
| @@ -1235,18 +1255,6 @@ window.jobTable = function () { | |||||
| }, | }, | ||||
| }, | }, | ||||
| { title: 'Created', field: 'created_at', minWidth: 160 }, | { title: 'Created', field: 'created_at', minWidth: 160 }, | ||||
| { | |||||
| 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>'; | |||||
| }, | |||||
| }, | |||||
| ], | ], | ||||
| }); | }); | ||||
| }, | }, | ||||
Powered by TurnKey Linux.