GUI-UI-change nach main vor 1 Tag zusammengeführt
| @@ -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 -l \"d:\\\\Development\\\\PHP\\\\Campaign-Tracker\\\\core\\\\Database.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_DATABASE=Campaign_Tracker | |||
| DB_USERNAME=sa | |||
| DB_PASSWORD=Dev_Password123! | |||
| DB_PASSWORD= | |||
| # ── 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_BASE_URL= | |||
| KEYCLOAK_REALM= | |||
| KEYCLOAK_CLIENT_ID= | |||
| KEYCLOAK_CLIENT_SECRET= | |||
| KEYCLOAK_REDIRECT_URI=http://localhost:8801/auth/callback | |||
| KEYCLOAK_LOGOUT_REDIRECT_URI=http://localhost:8801/login | |||
| @@ -997,5 +997,318 @@ Visual rhythm: | |||
| - Dense-data readability: | |||
| - 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; | |||
| use App\Repositories\CampaignRepository; | |||
| use App\Repositories\CampaignTypeRepository; | |||
| use App\Repositories\JobRepository; | |||
| use App\Repositories\JobTypeRepository; | |||
| use App\ViewModels\HomeIndexViewModel; | |||
| use Core\Controller; | |||
| @@ -11,15 +15,19 @@ class HomeController extends Controller | |||
| { | |||
| public function index() | |||
| { | |||
| $db = database(); | |||
| $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', [ | |||
| 'model' => $model, | |||
| 'pageTitle' => $model->title, | |||
| 'pageTitle' => 'Dashboard', | |||
| ]); | |||
| } | |||
| @@ -12,6 +12,35 @@ class CampaignRepository extends Repository | |||
| protected string $table = 'campaign'; | |||
| 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. | |||
| * | |||
| @@ -14,6 +14,12 @@ class CampaignTypeRepository extends Repository | |||
| /** | |||
| * @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 | |||
| { | |||
| return $this->database->query( | |||
| @@ -12,6 +12,12 @@ class JobRepository extends Repository | |||
| protected string $table = 'job'; | |||
| 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>> */ | |||
| public function allWithDetails(): array | |||
| { | |||
| @@ -11,6 +11,12 @@ class JobTypeRepository extends Repository | |||
| { | |||
| 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>> */ | |||
| public function allOrderedByName(): array | |||
| { | |||
| @@ -6,8 +6,10 @@ namespace App\ViewModels; | |||
| 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> | |||
| </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 id="campaign-table" class="tabulator-host"></div> | |||
| </section> | |||
| @@ -46,7 +52,11 @@ | |||
| </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 id="campaign-jobs-drilldown-table" class="tabulator-host"></div> | |||
| </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> | |||
| <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> | |||
| @@ -20,6 +20,9 @@ $jsVersion = filemtime(__DIR__ . '/../../../public/js/app.js') ?: time(); | |||
| <meta charset="UTF-8"> | |||
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |||
| <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="<?= e(asset('css/site.css')) ?>"> | |||
| <script>window.__csrf = '<?= e(csrf_token()) ?>';</script> | |||
| @@ -41,22 +44,42 @@ $jsVersion = filemtime(__DIR__ . '/../../../public/js/app.js') ?: time(); | |||
| </a> | |||
| <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'] === '/' | |||
| ? $currentPath === '/' | |||
| : str_starts_with($currentPath, $item['href']); | |||
| ?> | |||
| $needsSep = ($prevHref !== null) && ( | |||
| (in_array($prevHref, $group1) && in_array($item['href'], $group2)) || | |||
| (in_array($prevHref, $group2) && in_array($item['href'], $group3)) | |||
| ); | |||
| if ($needsSep): ?> | |||
| <span class="nav-sep" aria-hidden="true"></span> | |||
| <?php endif; ?> | |||
| <a class="nav-link<?= $isActive ? ' is-active' : '' ?>" href="<?= e($item['href']) ?>"> | |||
| <?= $navIcons[$item['href']] ?? '' ?> | |||
| <?= e($item['label']) ?> | |||
| </a> | |||
| <?php endforeach; ?> | |||
| <?php $prevHref = $item['href']; | |||
| endforeach; ?> | |||
| <?php if (auth()->check()): ?> | |||
| <span class="nav-sep" aria-hidden="true"></span> | |||
| <span class="nav-user"><?= e(auth()->user()?->displayName ?: auth()->user()?->username ?? '') ?></span> | |||
| <form method="post" action="/logout" class="nav-logout-form"> | |||
| <?= 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> | |||
| <?php endif; ?> | |||
| </nav> | |||
| @@ -12,6 +12,8 @@ class Session | |||
| return; | |||
| } | |||
| session_save_path('/tmp'); | |||
| $secure = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'; | |||
| session_set_cookie_params([ | |||
| @@ -43,6 +43,12 @@ class Response | |||
| 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); | |||
| foreach ($this->headers as $name => $value) { | |||
| @@ -1,14 +1,14 @@ | |||
| #Requires -Version 5.1 | |||
| <# | |||
| .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 | |||
| .\docker-publish.ps1 | |||
| .\docker-publish.ps1 -SkipBuild # push a previously built image | |||
| .\docker-publish.ps1 -SshKey "~/.ssh/id_rsa" | |||
| #> | |||
| param( | |||
| [switch]$SkipBuild | |||
| [string]$SshKey = "" | |||
| ) | |||
| Set-StrictMode -Version Latest | |||
| @@ -17,9 +17,10 @@ $ErrorActionPreference = "Stop" | |||
| # --------------------------------------------------------------------------- | |||
| # 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 | |||
| @@ -28,26 +29,45 @@ function Write-Step([string]$msg) { | |||
| 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 ─────────────────────────────────────────────────────────────── | |||
| const PAGE_SIZES = [10, 25, 50, 100]; | |||
| const PAGE_SIZES_SM = [5, 10, 25, 50]; | |||
| function _postDelete(action) { | |||
| const form = document.createElement('form'); | |||
| form.method = 'POST'; | |||
| @@ -50,10 +54,23 @@ window.campaignTypeTable = function () { | |||
| pagination: true, | |||
| paginationMode: 'local', | |||
| paginationSize: 10, | |||
| paginationSizeSelector: PAGE_SIZES, | |||
| movableColumns: true, | |||
| placeholder: 'No campaign types found.', | |||
| initialSort: [{ column: 'name', dir: 'asc' }], | |||
| columns: [ | |||
| { | |||
| title: 'Actions', | |||
| field: 'id', | |||
| width: 160, | |||
| hozAlign: 'center', | |||
| headerSort: false, | |||
| formatter: function (cell) { | |||
| const id = cell.getValue(); | |||
| return '<a href="/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: 'Attributes', | |||
| @@ -67,18 +84,6 @@ window.campaignTypeTable = function () { | |||
| }, | |||
| { title: 'Count', field: 'attribute_count', hozAlign: 'center', width: 80 }, | |||
| { 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, | |||
| paginationMode: 'local', | |||
| paginationSize: 10, | |||
| paginationSizeSelector: PAGE_SIZES, | |||
| movableColumns: true, | |||
| placeholder: 'No campaigns found.', | |||
| initialSort: [{ column: 'campaign_type_name', dir: 'asc' }], | |||
| @@ -275,6 +281,19 @@ window.campaignTable = function () { | |||
| columnsForAttributes(attributes) { | |||
| 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', | |||
| field: 'campaign_type_name', | |||
| @@ -297,20 +316,7 @@ window.campaignTable = function () { | |||
| }); | |||
| 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; | |||
| @@ -387,6 +393,7 @@ window.campaignTable = function () { | |||
| pagination: true, | |||
| paginationMode: 'local', | |||
| paginationSize: 10, | |||
| paginationSizeSelector: PAGE_SIZES, | |||
| movableColumns: true, | |||
| placeholder: 'No jobs found for this campaign.', | |||
| initialSort: [{ column: 'job_type_name', dir: 'asc' }], | |||
| @@ -446,6 +453,16 @@ window.campaignTable = function () { | |||
| jobColumnsForAttributes(attributes) { | |||
| 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: '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' }, | |||
| @@ -467,17 +484,7 @@ window.campaignTable = function () { | |||
| columns.push( | |||
| { 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; | |||
| @@ -565,6 +572,7 @@ window.campaignJobsPageTable = function (campaignId, jobTypes) { | |||
| pagination: true, | |||
| paginationMode: 'local', | |||
| paginationSize: 10, | |||
| paginationSizeSelector: PAGE_SIZES, | |||
| movableColumns: true, | |||
| placeholder: 'No jobs found for this campaign.', | |||
| initialSort: [{ column: 'job_type_name', dir: 'asc' }], | |||
| @@ -644,6 +652,16 @@ window.campaignJobsPageTable = function (campaignId, jobTypes) { | |||
| columnsForAttributes(attributes) { | |||
| 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: 'Campaign ID', field: 'campaign_id', width: 120, hozAlign: 'center', headerFilter: 'input' }, | |||
| { title: 'Campaign Type', field: 'campaign_type_name', minWidth: 160, headerFilter: 'input' }, | |||
| @@ -666,17 +684,7 @@ window.campaignJobsPageTable = function (campaignId, jobTypes) { | |||
| columns.push( | |||
| { 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; | |||
| @@ -1020,6 +1028,7 @@ window.campaignJobsTable = function (campaignId) { | |||
| pagination: true, | |||
| paginationMode: 'local', | |||
| paginationSize: 5, | |||
| paginationSizeSelector: PAGE_SIZES_SM, | |||
| movableColumns: true, | |||
| placeholder: 'No jobs found for this job type.', | |||
| initialSort: [{ column: 'created_at', dir: 'desc' }], | |||
| @@ -1029,7 +1038,18 @@ window.campaignJobsTable = function (campaignId) { | |||
| }, | |||
| 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, | |||
| field: 'attr_' + index, | |||
| 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() { | |||
| @@ -1094,10 +1100,23 @@ window.jobTypeTable = function () { | |||
| pagination: true, | |||
| paginationMode: 'local', | |||
| paginationSize: 10, | |||
| paginationSizeSelector: PAGE_SIZES, | |||
| movableColumns: true, | |||
| placeholder: 'No job types found.', | |||
| initialSort: [{ column: 'name', dir: 'asc' }], | |||
| columns: [ | |||
| { | |||
| title: 'Actions', | |||
| field: 'id', | |||
| width: 160, | |||
| hozAlign: 'center', | |||
| headerSort: false, | |||
| formatter: function (cell) { | |||
| const id = cell.getValue(); | |||
| return '<a href="/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: 'Attributes', | |||
| @@ -1111,18 +1130,6 @@ window.jobTypeTable = function () { | |||
| }, | |||
| { title: 'Count', field: 'attribute_count', hozAlign: 'center', width: 80 }, | |||
| { 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, | |||
| 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>'; | |||
| }, | |||
| }, | |||
| { title: 'Campaign', field: 'campaign_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: '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.