Bläddra i källkod

Merge pull request 'GUI-UI-change' (#1) from GUI-UI-change into main

main
dcovington 1 dag sedan
förälder
incheckning
1f512cb91d
17 ändrade filer med 1498 tillägg och 576 borttagningar
  1. +7
    -1
      .claude/settings.local.json
  2. +5
    -5
      .env.example
  3. +315
    -2
      AGENTS.md
  4. +13
    -5
      app/Controllers/HomeController.php
  5. +29
    -0
      app/Repositories/CampaignRepository.php
  6. +6
    -0
      app/Repositories/CampaignTypeRepository.php
  7. +6
    -0
      app/Repositories/JobRepository.php
  8. +6
    -0
      app/Repositories/JobTypeRepository.php
  9. +6
    -4
      app/ViewModels/HomeIndexViewModel.php
  10. +12
    -2
      app/Views/campaigns/index.php
  11. +103
    -33
      app/Views/home/index.php
  12. +28
    -5
      app/Views/partials/header.php
  13. +2
    -0
      core/Http/Session.php
  14. +6
    -0
      core/Response.php
  15. +42
    -22
      docker-publish.ps1
  16. +814
    -407
      public/css/site.css
  17. +98
    -90
      public/js/app.js

+ 7
- 1
.claude/settings.local.json Visa fil

@@ -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
- 5
.env.example Visa fil

@@ -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

+ 315
- 2
AGENTS.md Visa fil

@@ -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">&#8597;</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.

+ 13
- 5
app/Controllers/HomeController.php Visa fil

@@ -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',
]);
}



+ 29
- 0
app/Repositories/CampaignRepository.php Visa fil

@@ -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.
*


+ 6
- 0
app/Repositories/CampaignTypeRepository.php Visa fil

@@ -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(


+ 6
- 0
app/Repositories/JobRepository.php Visa fil

@@ -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
{


+ 6
- 0
app/Repositories/JobTypeRepository.php Visa fil

@@ -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
- 4
app/ViewModels/HomeIndexViewModel.php Visa fil

@@ -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 = [];
}

+ 12
- 2
app/Views/campaigns/index.php Visa fil

@@ -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>


+ 103
- 33
app/Views/home/index.php Visa fil

@@ -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>

+ 28
- 5
app/Views/partials/header.php Visa fil

@@ -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>


+ 2
- 0
core/Http/Session.php Visa fil

@@ -12,6 +12,8 @@ class Session
return;
}

session_save_path('/tmp');

$secure = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';

session_set_cookie_params([


+ 6
- 0
core/Response.php Visa fil

@@ -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) {


+ 42
- 22
docker-publish.ps1 Visa fil

@@ -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

+ 814
- 407
public/css/site.css
Filskillnaden har hållits tillbaka eftersom den är för stor
Visa fil


+ 98
- 90
public/js/app.js Visa fil

@@ -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>';
},
},
],
});
},


Laddar…
Avbryt
Spara

Powered by TurnKey Linux.