index.php
18.01 KB
<?php
require_once __DIR__ . '/../bootstrap.php';
require_once __DIR__ . '/../lib/feature_modules.php';
requireAdmin();
$pageTitle = 'Administration';
// Helper: list update logs for Release Notes
function wp_list_update_logs(): array {
$dir = realpath(__DIR__ . '/../assets/changelog');
if (!$dir || !is_dir($dir)) return [];
$logs = [];
$archive = $dir . '/updates.zip';
// Preferred: single archive file
if (is_readable($archive) && class_exists('ZipArchive')) {
$zip = new ZipArchive();
if ($zip->open($archive) === true) {
for ($i = 0; $i < $zip->numFiles; $i++) {
$stat = $zip->statIndex($i);
if (!$stat || empty($stat['name'])) continue;
$name = (string)$stat['name'];
if (!preg_match('/^update_log_(.+)\.md$/', basename($name), $m)) continue;
$ver = trim((string)$m[1]);
$content = $zip->getFromIndex($i);
$isLegacy = false;
if (is_string($content) && strpos($content, 'Legacy update log') !== false && strpos($content, 'Not recorded') !== false) {
$isLegacy = true;
}
$logs[] = [
'version' => $ver,
'path' => $archive . '::' . $name,
'mtime' => (int)($stat['mtime'] ?? 0),
'legacy' => $isLegacy,
];
}
$zip->close();
}
}
// Fallback: legacy loose files
if (empty($logs)) {
foreach (glob($dir . '/update_log_*.md') ?: [] as $path) {
$base = basename($path);
if (!preg_match('/^update_log_(.+)\.md$/', $base, $m)) continue;
$ver = trim((string)$m[1]);
$content = @file_get_contents($path);
$isLegacy = false;
if (is_string($content) && strpos($content, 'Legacy update log') !== false && strpos($content, 'Not recorded') !== false) {
$isLegacy = true;
}
$logs[] = [
'version' => $ver,
'path' => $path,
'mtime' => @filemtime($path) ?: 0,
'legacy' => $isLegacy,
];
}
}
usort($logs, function ($a, $b) {
$va = preg_replace('/-.*/', '', (string)$a['version']);
$vb = preg_replace('/-.*/', '', (string)$b['version']);
$cmp = version_compare($vb, $va);
if ($cmp !== 0) return $cmp;
$sa = (string)$a['version'];
$sb = (string)$b['version'];
$alphaA = str_contains($sa, '-alpha');
$alphaB = str_contains($sb, '-alpha');
if ($alphaA !== $alphaB) return $alphaB <=> $alphaA;
return ($b['mtime'] ?? 0) <=> ($a['mtime'] ?? 0);
});
return $logs;
}
$updateLogs = wp_list_update_logs();
$latestLogVersion = $updateLogs[0]['version'] ?? '';
// Quick stats (best-effort)
$staffCount = 0;
$eventCount = 0;
$vanCount = 0;
$dbInfo = '';
$appVersion = defined('APP_VERSION') ? APP_VERSION : '';
$ttConfigured = false;
$ttDevices = 0;
$ttLastUpdate = null;
try { $staffCount = (int)$pdo->query("SELECT COUNT(*) FROM users WHERE is_active = 1")->fetchColumn(); } catch (Throwable $e) {}
try { $vanCount = (int)$pdo->query("SELECT COUNT(*) FROM vans WHERE is_active = 1")->fetchColumn(); } catch (Throwable $e) {}
// DB info + TruTrak config/cache (best-effort)
try {
$pairs = $pdo->query("SELECT `key`,`value` FROM system_info")->fetchAll(PDO::FETCH_KEY_PAIR);
$ttConfigured = !empty($pairs['trutrak_base_url'] ?? '')
&& !empty($pairs['trutrak_login'] ?? '')
&& !empty($pairs['trutrak_password'] ?? '')
&& !empty($pairs['trutrak_secret_key'] ?? '');
} catch (Throwable $e) {}
try {
$ver = (string)$pdo->query('SELECT VERSION()')->fetchColumn();
$comment = '';
try { $comment = (string)$pdo->query('SELECT @@version_comment')->fetchColumn(); } catch (Throwable $e) {}
$hint = strtolower(trim($ver . ' ' . $comment));
$product = (str_contains($hint, 'mariadb') ? 'MariaDB' : 'MySQL');
$dbInfo = trim($product . ' ' . $ver);
} catch (Throwable $e) {}
try {
$ttDevices = (int)$pdo->query("SELECT COUNT(*) FROM trutrak_device_cache")->fetchColumn();
$ttLastUpdate = $pdo->query("SELECT MAX(updated_at) FROM trutrak_device_cache")->fetchColumn();
} catch (Throwable $e) {}
// Calendar schema varies across alpha builds β try the modern schema first, then fall back.
try {
$eventCount = (int)$pdo->query("SELECT COUNT(*) FROM calendar_events WHERE start_at >= NOW()")->fetchColumn();
} catch (Throwable $e) {
try { $eventCount = (int)$pdo->query("SELECT COUNT(*) FROM calendar_events WHERE event_date >= CURDATE()")->fetchColumn(); } catch (Throwable $e2) {}
}
$featureMetrics = function_exists('wp_feature_dashboard_metrics') ? wp_feature_dashboard_metrics() : [
'rota_today' => 0,
'holiday_pending' => 0,
'holiday_mine' => 0,
'incidents_open' => 0,
'contacts_total' => 0,
'alerts_active' => 0,
'alerts_unread' => 0,
];
$featureHubCards = [
['icon' => 'π₯', 'title' => 'Staff & Permissions', 'count' => $staffCount, 'label' => 'users and role access', 'link' => app_url('admin/staff')],
['icon' => 'π
', 'title' => 'Calendar Admin', 'count' => $eventCount, 'label' => 'settings and categories', 'link' => app_url('admin/calendar')],
['icon' => 'π', 'title' => 'TruTrak Admin', 'count' => $ttDevices, 'label' => $ttConfigured ? 'connector and mapping' : 'connector not set', 'link' => app_url('admin/trutrak')],
['icon' => 'π€', 'title' => 'Exports', 'count' => null, 'label' => 'csv downloads', 'link' => app_url('admin/exports')],
['icon' => 'βοΈ', 'title' => 'Integrations', 'count' => null, 'label' => wp_onedrive_is_configured() ? 'OneDrive ready' : 'OneDrive setup', 'link' => app_url('admin/integrations')],
['icon' => 'π§°', 'title' => 'Maintenance', 'count' => null, 'label' => 'branding and updater', 'link' => app_url('admin/maintenance')],
['icon' => 'π', 'title' => 'Logs', 'count' => null, 'label' => 'activity audit', 'link' => app_url('admin/logs')],
];
$extraHead = '<style>
.feature-hub-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px;}
.feature-hub-card{display:flex;flex-direction:column;gap:14px;height:100%;padding:18px;border:1px solid var(--border);border-radius:16px;background:rgba(255,255,255,0.02);}
.feature-hub-head{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;}
.feature-hub-icon{width:48px;height:48px;border-radius:14px;display:flex;align-items:center;justify-content:center;background:rgba(255,140,26,.12);font-size:1.4rem;}
.feature-hub-title{margin:0 0 6px 0;font-size:1rem;font-weight:800;}
.feature-hub-meta{color:var(--text-muted);font-size:.92rem;}
.feature-hub-count{font-size:1.55rem;font-weight:900;line-height:1;color:var(--text-main);}
@media (max-width:1100px){.feature-hub-grid{grid-template-columns:repeat(2,minmax(0,1fr));}}
@media (max-width:640px){.feature-hub-grid{grid-template-columns:1fr;}}
</style>';
include __DIR__ . '/../partials/header.php';
?>
<div class="content-header">
<h1 class="content-title">βοΈ Administration</h1>
<p class="content-subtitle">System setup, integrations, permissions, exports and maintenance</p>
</div>
<div class="grid-3" style="margin-top:16px;">
<div class="card">
<div class="d-flex justify-between align-center">
<div>
<p class="text-muted" style="margin:0;font-size:.9rem;">App Version</p>
<h2 style="margin:8px 0 0 0;font-size:1.6rem;font-weight:900;line-height:1.1;"><?= htmlspecialchars($appVersion ?: 'β') ?></h2>
</div>
<div style="font-size:2.2rem;opacity:.6;">π¦</div>
</div>
</div>
<div class="card">
<div class="d-flex justify-between align-center">
<div>
<p class="text-muted" style="margin:0;font-size:.9rem;">Database</p>
<h2 style="margin:8px 0 0 0;font-size:1.35rem;font-weight:900;line-height:1.1;"><?= htmlspecialchars($dbInfo ?: 'β') ?></h2>
</div>
<div style="font-size:2.2rem;opacity:.6;">ποΈ</div>
</div>
</div>
<div class="card">
<div class="d-flex justify-between align-center">
<div>
<p class="text-muted" style="margin:0;font-size:.9rem;">TruTrak Cache</p>
<h2 style="margin:8px 0 0 0;font-size:1.6rem;font-weight:900;line-height:1.1;"><?= (int)$ttDevices ?> devices</h2>
<div class="text-muted" style="margin-top:6px;font-size:.9rem;">
<?= $ttConfigured ? 'Configured' : 'Not configured' ?>
<?= $ttLastUpdate ? 'β’ Updated ' . htmlspecialchars($ttLastUpdate) : '' ?>
</div>
</div>
<div style="font-size:2.2rem;opacity:.6;">π</div>
</div>
</div>
</div>
<div class="card" style="margin-top:16px;">
<div class="card-header">
<div>
<h3 class="card-title">Administration Tools</h3>
<p class="text-muted" style="margin:6px 0 0 0;">System setup only. Day-to-day rota, clock records, holidays, incidents, contacts, alerts and vehicles stay under Management.</p>
</div>
</div>
<div class="feature-hub-grid">
<?php foreach ($featureHubCards as $card): ?>
<div class="feature-hub-card">
<div class="feature-hub-head">
<div>
<h4 class="feature-hub-title"><?= e($card['title']) ?></h4>
<?php if ($card['count'] !== null): ?>
<div class="feature-hub-count"><?= (int)$card['count'] ?></div>
<?php endif; ?>
<div class="feature-hub-meta"><?= e((string)$card['label']) ?></div>
</div>
<div class="feature-hub-icon"><?= $card['icon'] ?></div>
</div>
<div style="margin-top:auto;">
<a href="<?= e($card['link']) ?>" class="btn btn-secondary">Open</a>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<div class="card" style="margin-top:24px;">
<div class="card-header">
<h3 class="card-title">π Release Notes</h3>
<p class="text-muted" style="margin:6px 0 0 0;">View what changed in each update (stored in a single <code>assets/changelog/updates.zip</code> archive). Legacy versions without recorded notes are labelled.</p>
</div>
<div class="card-body">
<?php if (!empty($updateLogs)): ?>
<div class="grid-2" style="gap:12px;align-items:end;">
<div>
<label class="text-muted" style="display:block;font-size:.85rem;margin-bottom:6px;">Latest</label>
<button type="button" class="btn btn-secondary btn-block" id="btnViewLatest">π View Latest (<?= htmlspecialchars($latestLogVersion ?: 'β') ?>)</button>
</div>
<div>
<label class="text-muted" style="display:block;font-size:.85rem;margin-bottom:6px;">History</label>
<select id="updateSelect" style="width:100%;">
<?php
$withNotes = array_values(array_filter($updateLogs, fn($x) => empty($x['legacy'])));
$legacy = array_values(array_filter($updateLogs, fn($x) => !empty($x['legacy'])));
?>
<?php if (!empty($withNotes)): ?>
<optgroup label="Release notes available">
<?php foreach ($withNotes as $l): ?>
<option value="<?= htmlspecialchars($l['version']) ?>" <?= ($l['version'] === $latestLogVersion) ? 'selected' : '' ?>><?= htmlspecialchars($l['version']) ?></option>
<?php endforeach; ?>
</optgroup>
<?php endif; ?>
<?php if (!empty($legacy)): ?>
<optgroup label="Legacy (no notes recorded)">
<?php foreach ($legacy as $l): ?>
<option value="<?= htmlspecialchars($l['version']) ?>"><?= htmlspecialchars($l['version']) ?> (legacy)</option>
<?php endforeach; ?>
</optgroup>
<?php endif; ?>
</select>
<div style="margin-top:10px;">
<button type="button" class="btn btn-secondary btn-block" id="btnViewSelected">π View Selected</button>
</div>
</div>
</div>
<?php else: ?>
<div class="text-muted">No update logs found yet in <code>assets/changelog</code>.</div>
<?php endif; ?>
</div>
</div>
<!-- Release Notes Modal -->
<div id="changelogModal" class="modal-overlay" style="display:none;">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="changelogTitle">
<div class="modal-header">
<h3 class="modal-title" id="changelogTitle">Update Log</h3>
<button class="modal-close" type="button" aria-label="Close" onclick="closeChangelog()">Γ</button>
</div>
<div class="modal-body">
<div id="changelogLegacyBanner" style="display:none; padding:12px 14px; border:1px solid rgba(255, 183, 3, .35); background: rgba(255, 183, 3, .08); border-radius: 12px; margin-bottom: 12px;">
<div style="font-weight:700; margin-bottom:4px;">Legacy build β no release notes recorded</div>
<div class="text-muted" style="font-size:.9rem; line-height:1.35;">This version existed historically but detailed release notes were not captured at the time.</div>
<div style="margin-top:10px;">
<button type="button" class="btn btn-secondary" id="btnToggleLegacyLog" style="padding:6px 10px; font-size:.85rem;">Show raw placeholder</button>
</div>
</div>
<pre id="changelogText" style="white-space: pre-wrap; margin:0;">Loadingβ¦</pre>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeChangelog()">Close</button>
</div>
</div>
</div>
<script>
let __legacyRawLog = '';
async function loadLog(version){
const titleEl = document.getElementById('changelogTitle');
const bannerEl = document.getElementById('changelogLegacyBanner');
const toggleEl = document.getElementById('btnToggleLegacyLog');
const textEl = document.getElementById('changelogText');
if (!titleEl || !textEl) return;
// Reset UI
titleEl.textContent = 'Update Log β ' + version;
textEl.textContent = 'Loadingβ¦';
__legacyRawLog = '';
if (bannerEl) bannerEl.style.display = 'none';
if (toggleEl) {
toggleEl.style.display = 'none';
toggleEl.dataset.state = 'hidden';
toggleEl.textContent = 'Show raw placeholder';
}
try {
const res = await fetch('/api/changelog?v=' + encodeURIComponent(version), { credentials: 'same-origin' });
const data = await res.json();
if (!(data && data.ok)) {
textEl.textContent = (data && data.error) ? data.error : 'Unable to load update log.';
return;
}
const isLegacy = !!data.legacy;
if (isLegacy) {
titleEl.textContent = 'Update Log β ' + version + ' (legacy)';
if (bannerEl) bannerEl.style.display = 'block';
__legacyRawLog = data.content || '';
textEl.textContent = 'No release notes were recorded for this legacy build.';
if (toggleEl) {
toggleEl.style.display = 'inline-flex';
toggleEl.onclick = () => {
const shown = toggleEl.dataset.state === 'shown';
if (shown) {
textEl.textContent = 'No release notes were recorded for this legacy build.';
toggleEl.dataset.state = 'hidden';
toggleEl.textContent = 'Show raw placeholder';
} else {
textEl.textContent = __legacyRawLog || '(empty)';
toggleEl.dataset.state = 'shown';
toggleEl.textContent = 'Hide raw placeholder';
}
};
}
} else {
textEl.textContent = data.content || '(empty)';
}
} catch(e) {
textEl.textContent = 'Unable to load update log.';
}
}
function openChangelog(){
const o = document.getElementById('changelogModal');
if (!o) return;
o.style.display = 'flex';
document.body.style.overflow = 'hidden';
}
function closeChangelog(){
const o = document.getElementById('changelogModal');
if (!o) return;
o.style.display = 'none';
document.body.style.overflow = '';
}
(function(){
const btnLatest = document.getElementById('btnViewLatest');
const btnSel = document.getElementById('btnViewSelected');
const sel = document.getElementById('updateSelect');
const latest = <?= json_encode($latestLogVersion ?: '') ?>;
if (btnLatest) {
btnLatest.addEventListener('click', async () => {
if (!latest) return;
await loadLog(latest);
openChangelog();
});
}
if (btnSel && sel) {
btnSel.addEventListener('click', async () => {
const v = sel.value;
if (!v) return;
await loadLog(v);
openChangelog();
});
}
// Close on overlay click + ESC
const o = document.getElementById('changelogModal');
if (o) {
o.addEventListener('click', (e) => { if (e.target === o) closeChangelog(); });
}
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
const cl = document.getElementById('changelogModal');
if (cl && cl.style.display === 'flex') closeChangelog();
});
})();
</script>
<?php include __DIR__ . '/../partials/footer.php'; ?>