diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 0f87d20..494e663 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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)" ] } } diff --git a/.env.example b/.env.example index 7d2bd1c..1f72a6f 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 6a2d1a2..3f6550b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 \ No newline at end of file +# 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 `` 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 + + + Campaigns + +``` + +**Group separators** +Nav links are divided into logical groups separated by ``: +- 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 +
+
...
+
...
+
+``` + +**Page toolbar** (`.page-toolbar`) always contains: +- A `.section-heading` div with an `

` and optional `

` description on the left. +- A primary action button (or back link) on the right. + +```html +

+
+

Page Title

+

One-line description of what this page manages.

+
+ + New Resource +
+``` + +**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 `
`. 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 `
` with `

` + optional `

`, and a right side for action buttons. +- Panel `

` is 18px. Do not make it larger than the page `

`. + +```html +
+
+
+

Section Title

+

Brief description of this section's content.

+
+ +
+ + +
+``` + +--- + +### Stat Cards (Dashboard Metrics) + +Use `.stat-card` for any numeric summary. Rules: + +- When a stat card navigates somewhere, use `` — not a `
`. The CSS `a.stat-card` handles hover lift and the "View all →" affordance automatically. +- When a stat card is purely informational (no destination), use `
`. +- 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 + +``` + +--- + +### 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 ``. 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 +
+ + +
+ +
+ +
+
+``` + +**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 `*` inside the label. Color is `var(--error)` via CSS. + +**Error messages** +Server-side validation errors go in `` 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 ``. + 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 +
+
+
+
+
+
+
+``` + +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 `` elements, not `
` 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. \ No newline at end of file diff --git a/app/Controllers/HomeController.php b/app/Controllers/HomeController.php index 4ef3ab8..12efc7e 100644 --- a/app/Controllers/HomeController.php +++ b/app/Controllers/HomeController.php @@ -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', ]); } diff --git a/app/Repositories/CampaignRepository.php b/app/Repositories/CampaignRepository.php index 5842dfe..8a0db99 100644 --- a/app/Repositories/CampaignRepository.php +++ b/app/Repositories/CampaignRepository.php @@ -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> */ + 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> */ + 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. * diff --git a/app/Repositories/CampaignTypeRepository.php b/app/Repositories/CampaignTypeRepository.php index f8bbc61..0e58a10 100644 --- a/app/Repositories/CampaignTypeRepository.php +++ b/app/Repositories/CampaignTypeRepository.php @@ -14,6 +14,12 @@ class CampaignTypeRepository extends Repository /** * @return list> */ + 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( diff --git a/app/Repositories/JobRepository.php b/app/Repositories/JobRepository.php index d36a9a6..9482e21 100644 --- a/app/Repositories/JobRepository.php +++ b/app/Repositories/JobRepository.php @@ -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> */ public function allWithDetails(): array { diff --git a/app/Repositories/JobTypeRepository.php b/app/Repositories/JobTypeRepository.php index c4ee5b6..7a9b4fb 100644 --- a/app/Repositories/JobTypeRepository.php +++ b/app/Repositories/JobTypeRepository.php @@ -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> */ public function allOrderedByName(): array { diff --git a/app/ViewModels/HomeIndexViewModel.php b/app/ViewModels/HomeIndexViewModel.php index 458b11f..fea0a45 100644 --- a/app/ViewModels/HomeIndexViewModel.php +++ b/app/ViewModels/HomeIndexViewModel.php @@ -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 = []; } diff --git a/app/Views/campaigns/index.php b/app/Views/campaigns/index.php index fad8b79..d000a01 100644 --- a/app/Views/campaigns/index.php +++ b/app/Views/campaigns/index.php @@ -29,7 +29,13 @@
-
Loading campaigns...
+
+
+
+
+
+
+

@@ -46,7 +52,11 @@ -
Loading jobs...
+
+
+
+
+
diff --git a/app/Views/home/index.php b/app/Views/home/index.php index 3820ef7..ac63ec3 100644 --- a/app/Views/home/index.php +++ b/app/Views/home/index.php @@ -1,39 +1,109 @@ -
-
- eyebrow) ?> -

title) ?>

-

message) ?>

- -
- Open Campaign Types - See Highlights +campaignsByType as $row) { + if ((int) $row['campaign_count'] > $maxCount) { + $maxCount = (int) $row['campaign_count']; + } +} +?> +
+ +
+
+

Dashboard

+

Overview of your campaign tracking data.

- -
+
+ +
+
+
+

Recent Campaigns

+

The 5 most recently created campaigns.

+
+ View All +
+ + recentCampaigns)): ?> +
+

No campaigns yet.

+

Create your first campaign

+
+ + + + + + + + + + + + recentCampaigns as $row): ?> + + + + + + + + +
IDTypeCreated
#Edit
+ +
+ +
+
+
+

Campaigns by Type

+

Campaign count per campaign type.

+
+ Manage Types +
+ + campaignsByType)): ?> +
+

No campaign types yet.

+

Create your first type

+
+ +
+ campaignsByType as $row): ?> + 0 ? round((int) $row['campaign_count'] / $maxCount * 100) : 0; ?> + + + + + + + + +
+ +
+ +
-
-
-

Readable by design

-

Small files, explicit routing, and plain PHP views keep the framework approachable for day-to-day work.

-
- -
-

Classic MVC feel

-

Controllers, repositories, and view models stay separate so request handling remains predictable and easy to follow.

-
- -
-

SQL Server ready

-

Typed PHP 8.3 code, Composer autoloading, PDO access, and migration support make the project feel current without becoming heavyweight.

-
diff --git a/app/Views/partials/header.php b/app/Views/partials/header.php index 7d9bc73..50f0e49 100644 --- a/app/Views/partials/header.php +++ b/app/Views/partials/header.php @@ -20,6 +20,9 @@ $jsVersion = filemtime(__DIR__ . '/../../../public/js/app.js') ?: time(); <?= e($pageTitle ?? 'Campaign Tracker') ?> + + + @@ -41,22 +44,42 @@ $jsVersion = filemtime(__DIR__ . '/../../../public/js/app.js') ?: time(); diff --git a/core/Http/Session.php b/core/Http/Session.php index 0e36732..38fb2ea 100644 --- a/core/Http/Session.php +++ b/core/Http/Session.php @@ -12,6 +12,8 @@ class Session return; } + session_save_path('/tmp'); + $secure = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'; session_set_cookie_params([ diff --git a/core/Response.php b/core/Response.php index afd3a0c..63ba0b0 100644 --- a/core/Response.php +++ b/core/Response.php @@ -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) { diff --git a/docker-publish.ps1 b/docker-publish.ps1 index 6b307c3..7331c1d 100644 --- a/docker-publish.ps1 +++ b/docker-publish.ps1 @@ -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 diff --git a/public/css/site.css b/public/css/site.css index 606f944..12085ab 100644 --- a/public/css/site.css +++ b/public/css/site.css @@ -1,19 +1,49 @@ +/* ── Design System Tokens ────────────────────────────────────────────── */ :root { - --page-background: #f4efe7; - --surface: rgba(255, 252, 247, 0.88); - --surface-strong: #fffdf8; - --surface-border: rgba(26, 72, 64, 0.12); - --text-primary: #143631; - --text-secondary: #4f655f; - --accent: #1d7a6d; - --accent-strong: #135c52; - --accent-soft: #daf1ec; - --highlight: #ef7c4d; - --shadow-soft: 0 18px 50px rgba(20, 54, 49, 0.1); - --shadow-card: 0 20px 40px rgba(20, 54, 49, 0.08); -} - -* { + --primary: #1F4E79; + --primary-dark: #163b5c; + --primary-light: #dbeafe; + --secondary: #0F766E; + --accent: #2563EB; + + --success: #2E7D32; + --success-bg: #F0FDF4; + --success-border: #BBF7D0; + --warning: #B45309; + --warning-bg: #FFFBEB; + --warning-border: #FDE68A; + --error: #B91C1C; + --error-bg: #FEF2F2; + --error-border: #FECACA; + --info: #2563EB; + --info-bg: #EFF6FF; + --info-border: #BFDBFE; + --overdue: #7F1D1D; + + --bg: #F7F9FC; + --surface: #FFFFFF; + --surface-raised: #F8FAFC; + --border: #D0D7E2; + --border-strong: #9FB6D6; + + --text: #111827; + --text-secondary: #4B5563; + --text-muted: #6B7280; + + --radius-xs: 4px; + --radius-sm: 6px; + --radius: 8px; + --radius-lg: 10px; + --radius-xl: 12px; + + --shadow-xs: 0 1px 2px rgba(15, 23, 42, 0.06); + --shadow-sm: 0 1px 4px rgba(15, 23, 42, 0.08); + --shadow: 0 4px 12px rgba(15, 23, 42, 0.10); + --shadow-md: 0 8px 24px rgba(15, 23, 42, 0.12); +} + +/* ── Reset / Base ────────────────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; } @@ -28,12 +58,13 @@ html { body { margin: 0; min-height: 100vh; - font-family: "Trebuchet MS", "Lucida Sans Unicode", "Lucida Grande", sans-serif; - color: var(--text-primary); - background: - radial-gradient(circle at top left, rgba(239, 124, 77, 0.18), transparent 28%), - radial-gradient(circle at top right, rgba(29, 122, 109, 0.18), transparent 32%), - linear-gradient(180deg, #f8f2e8 0%, var(--page-background) 48%, #efe6da 100%); + font-family: "Public Sans", "Segoe UI", Arial, sans-serif; + font-size: 14px; + line-height: 22px; + font-weight: 400; + color: var(--text); + background: var(--bg); + overflow-x: hidden; } a { @@ -41,9 +72,22 @@ a { } code { - font-family: Consolas, "Courier New", monospace; + font-family: "IBM Plex Mono", Consolas, "Courier New", monospace; + font-size: 13px; +} + +h1, h2, h3, h4, h5, h6 { + margin: 0; + font-weight: 600; + color: var(--text); } +h1 { font-size: 28px; line-height: 36px; } +h2 { font-size: 22px; line-height: 30px; } +h3 { font-size: 18px; line-height: 26px; } +h4 { font-size: 16px; line-height: 24px; } + +/* ── Layout ──────────────────────────────────────────────────────────── */ .page-shell { min-height: 100vh; display: flex; @@ -51,17 +95,18 @@ code { } .container { - width: min(1120px, calc(100% - 2rem)); + width: min(1200px, calc(100% - 2rem)); margin: 0 auto; } +/* ── Site Header ─────────────────────────────────────────────────────── */ .site-header { position: sticky; top: 0; z-index: 20; - backdrop-filter: blur(14px); - background: rgba(248, 242, 232, 0.78); - border-bottom: 1px solid rgba(20, 54, 49, 0.08); + background: var(--primary); + border-bottom: 1px solid var(--primary-dark); + box-shadow: var(--shadow-sm); } .header-inner { @@ -69,13 +114,14 @@ code { align-items: center; justify-content: space-between; gap: 1rem; - padding: 1rem 0; + padding: 0; + height: 52px; } .brand { display: inline-flex; align-items: center; - gap: 0.85rem; + gap: 10px; text-decoration: none; } @@ -83,44 +129,48 @@ code { display: inline-flex; align-items: center; justify-content: center; - width: 2.75rem; - height: 2.75rem; - border-radius: 0.95rem; - background: linear-gradient(135deg, var(--accent), var(--highlight)); + width: 32px; + height: 32px; + border-radius: var(--radius-sm); + background: rgba(255, 255, 255, 0.18); color: #fff; - font-weight: 700; - letter-spacing: 0.08em; - box-shadow: var(--shadow-soft); + font-weight: 800; + font-size: 12px; + letter-spacing: 0.04em; + flex-shrink: 0; } .brand-copy { display: flex; flex-direction: column; - line-height: 1.1; + line-height: 1.2; } .brand-copy strong { - font-size: 1rem; + font-size: 14px; + font-weight: 700; + color: #fff; + letter-spacing: 0.01em; } .brand-copy small { - color: var(--text-secondary); - font-size: 0.75rem; + color: rgba(255, 255, 255, 0.6); + font-size: 11px; text-transform: uppercase; - letter-spacing: 0.14em; + letter-spacing: 0.1em; } .site-nav { display: flex; align-items: center; - gap: 0.6rem; + gap: 2px; flex-wrap: wrap; } .nav-user { - padding: 0 0.4rem; - color: var(--text-secondary); - font-size: 0.88rem; + padding: 0 8px; + color: rgba(255, 255, 255, 0.7); + font-size: 13px; font-weight: 600; } @@ -129,54 +179,104 @@ code { } .nav-link { + display: inline-flex; + align-items: center; + gap: 6px; text-decoration: none; - color: var(--text-secondary); + color: rgba(255, 255, 255, 0.88); + font-size: 13px; font-weight: 600; - padding: 0.7rem 1rem; - border-radius: 999px; - transition: background-color 160ms ease, color 160ms ease, transform 160ms ease; + padding: 6px 12px; + border-radius: var(--radius-sm); + transition: background-color 120ms ease, color 120ms ease; +} + +.nav-link:hover { + color: #fff; + background: rgba(255, 255, 255, 0.12); +} + +.nav-link:focus-visible { + color: #fff; + background: rgba(255, 255, 255, 0.12); + outline: 2px solid rgba(255, 255, 255, 0.6); + outline-offset: 2px; } -.nav-link:hover, -.nav-link:focus-visible, .nav-link.is-active { - color: var(--accent-strong); - background: rgba(29, 122, 109, 0.12); - transform: translateY(-1px); + color: #fff; + background: rgba(255, 255, 255, 0.18); } +.nav-sep { + width: 1px; + height: 18px; + background: rgba(255, 255, 255, 0.2); + margin: 0 4px; + flex-shrink: 0; +} + +.nav-logout-btn { + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: var(--radius-xs); + font-family: inherit; + font-size: 12px; + font-weight: 600; + line-height: 20px; + cursor: pointer; + background: transparent; + color: rgba(255, 255, 255, 0.75); + border: 1px solid rgba(255, 255, 255, 0.22); + transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease; +} + +.nav-logout-btn:hover { + background: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.4); + color: #fff; +} + +.nav-logout-btn:focus-visible { + outline: 2px solid rgba(255, 255, 255, 0.6); + outline-offset: 2px; +} + +/* ── Page Content ────────────────────────────────────────────────────── */ .page-content { flex: 1; - padding: 3.5rem 0 4rem; + padding: 32px 0 40px; } .content-stack { display: grid; - gap: 1.5rem; + gap: 24px; } +/* ── Section Heading ─────────────────────────────────────────────────── */ .section-heading { - max-width: 46rem; + max-width: 720px; } .section-heading h1 { - margin: 0.3rem 0 0.8rem; - font-size: clamp(2.4rem, 5vw, 4rem); - line-height: 1; - letter-spacing: -0.04em; + margin: 4px 0 8px; + font-size: 32px; + line-height: 40px; } .section-heading p { margin: 0; color: var(--text-secondary); - line-height: 1.8; - font-size: 1.05rem; + line-height: 22px; + font-size: 14px; } +/* ── Hero (home page) ────────────────────────────────────────────────── */ .hero { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(280px, 0.9fr); - gap: 1.5rem; + gap: 16px; align-items: stretch; } @@ -187,155 +287,141 @@ code { .alert, .empty-state { background: var(--surface); - border: 1px solid var(--surface-border); - box-shadow: var(--shadow-card); + border: 1px solid var(--border); + box-shadow: var(--shadow-sm); } .hero-copy { - padding: 3rem; - border-radius: 2rem; + padding: 32px; + border-radius: var(--radius-lg); } .eyebrow { display: inline-block; - margin-bottom: 1rem; - padding: 0.4rem 0.75rem; + margin-bottom: 16px; + padding: 3px 10px; border-radius: 999px; - background: var(--accent-soft); - color: var(--accent-strong); - font-size: 0.78rem; + background: var(--info-bg); + border: 1px solid var(--info-border); + color: var(--accent); + font-size: 11px; font-weight: 700; text-transform: uppercase; - letter-spacing: 0.14em; + letter-spacing: 0.1em; } .hero h1 { - margin: 0; - font-size: clamp(2.8rem, 6vw, 4.8rem); - line-height: 0.98; - letter-spacing: -0.04em; + font-size: clamp(24px, 4vw, 36px); + line-height: 1.15; + letter-spacing: -0.02em; + color: var(--primary); } .hero-text { - max-width: 44rem; - margin: 1.25rem 0 0; - font-size: 1.12rem; - line-height: 1.8; + max-width: 560px; + margin: 16px 0 0; + font-size: 14px; + line-height: 22px; color: var(--text-secondary); } .hero-actions { display: flex; flex-wrap: wrap; - gap: 0.85rem; - margin-top: 2rem; -} - -.button { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 0.9rem 1.35rem; - border-radius: 999px; - text-decoration: none; - font-weight: 700; -} - -.button-primary { - background: linear-gradient(135deg, var(--accent), var(--accent-strong)); - color: #fff; - box-shadow: 0 18px 30px rgba(19, 92, 82, 0.25); -} - -.button-secondary { - background: rgba(29, 122, 109, 0.08); - color: var(--accent-strong); + gap: 8px; + margin-top: 24px; } .hero-panel { display: flex; flex-direction: column; justify-content: space-between; - padding: 2rem; - border-radius: 1.8rem; + padding: 24px; + border-radius: var(--radius-lg); } .panel-label { - margin: 0 0 1rem; - font-size: 0.78rem; + margin: 0 0 12px; + font-size: 11px; font-weight: 700; - letter-spacing: 0.16em; + letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-secondary); } .hero-panel code { display: block; - padding: 1rem 1.1rem; - border-radius: 1.2rem; - background: #173d37; - color: #eefbf6; - line-height: 1.7; + padding: 12px 14px; + border-radius: var(--radius); + background: #0f172a; + color: #e2e8f0; + font-family: "IBM Plex Mono", Consolas, monospace; + font-size: 12px; + line-height: 20px; white-space: normal; } .route-callout { - margin-top: 1.5rem; - padding: 1rem 1.1rem; - border-radius: 1.2rem; - background: var(--surface-strong); + margin-top: 16px; + padding: 12px 14px; + border-radius: var(--radius); + background: var(--surface-raised); + border: 1px solid var(--border); } .route-callout span { display: block; - margin-bottom: 0.45rem; + margin-bottom: 6px; color: var(--text-secondary); - font-size: 0.92rem; + font-size: 13px; } .route-callout a { - color: var(--highlight); + color: var(--accent); font-weight: 700; text-decoration: none; } +/* ── Feature Grid ────────────────────────────────────────────────────── */ .feature-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 1.25rem; - margin-top: 1.5rem; + gap: 16px; + margin-top: 16px; } .feature-card { - padding: 1.75rem; - border-radius: 1.6rem; + padding: 20px; + border-radius: var(--radius-lg); } .feature-card h2 { margin-top: 0; - margin-bottom: 0.8rem; - font-size: 1.25rem; + margin-bottom: 8px; + font-size: 16px; + line-height: 24px; } .feature-card p { margin: 0; color: var(--text-secondary); - line-height: 1.7; + line-height: 22px; + font-size: 13px; } +/* ── Panels & Controls ───────────────────────────────────────────────── */ .controls-panel, .table-shell { overflow: hidden; - background: - linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(248, 242, 232, 0.88)), - var(--surface); + background: var(--surface); + border: 1px solid var(--border); } .controls-header { display: flex; align-items: flex-start; justify-content: space-between; - gap: 1rem; + gap: 16px; } .search-row { @@ -348,47 +434,53 @@ code { } .section-panel { - padding: 1.75rem; - border-radius: 1.8rem; + padding: 24px; + border-radius: var(--radius-lg); + min-width: 0; + border-left: 3px solid var(--primary); } .panel-header { display: flex; align-items: flex-start; justify-content: space-between; - gap: 1rem; + gap: 16px; flex-wrap: wrap; - margin-bottom: 1.5rem; + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 1px solid var(--border); } .panel-actions { display: flex; align-items: center; - gap: 0.5rem; + gap: 8px; flex-wrap: wrap; } .panel-header h2 { - margin: 0 0 0.45rem; - font-size: 1.45rem; + margin: 0 0 4px; + font-size: 18px; + line-height: 26px; } .panel-header p { margin: 0; color: var(--text-secondary); - line-height: 1.7; + line-height: 22px; + font-size: 13px; } .job-type-table-stack { display: grid; - gap: 1.5rem; + gap: 16px; } .job-type-table-group { display: grid; - gap: 0.85rem; - padding-top: 1.25rem; - border-top: 1px solid rgba(20, 54, 49, 0.1); + gap: 8px; + padding-top: 16px; + border-top: 1px solid var(--border); } .job-type-table-group:first-child { @@ -400,97 +492,68 @@ code { display: flex; align-items: center; justify-content: space-between; - gap: 1rem; + gap: 16px; flex-wrap: wrap; } .job-type-table-heading h3 { margin: 0; - font-size: 1.08rem; + font-size: 15px; + line-height: 22px; } .job-type-table-heading span { color: var(--text-secondary); - font-size: 0.86rem; + font-size: 12px; font-weight: 700; } +/* ── Forms ───────────────────────────────────────────────────────────── */ .form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 1rem; -} - -/* Campaign jobs table — horizontal scroll inside the panel */ -#campaign-jobs-page-table { - overflow-x: auto; - width: 100%; -} - -.import-tabs { - display: flex; - gap: 0.25rem; - margin-bottom: 1.25rem; - border-bottom: 1px solid var(--surface-border); - padding-bottom: 0; -} - -.import-tab { - padding: 0.55rem 1.1rem; - border: none; - background: none; - cursor: pointer; - font: inherit; - font-weight: 600; - color: var(--text-secondary); - border-bottom: 2px solid transparent; - margin-bottom: -1px; - border-radius: 0; - transition: color 120ms, border-color 120ms; -} - -.import-tab:hover { color: var(--accent); } -.import-tab.is-active { color: var(--accent-strong); border-bottom-color: var(--accent-strong); } - -.import-grid { - display: grid; - grid-template-columns: minmax(260px, 1.4fr) minmax(180px, 0.8fr) minmax(180px, 0.8fr); - gap: 1rem; -} - -.import-actions { - margin-top: 1rem; - flex-wrap: wrap; + gap: 16px; } .field { display: grid; - gap: 0.45rem; + gap: 6px; font-weight: 600; + font-size: 13px; } .field span { - font-size: 0.96rem; + font-size: 13px; + color: var(--text); } .input { width: 100%; - padding: 0.95rem 1rem; - border: 1px solid rgba(20, 54, 49, 0.16); - border-radius: 1rem; - background: rgba(255, 255, 255, 0.92); - color: var(--text-primary); + padding: 7px 10px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text); font: inherit; + font-size: 14px; + line-height: 20px; + transition: border-color 120ms ease, box-shadow 120ms ease; } .input:focus { - outline: 2px solid rgba(29, 122, 109, 0.22); - border-color: rgba(29, 122, 109, 0.45); + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12); +} + +.input:invalid:not(:placeholder-shown) { + border-color: var(--error); + box-shadow: 0 0 0 3px rgba(185, 28, 28, 0.08); } .field-error { - color: #a43d1f; - font-size: 0.88rem; + color: var(--error); + font-size: 12px; font-weight: 600; } @@ -498,136 +561,360 @@ code { display: flex; justify-content: flex-start; align-items: center; - gap: 0.85rem; + gap: 8px; +} + +.ct-form .form-actions { + position: sticky; + bottom: 0; + z-index: 2; + background: var(--surface); + margin: 8px -24px -24px; + padding: 12px 24px; + border-top: 1px solid var(--border); + border-radius: 0 0 var(--radius-lg) var(--radius-lg); } +/* ── Buttons ─────────────────────────────────────────────────────────── */ .button { - border: 0; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 7px 16px; + border-radius: var(--radius-sm); + text-decoration: none; + font-family: inherit; + font-size: 13px; + font-weight: 600; + line-height: 20px; + border: 1px solid transparent; cursor: pointer; + transition: background-color 120ms ease, border-color 120ms ease, box-shadow 120ms ease; } -.htmx-indicator { - display: none; +.button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; } -.htmx-request .htmx-indicator, -.htmx-request.htmx-indicator { - display: inline-flex; +.button-primary { + background: var(--primary); + color: #fff; + border-color: var(--primary-dark); } -.inline-indicator { - color: var(--text-secondary); - font-size: 0.9rem; - font-weight: 600; +.button-primary:hover { + background: var(--primary-dark); +} + +.button-secondary { + background: var(--surface); + color: var(--text); + border-color: var(--border); +} + +.button-secondary:hover { + background: var(--surface-raised); + border-color: var(--border-strong); +} + +.button-danger { + background: var(--error); + color: #fff; + border-color: #991B1B; +} + +.button-danger:hover, +.button-danger:focus-visible { + background: #991B1B; +} + +.button-sm { + padding: 4px 10px; + font-size: 12px; + border-radius: var(--radius-xs); + border: 1px solid transparent; + cursor: pointer; } +/* ── Alerts & Empty States ───────────────────────────────────────────── */ .alert, .empty-state { - padding: 1rem 1.15rem; - border-radius: 1.2rem; + padding: 12px 16px; + border-radius: var(--radius); + font-size: 14px; } .alert-success { - background: rgba(218, 241, 236, 0.92); - color: var(--accent-strong); + background: var(--success-bg); + border-color: var(--success-border); + color: var(--success); } .alert-error { - background: rgba(239, 124, 77, 0.14); - color: #8f3518; + background: var(--error-bg); + border-color: var(--error-border); + color: var(--error); } .empty-state p { margin: 0; color: var(--text-secondary); - line-height: 1.7; + line-height: 22px; } .empty-state p + p { - margin-top: 0.45rem; + margin-top: 6px; } +/* ── Stats Grid ──────────────────────────────────────────────────────── */ .stats-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 0.9rem; + gap: 12px; } .stat-card { - padding: 1rem; - border-radius: 1.3rem; - background: rgba(255, 255, 255, 0.72); - border: 1px solid rgba(20, 54, 49, 0.08); + padding: 16px; + border-radius: var(--radius); + background: var(--surface); + border: 1px solid var(--border); + box-shadow: var(--shadow-xs); } .stat-card span { display: block; color: var(--text-secondary); - font-size: 0.82rem; + font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; + font-weight: 600; } .stat-card strong { display: block; - margin-top: 0.45rem; - font-size: 1.7rem; + margin-top: 8px; + font-size: 38px; line-height: 1; + font-weight: 700; + color: var(--primary); +} + +a.stat-card { + text-decoration: none; + cursor: pointer; + transition: transform 100ms ease, box-shadow 100ms ease, border-color 100ms ease; +} + +a.stat-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow); + border-color: var(--border-strong); +} + +a.stat-card:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +a.stat-card::after { + content: 'View all →'; + display: block; + margin-top: 10px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + color: var(--text-muted); + opacity: 0; + transform: translateX(-4px); + transition: opacity 150ms ease, transform 150ms ease; +} + +a.stat-card:hover::after { + opacity: 1; + transform: translateX(0); } .summary-feature { - margin-top: 1rem; - padding: 1.15rem; - border-radius: 1.3rem; - background: linear-gradient(135deg, rgba(29, 122, 109, 0.12), rgba(239, 124, 77, 0.12)); + margin-top: 16px; + padding: 14px 16px; + border-radius: var(--radius); + background: var(--info-bg); + border: 1px solid var(--info-border); } .summary-label { display: block; color: var(--text-secondary); - font-size: 0.82rem; + font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; + font-weight: 600; } .summary-feature h3 { - margin: 0.55rem 0 0.3rem; - font-size: 1.35rem; + margin: 6px 0 4px; + font-size: 16px; + line-height: 24px; + color: var(--primary); } .summary-feature p { margin: 0; color: var(--text-secondary); + font-size: 13px; +} + +/* ── Dashboard ───────────────────────────────────────────────────────── */ +.stats-grid-4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.dashboard-panels { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + align-items: start; +} + +.dashboard-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.dashboard-table th { + text-align: left; + padding: 6px 10px; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-secondary); + border-bottom: 1px solid var(--border); +} + +.dashboard-table td { + padding: 8px 10px; + border-bottom: 1px solid var(--border); + color: var(--text-primary); +} + +.dashboard-table tr:last-child td { + border-bottom: none; +} + +.dashboard-table-id { + color: var(--text-secondary); + font-variant-numeric: tabular-nums; + width: 48px; +} + +.dashboard-table-date { + color: var(--text-secondary); + white-space: nowrap; +} + +.dashboard-table-action { + text-align: right; + width: 40px; +} + +.dashboard-table-action a { + color: var(--primary); + font-size: 12px; +} + +.type-breakdown { + display: flex; + flex-direction: column; + gap: 10px; + padding: 4px 0; +} + +.type-breakdown-row { + display: grid; + grid-template-columns: 140px 1fr 36px; + align-items: center; + gap: 10px; + font-size: 13px; +} + +.type-name { + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.type-bar-wrap { + height: 8px; + background: var(--border); + border-radius: 4px; + overflow: hidden; +} + +.type-bar { + display: block; + height: 100%; + background: var(--primary); + border-radius: 4px; + min-width: 4px; + transition: width 0.3s ease; +} + +.type-count { + text-align: right; + font-variant-numeric: tabular-nums; + color: var(--text-secondary); + font-size: 12px; +} + +@media (max-width: 860px) { + .stats-grid-4 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .dashboard-panels { + grid-template-columns: 1fr; + } } +/* ── Table Toolbar ───────────────────────────────────────────────────── */ .table-toolbar { display: flex; align-items: center; justify-content: space-between; - gap: 1rem; - margin-bottom: 1rem; + gap: 16px; + margin-bottom: 12px; flex-wrap: wrap; - padding: 0.9rem 1rem; - border: 1px solid rgba(20, 54, 49, 0.08); - border-radius: 1rem; - background: rgba(255, 255, 255, 0.58); + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); } .table-pill { display: inline-flex; align-items: center; - padding: 0.5rem 0.8rem; + padding: 3px 10px; border-radius: 999px; - background: rgba(29, 122, 109, 0.12); - color: var(--accent-strong); - font-size: 0.82rem; + background: var(--info-bg); + border: 1px solid var(--info-border); + color: var(--accent); + font-size: 12px; font-weight: 700; letter-spacing: 0.04em; } .table-caption { color: var(--text-secondary); - font-size: 0.92rem; + font-size: 12px; +} + +/* ── Tabulator ───────────────────────────────────────────────────────── */ +.tabulator-host { + overflow-x: auto; + width: 100%; } .directory-panel .tabulator-host { @@ -635,24 +922,22 @@ code { } .tabulator-host .tabulator { - border: 1px solid var(--surface-border); - border-radius: 1.35rem; + border: 1px solid var(--border); + border-radius: var(--radius-lg); overflow: hidden; - background: rgba(255, 255, 255, 0.82); - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.5), - 0 18px 35px rgba(20, 54, 49, 0.08); + background: var(--surface); + box-shadow: var(--shadow-sm); } .tabulator-host .tabulator-header { - border-bottom: 1px solid rgba(20, 54, 49, 0.08); - background: linear-gradient(180deg, rgba(29, 122, 109, 0.14), rgba(29, 122, 109, 0.08)); + border-bottom: 1px solid var(--border); + background: var(--surface-raised); } .tabulator-host .tabulator-header .tabulator-col { - min-height: 3.25rem; + min-height: 36px; background: transparent; - border-right: 1px solid rgba(20, 54, 49, 0.06); + border-right: 1px solid var(--border); } .tabulator-host .tabulator-header .tabulator-col:last-child { @@ -660,20 +945,20 @@ code { } .tabulator-host .tabulator-header .tabulator-col .tabulator-col-content { - padding: 0.9rem 0.95rem 0.85rem; + padding: 8px 10px; } .tabulator-host .tabulator-header .tabulator-col .tabulator-col-title { - font-size: 0.78rem; - font-weight: 800; - letter-spacing: 0.08em; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; text-transform: uppercase; - color: var(--accent-strong); + color: var(--text-secondary); } .tabulator-host .tabulator-col, .tabulator-host .tabulator-cell { - border-right: 1px solid rgba(20, 54, 49, 0.06); + border-right: 1px solid var(--border); } .tabulator-host .tabulator-row .tabulator-cell:last-child { @@ -681,157 +966,117 @@ code { } .tabulator-host .tabulator-row { - background: rgba(255, 255, 255, 0.96); - border-bottom: 1px solid rgba(20, 54, 49, 0.06); - transition: background-color 160ms ease, transform 160ms ease; + background: var(--surface); + border-bottom: 1px solid var(--border); + transition: background-color 100ms ease; } .tabulator-host .tabulator-row:nth-child(even) { - background: rgba(248, 242, 232, 0.82); + background: var(--surface-raised); } .tabulator-host .tabulator-row:hover { - background: rgba(218, 241, 236, 0.72); + background: var(--info-bg); } .tabulator-host .tabulator-row.tabulator-selected { - background: rgba(29, 122, 109, 0.18); + background: var(--primary-light); } .tabulator-host .tabulator-cell { - padding: 0.95rem 0.95rem; - font-size: 0.96rem; - line-height: 1.4; + padding: 8px 10px; + font-size: 13px; + line-height: 20px; } .tabulator-host .tabulator-row .tabulator-cell:first-child { - font-weight: 700; - color: var(--text-primary); + font-weight: 600; + color: var(--text); } .tabulator-host .tabulator-footer { - padding: 0.55rem 0.7rem; - background: rgba(255, 255, 255, 0.88); - border-top: 1px solid rgba(20, 54, 49, 0.08); + padding: 6px 8px; + background: var(--surface-raised); + border-top: 1px solid var(--border); } .tabulator-host .tabulator-footer .tabulator-paginator { font-family: inherit; + font-size: 13px; } .tabulator-host .tabulator-footer .tabulator-page { - margin: 0 0.2rem; - padding: 0.45rem 0.7rem; - border: 1px solid rgba(20, 54, 49, 0.1); - border-radius: 0.8rem; - background: rgba(255, 255, 255, 0.9); + margin: 0 2px; + padding: 4px 8px; + border: 1px solid var(--border); + border-radius: var(--radius-xs); + background: var(--surface); color: var(--text-secondary); - font-weight: 700; + font-weight: 600; + font-size: 12px; } .tabulator-host .tabulator-footer .tabulator-page.active, .tabulator-host .tabulator-footer .tabulator-page:hover { - background: linear-gradient(135deg, var(--accent), var(--accent-strong)); - border-color: transparent; + background: var(--primary); + border-color: var(--primary-dark); color: #fff; } .tabulator-host .tabulator-footer .tabulator-page:disabled { - opacity: 0.45; + opacity: 0.4; } .tabulator-host .tabulator-placeholder { - padding: 2.5rem 1rem; + padding: 32px 16px; + color: var(--text-secondary); + font-size: 13px; + font-weight: 600; +} + +/* ── HTMX Indicators ─────────────────────────────────────────────────── */ +.htmx-indicator { + display: none; +} + +.htmx-request .htmx-indicator, +.htmx-request.htmx-indicator { + display: inline-flex; +} + +.inline-indicator { color: var(--text-secondary); - font-size: 1rem; + font-size: 13px; font-weight: 600; } +/* ── Site Footer ─────────────────────────────────────────────────────── */ .site-footer { margin-top: auto; - border-top: 1px solid rgba(20, 54, 49, 0.08); - background: rgba(255, 252, 247, 0.72); + border-top: 1px solid var(--border); + background: var(--surface); } .footer-inner { display: flex; justify-content: space-between; - gap: 1rem; - padding: 1.25rem 0 2rem; - color: var(--text-secondary); - font-size: 0.95rem; + gap: 16px; + padding: 16px 0 24px; + color: var(--text-muted); + font-size: 12px; + line-height: 18px; } .footer-inner p { margin: 0; } -@media (max-width: 860px) { - .header-inner, - .footer-inner { - flex-direction: column; - align-items: flex-start; - } - - .hero, - .feature-grid { - grid-template-columns: 1fr; - } - - .controls-header, - .table-toolbar { - flex-direction: column; - align-items: flex-start; - } - - .hero-copy, - .hero-panel { - padding: 2rem; - } - - .form-grid { - grid-template-columns: 1fr; - } - - .import-grid { - grid-template-columns: 1fr; - } - - .stats-grid { - grid-template-columns: 1fr; - } - - .page-content { - padding-top: 2rem; - } -} - -@media (max-width: 560px) { - .container { - width: min(100% - 1.25rem, 1120px); - } - - .site-nav { - width: 100%; - } - - .nav-link { - width: 100%; - text-align: center; - } - - .hero h1 { - font-size: 2.5rem; - } -} - -/* ── Campaign Types ─────────────────────────────────────────────────── */ - +/* ── Page Toolbar ────────────────────────────────────────────────────── */ .page-toolbar { display: flex; align-items: flex-start; justify-content: space-between; - gap: 1.5rem; + gap: 16px; flex-wrap: wrap; } @@ -840,82 +1085,83 @@ code { } .page-toolbar .section-heading h1 { - margin: 0 0 0.4rem; -} - -.button-danger { - background: linear-gradient(135deg, #c0392b, #962d22); - color: #fff; - box-shadow: 0 8px 20px rgba(192, 57, 43, 0.28); - border: none; - cursor: pointer; -} - -.button-danger:hover, -.button-danger:focus-visible { - background: linear-gradient(135deg, #d44637, #c0392b); -} - -.button-sm { - padding: 0.4rem 0.85rem; - font-size: 0.82rem; - border-radius: 999px; - border: none; - cursor: pointer; + margin: 0 0 4px; } +/* ── Campaign Types / Import Forms ───────────────────────────────────── */ .ct-form { display: grid; - gap: 2rem; + gap: 24px; } .form-section { display: grid; - gap: 1rem; + gap: 12px; } .form-section h3 { margin: 0; - font-size: 1.05rem; + font-size: 15px; + line-height: 22px; } .attributes-header { display: flex; flex-direction: column; - gap: 0.25rem; + gap: 4px; } .attributes-hint { margin: 0; color: var(--text-secondary); - font-size: 0.9rem; + font-size: 13px; } .attribute-list { display: grid; - gap: 0.6rem; + gap: 6px; } .attribute-row { display: flex; align-items: flex-end; - gap: 0.75rem; + gap: 8px; flex-wrap: wrap; } .attr-drag-handle { cursor: grab; - padding: 0 0.3rem; - color: var(--text-secondary); - font-size: 1.25rem; user-select: none; align-self: flex-end; - padding-bottom: 0.6rem; - line-height: 1; + padding-bottom: 8px; + font-size: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 34px; + flex-shrink: 0; + opacity: 0.5; + transition: opacity 100ms ease; +} + +.attr-drag-handle::before { + content: ''; + display: block; + width: 6px; + height: 16px; + background-image: radial-gradient(circle, var(--text-muted) 1.5px, transparent 1.5px); + background-size: 3px 4px; + background-repeat: repeat; +} + +.attr-drag-handle:hover { + opacity: 1; } .attr-drag-handle:active { cursor: grabbing; + opacity: 1; } .attribute-row.is-dragging { @@ -924,8 +1170,8 @@ code { .attribute-row.is-drag-over { outline: 2px dashed var(--accent); - border-radius: 0.8rem; - background: var(--accent-soft); + border-radius: var(--radius-sm); + background: var(--info-bg); } .attribute-order-field { @@ -951,42 +1197,203 @@ code { padding-bottom: 0.1rem; } -.field-full { - width: 100%; -} - .input-error { - border-color: #c0392b !important; + border-color: var(--error) !important; } .required-mark { - color: #c0392b; + color: var(--error); } .delete-zone { - margin-top: 2.5rem; - padding-top: 1.5rem; - border-top: 1px solid rgba(192, 57, 43, 0.2); + margin-top: 32px; + padding-top: 16px; + border-top: 1px solid var(--error-border); } .delete-zone h4 { - margin: 0 0 0.35rem; - color: #c0392b; - font-size: 0.95rem; + margin: 0 0 4px; + color: var(--error); + font-size: 14px; } .delete-zone p { - margin: 0 0 1rem; + margin: 0 0 12px; color: var(--text-secondary); - font-size: 0.88rem; + font-size: 13px; } .attr-summary { color: var(--text-secondary); - font-size: 0.88rem; + font-size: 13px; } .attr-empty { + color: var(--text-muted); + opacity: 0.6; +} + +/* ── Import Tabs ─────────────────────────────────────────────────────── */ +.import-tabs { + display: flex; + gap: 2px; + margin-bottom: 16px; + border-bottom: 1px solid var(--border); + padding-bottom: 0; +} + +.import-tab { + padding: 6px 14px; + border: none; + background: none; + cursor: pointer; + font: inherit; + font-size: 13px; + font-weight: 600; color: var(--text-secondary); - opacity: 0.45; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + border-radius: 0; + transition: color 120ms, border-color 120ms; +} + +.import-tab:hover { color: var(--accent); } +.import-tab.is-active { color: var(--primary); border-bottom-color: var(--primary); } + +.import-grid { + display: grid; + grid-template-columns: minmax(260px, 1.4fr) minmax(180px, 0.8fr) minmax(180px, 0.8fr); + gap: 16px; +} + +.import-actions { + margin-top: 16px; + flex-wrap: wrap; +} + +/* Campaign jobs table — horizontal scroll inside the panel */ +#campaign-jobs-page-table { + overflow-x: auto; + width: 100%; +} + +/* ── Global Focus Visible ────────────────────────────────────────────── */ +.page-content a:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: 2px; +} + +/* ── Skeleton Loading ────────────────────────────────────────────────── */ +@keyframes skeleton-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +.skeleton-rows { + display: flex; + flex-direction: column; + gap: 8px; + padding: 4px 0; +} + +.skeleton-row { + height: 38px; + border-radius: var(--radius-sm); + background: linear-gradient(90deg, var(--surface-raised) 25%, var(--border) 50%, var(--surface-raised) 75%); + background-size: 400% 100%; + animation: skeleton-shimmer 1.5s ease-in-out infinite; +} + +.skeleton-row:nth-child(2) { animation-delay: 0.1s; opacity: 0.85; } +.skeleton-row:nth-child(3) { animation-delay: 0.2s; opacity: 0.7; } +.skeleton-row:nth-child(4) { animation-delay: 0.3s; opacity: 0.55; } +.skeleton-row:nth-child(5) { animation-delay: 0.4s; opacity: 0.4; } + +/* ── Type Breakdown Links ────────────────────────────────────────────── */ +a.type-breakdown-row { + text-decoration: none; + color: inherit; + padding: 4px 6px; + margin: 0 -6px; + border-radius: var(--radius-sm); + transition: background-color 100ms ease; +} + +a.type-breakdown-row:hover { + background: var(--surface-raised); +} + +a.type-breakdown-row:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* ── Responsive ──────────────────────────────────────────────────────── */ +@media (max-width: 860px) { + .header-inner, + .footer-inner { + flex-direction: column; + align-items: flex-start; + } + + .header-inner { + height: auto; + padding: 12px 0; + } + + .hero, + .feature-grid { + grid-template-columns: 1fr; + } + + .controls-header, + .table-toolbar { + flex-direction: column; + align-items: flex-start; + } + + .hero-copy, + .hero-panel { + padding: 24px; + } + + .form-grid { + grid-template-columns: 1fr; + } + + .import-grid { + grid-template-columns: 1fr; + } + + .stats-grid { + grid-template-columns: 1fr; + } + + .page-content { + padding-top: 24px; + } +} + +@media (max-width: 560px) { + .container { + width: min(100% - 1rem, 1200px); + } + + .site-nav { + width: 100%; + } + + .nav-link { + width: 100%; + text-align: center; + } + + .nav-sep { + display: none; + } + + .hero h1 { + font-size: 22px; + } } diff --git a/public/js/app.js b/public/js/app.js index 5b11ae3..e9193d4 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -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 'Edit ' + + ''; + }, + }, { 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 'Edit ' + - ''; - }, - }, ], }); }, @@ -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 'Jobs ' + + 'Edit ' + + ''; + }, + }, { 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 'Jobs ' + - 'Edit ' + - ''; - }, - } + { 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 'Edit'; + }, + }, { 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 'Edit'; - }, - } + { 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 'Edit'; + }, + }, { 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 'Edit'; - }, - } + { 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 'Edit'; + }, + }; + + 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 'Edit'; - }, - } - ); - - 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 'Edit ' + + ''; + }, + }, { 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 'Edit ' + - ''; - }, - }, ], }); }, @@ -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 'Edit ' + + ''; + }, + }, { 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 'Edit ' + - ''; - }, - }, ], }); },