Auto commit: 2026-04-21T09:45:25.562Z

This commit is contained in:
Flatlogic Bot 2026-04-21 09:45:25 +00:00
parent d5e1fcddeb
commit 8141b27040
2 changed files with 212 additions and 17 deletions

112
app.php
View File

@ -244,20 +244,116 @@ function create_request(array $input): int
return (int) db()->lastInsertId();
}
function fetch_requests(?string $status = null): array
function normalize_request_status(?string $status): ?string
{
ensure_schema();
if ($status !== null && $status !== '' && array_key_exists($status, request_status_options())) {
$stmt = db()->prepare('SELECT * FROM project_requests WHERE status = :status ORDER BY created_at DESC, id DESC');
$stmt->bindValue(':status', $status);
$stmt->execute();
return $stmt->fetchAll();
$status = trim((string) $status);
if ($status === '' || !array_key_exists($status, request_status_options())) {
return null;
}
$stmt = db()->query('SELECT * FROM project_requests ORDER BY created_at DESC, id DESC');
return $status;
}
function normalize_request_search(string $search): string
{
$search = preg_replace('/\s+/', ' ', trim($search));
return app_substr((string) $search, 0, 120);
}
function request_search_reference_id(string $search): ?int
{
$search = strtoupper(trim($search));
if ($search === '') {
return null;
}
if (preg_match('/^REQ-(\d{1,10})$/', $search, $matches) === 1) {
return (int) $matches[1];
}
if (ctype_digit($search)) {
return (int) $search;
}
return null;
}
function build_request_filters(?string $status = null, string $search = ''): array
{
$where = [];
$bindings = [];
$status = normalize_request_status($status);
if ($status !== null) {
$where[] = 'status = :status';
$bindings[':status'] = ['value' => $status, 'type' => PDO::PARAM_STR];
}
$search = normalize_request_search($search);
if ($search !== '') {
$searchConditions = [
'name LIKE :search',
'email LIKE :search',
'company LIKE :search',
'project_type LIKE :search',
'details LIKE :search',
];
$bindings[':search'] = ['value' => '%' . $search . '%', 'type' => PDO::PARAM_STR];
$referenceId = request_search_reference_id($search);
if ($referenceId !== null) {
$searchConditions[] = 'id = :search_id';
$bindings[':search_id'] = ['value' => $referenceId, 'type' => PDO::PARAM_INT];
}
$where[] = '(' . implode(' OR ', $searchConditions) . ')';
}
return [
'where_sql' => $where ? ' WHERE ' . implode(' AND ', $where) : '',
'bindings' => $bindings,
];
}
function bind_request_query_values(PDOStatement $stmt, array $bindings): void
{
foreach ($bindings as $placeholder => $binding) {
$stmt->bindValue($placeholder, $binding['value'], $binding['type']);
}
}
function fetch_requests(?string $status = null, string $search = '', int $limit = 0, int $offset = 0): array
{
ensure_schema();
$filters = build_request_filters($status, $search);
$sql = 'SELECT * FROM project_requests' . $filters['where_sql'] . ' ORDER BY created_at DESC, id DESC';
if ($limit > 0) {
$sql .= ' LIMIT :limit OFFSET :offset';
}
$stmt = db()->prepare($sql);
bind_request_query_values($stmt, $filters['bindings']);
if ($limit > 0) {
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->bindValue(':offset', max(0, $offset), PDO::PARAM_INT);
}
$stmt->execute();
return $stmt->fetchAll();
}
function count_requests(?string $status = null, string $search = ''): int
{
ensure_schema();
$filters = build_request_filters($status, $search);
$stmt = db()->prepare('SELECT COUNT(*) FROM project_requests' . $filters['where_sql']);
bind_request_query_values($stmt, $filters['bindings']);
$stmt->execute();
return (int) $stmt->fetchColumn();
}
function fetch_request(int $id): ?array
{
ensure_schema();

View File

@ -4,9 +4,42 @@ declare(strict_types=1);
require_once __DIR__ . '/app.php';
require_admin();
$statusFilter = trim((string) ($_GET['status'] ?? ''));
$requests = fetch_requests($statusFilter !== '' ? $statusFilter : null);
$statusFilter = normalize_request_status((string) ($_GET['status'] ?? '')) ?? '';
$searchQuery = normalize_request_search((string) ($_GET['q'] ?? ''));
$page = max(1, (int) ($_GET['page'] ?? 1));
$perPage = 10;
$totalRequests = count_requests($statusFilter !== '' ? $statusFilter : null, $searchQuery);
$totalPages = max(1, (int) ceil(max($totalRequests, 1) / $perPage));
if ($page > $totalPages) {
$page = $totalPages;
}
$offset = ($page - 1) * $perPage;
$requests = fetch_requests($statusFilter !== '' ? $statusFilter : null, $searchQuery, $perPage, $offset);
$metrics = dashboard_metrics();
$showingStart = $totalRequests > 0 ? $offset + 1 : 0;
$showingEnd = $totalRequests > 0 ? $offset + count($requests) : 0;
$hasActiveFilters = $statusFilter !== '' || $searchQuery !== '';
$buildDashboardUrl = static function (array $overrides = []) use ($statusFilter, $searchQuery, $page): string {
$params = [
'status' => $statusFilter,
'q' => $searchQuery,
'page' => $page,
];
foreach ($overrides as $key => $value) {
$params[$key] = $value;
}
foreach ($params as $key => $value) {
if ($value === '' || $value === null || ($key === 'page' && (int) $value <= 1)) {
unset($params[$key]);
}
}
$query = http_build_query($params);
return '/dashboard.php' . ($query !== '' ? '?' . $query : '');
};
render_head(project_name() . ' | Dashboard', 'Admin dashboard for reviewing incoming project requests.', true);
render_navbar('dashboard');
@ -19,7 +52,7 @@ render_flash_toast();
<div>
<div class="section-kicker">Dashboard</div>
<h1 class="h2 mb-2">Project request queue</h1>
<p class="text-secondary mb-0">Review the latest submissions, filter by status, and open each request detail page.</p>
<p class="text-secondary mb-0">Review the latest submissions, search contacts or companies, filter by status, and open each request detail page.</p>
</div>
<a class="btn btn-dark" href="/#request-form">Create another request</a>
</div>
@ -45,21 +78,61 @@ render_flash_toast();
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3 mb-3">
<div>
<h2 class="h4 mb-1">All requests</h2>
<p class="text-secondary mb-0">Thin-slice admin table with filters and drill-down details.</p>
<p class="text-secondary mb-0">Search by reference, name, email, company, or project type, then paginate through the queue.</p>
</div>
<div class="d-flex flex-wrap gap-2">
<a href="/dashboard.php" class="btn btn-sm <?= $statusFilter === '' ? 'btn-dark' : 'btn-outline-secondary' ?>">All</a>
<a href="<?= e($buildDashboardUrl(['status' => '', 'page' => 1])) ?>" class="btn btn-sm <?= $statusFilter === '' ? 'btn-dark' : 'btn-outline-secondary' ?>">All</a>
<?php foreach (request_status_options() as $value => $label): ?>
<a href="/dashboard.php?status=<?= urlencode($value) ?>" class="btn btn-sm <?= $statusFilter === $value ? 'btn-dark' : 'btn-outline-secondary' ?>"><?= e($label) ?></a>
<a href="<?= e($buildDashboardUrl(['status' => $value, 'page' => 1])) ?>" class="btn btn-sm <?= $statusFilter === $value ? 'btn-dark' : 'btn-outline-secondary' ?>"><?= e($label) ?></a>
<?php endforeach; ?>
</div>
</div>
<div class="panel-card mb-3">
<form method="get" class="row g-3 align-items-end" role="search">
<div class="col-lg-7">
<label class="form-label" for="q">Search requests</label>
<input class="form-control" id="q" name="q" type="search" value="<?= e($searchQuery) ?>" placeholder="Try REQ-0001, name, email, company, or project type">
</div>
<div class="col-md-4 col-lg-3">
<label class="form-label" for="status">Status</label>
<select class="form-select" id="status" name="status">
<option value="">All statuses</option>
<?php foreach (request_status_options() as $value => $label): ?>
<option value="<?= e($value) ?>" <?= $statusFilter === $value ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-8 col-lg-2 d-grid d-md-flex gap-2">
<button class="btn btn-dark flex-fill" type="submit">Apply</button>
<?php if ($hasActiveFilters): ?>
<a class="btn btn-outline-secondary flex-fill" href="/dashboard.php">Reset</a>
<?php endif; ?>
</div>
</form>
</div>
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-2 mb-3 small text-secondary">
<div>
Showing <?= e((string) $showingStart) ?><?= e((string) $showingEnd) ?> of <?= e((string) $totalRequests) ?> request<?= $totalRequests === 1 ? '' : 's' ?>.
</div>
<?php if ($hasActiveFilters): ?>
<div>Filtered results are based on your current search and status selection.</div>
<?php endif; ?>
</div>
<div class="panel-card p-0 overflow-hidden">
<?php if (!$requests): ?>
<div class="empty-state p-5 text-center">
<h3 class="h5 mb-2">No requests yet</h3>
<p class="text-secondary mb-3">Submit the first project request from the home page to populate the queue.</p>
<a class="btn btn-dark btn-sm" href="/#request-form">Create the first request</a>
<?php if ($hasActiveFilters): ?>
<h3 class="h5 mb-2">No matching requests</h3>
<p class="text-secondary mb-3">Try a different keyword or clear the filters to see the full queue again.</p>
<a class="btn btn-dark btn-sm" href="/dashboard.php">Clear filters</a>
<?php else: ?>
<h3 class="h5 mb-2">No requests yet</h3>
<p class="text-secondary mb-3">Submit the first project request from the home page to populate the queue.</p>
<a class="btn btn-dark btn-sm" href="/#request-form">Create the first request</a>
<?php endif; ?>
</div>
<?php else: ?>
<div class="table-responsive">
@ -82,6 +155,9 @@ render_flash_toast();
<td>
<div><?= e($request['name']) ?></div>
<div class="small text-secondary"><?= e($request['email']) ?></div>
<?php if (!empty($request['company'])): ?>
<div class="small text-secondary"><?= e((string) $request['company']) ?></div>
<?php endif; ?>
</td>
<td><?= e($request['project_type']) ?></td>
<td><?= e(timeline_options()[$request['timeline']] ?? $request['timeline']) ?></td>
@ -93,6 +169,29 @@ render_flash_toast();
</tbody>
</table>
</div>
<?php if ($totalPages > 1): ?>
<?php
$windowStart = max(1, $page - 2);
$windowEnd = min($totalPages, $windowStart + 4);
$windowStart = max(1, $windowEnd - 4);
?>
<nav class="border-top px-3 py-3" aria-label="Request pagination">
<ul class="pagination pagination-sm flex-wrap justify-content-center justify-content-md-end gap-1 mb-0">
<li class="page-item <?= $page <= 1 ? 'disabled' : '' ?>">
<a class="page-link" href="<?= e($buildDashboardUrl(['page' => max(1, $page - 1)])) ?>" <?= $page <= 1 ? 'tabindex="-1" aria-disabled="true"' : '' ?>>Previous</a>
</li>
<?php for ($pageNumber = $windowStart; $pageNumber <= $windowEnd; $pageNumber++): ?>
<li class="page-item <?= $pageNumber === $page ? 'active' : '' ?>">
<a class="page-link" href="<?= e($buildDashboardUrl(['page' => $pageNumber])) ?>"><?= e((string) $pageNumber) ?></a>
</li>
<?php endfor; ?>
<li class="page-item <?= $page >= $totalPages ? 'disabled' : '' ?>">
<a class="page-link" href="<?= e($buildDashboardUrl(['page' => min($totalPages, $page + 1)])) ?>" <?= $page >= $totalPages ? 'tabindex="-1" aria-disabled="true"' : '' ?>>Next</a>
</li>
</ul>
</nav>
<?php endif; ?>
<?php endif; ?>
</div>
</div>