Kaynağa Gözat

gui chnage stage 1

pull/1/head
Daniel Covington 1 gün önce
ebeveyn
işleme
ef6881fc67
11 değiştirilmiş dosya ile 913 ekleme ve 538 silme
  1. +6
    -1
      .claude/settings.local.json
  2. +13
    -5
      app/Controllers/HomeController.php
  3. +29
    -0
      app/Repositories/CampaignRepository.php
  4. +6
    -0
      app/Repositories/CampaignTypeRepository.php
  5. +6
    -0
      app/Repositories/JobRepository.php
  6. +6
    -0
      app/Repositories/JobTypeRepository.php
  7. +6
    -4
      app/ViewModels/HomeIndexViewModel.php
  8. +103
    -33
      app/Views/home/index.php
  9. +3
    -0
      app/Views/partials/header.php
  10. +637
    -405
      public/css/site.css
  11. +98
    -90
      public/js/app.js

+ 6
- 1
.claude/settings.local.json Dosyayı Görüntüle

@@ -7,7 +7,12 @@
"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)"
]
}
}

+ 13
- 5
app/Controllers/HomeController.php Dosyayı Görüntüle

@@ -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 Dosyayı Görüntüle

@@ -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 Dosyayı Görüntüle

@@ -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 Dosyayı Görüntüle

@@ -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 Dosyayı Görüntüle

@@ -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 Dosyayı Görüntüle

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

+ 103
- 33
app/Views/home/index.php Dosyayı Görüntüle

@@ -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="route-callout">
<span>Campaign types</span>
<a href="<?= e($model->routeExample) ?>"><?= e($model->routeExample) ?></a>
<div class="stats-grid stats-grid-4">
<div class="stat-card">
<span>Campaign Types</span>
<strong><?= e((string) $model->totalCampaignTypes) ?></strong>
</div>
</aside>
</section>
<div class="stat-card">
<span>Campaigns</span>
<strong><?= e((string) $model->totalCampaigns) ?></strong>
</div>
<div class="stat-card">
<span>Job Types</span>
<strong><?= e((string) $model->totalJobTypes) ?></strong>
</div>
<div class="stat-card">
<span>Jobs</span>
<strong><?= e((string) $model->totalJobs) ?></strong>
</div>
</div>

<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; ?>
<div class="type-breakdown-row">
<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>
</div>
<?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>

+ 3
- 0
app/Views/partials/header.php Dosyayı Görüntüle

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


+ 637
- 405
public/css/site.css
Dosya farkı çok büyük olduğundan ihmal edildi
Dosyayı Görüntüle


+ 98
- 90
public/js/app.js Dosyayı Görüntüle

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


Yükleniyor…
İptal
Kaydet

Powered by TurnKey Linux.