mirror of
https://github.com/myronblair/do-server-config
synced 2026-07-27 21:18:32 -05:00
[orbis] Weekly backup 2026-07-07 — 318 files changed, 48597 insertions(+), 7 deletions(-)
This commit is contained in:
@@ -0,0 +1 @@
|
||||
.DS_Store
|
||||
@@ -0,0 +1,30 @@
|
||||
# Work Tracking - Security Configuration
|
||||
|
||||
Options -Indexes -Includes
|
||||
ServerSignature Off
|
||||
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
|
||||
# Block sensitive paths and file types - RewriteRule confirmed to actually
|
||||
# work on this OpenLiteSpeed setup, unlike <FilesMatch>/Order,Deny which
|
||||
# is not honored here.
|
||||
RewriteCond %{REQUEST_URI} ^/(\.git|includes/|config)
|
||||
RewriteRule .* - [F,L]
|
||||
RewriteCond %{REQUEST_URI} \.(sql|env|log|bak|backup|old|orig|tmp|swp|py)$
|
||||
RewriteRule .* - [F,L]
|
||||
|
||||
# Canonical HTTPS
|
||||
RewriteCond %{HTTPS} off
|
||||
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
|
||||
</IfModule>
|
||||
|
||||
<IfModule mod_headers.c>
|
||||
Header always set X-Content-Type-Options "nosniff"
|
||||
Header always set X-Frame-Options "DENY"
|
||||
Header always set X-XSS-Protection "1; mode=block"
|
||||
Header always set Referrer-Policy "strict-origin-when-cross-origin"
|
||||
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||
Header unset Server
|
||||
Header unset X-Powered-By
|
||||
</IfModule>
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
AdminAuth::require();
|
||||
|
||||
$rows = db()->fetchAll("
|
||||
SELECT ws.worker_id, ws.week_start, ws.paid_at, w.name
|
||||
FROM week_settlements ws
|
||||
JOIN workers w ON w.id = ws.worker_id
|
||||
WHERE ws.paid = 1
|
||||
ORDER BY ws.week_start DESC, w.name ASC
|
||||
");
|
||||
|
||||
$owedByRow = [];
|
||||
foreach ($rows as $i => $r) {
|
||||
$dates = getWeekDates($r['week_start']);
|
||||
$placeholders = implode(',', array_fill(0, count($dates), '?'));
|
||||
$entries = db()->query("SELECT * FROM day_entries WHERE worker_id = ? AND work_date IN ({$placeholders})", array_merge([$r['worker_id']], $dates))->fetchAll();
|
||||
$owedByRow[$i] = calcOwed($entries);
|
||||
}
|
||||
|
||||
$pageTitle = 'Full History - ChuckCo Time Keeper';
|
||||
require_once __DIR__ . '/../includes/header.php';
|
||||
?>
|
||||
<div class="topbar">
|
||||
<h1>Full Payment History</h1>
|
||||
<a href="/admin/index.php" class="logout">← Back</a>
|
||||
</div>
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<h3>All Paid Weeks</h3>
|
||||
<?php if (empty($rows)): ?>
|
||||
<p class="text-muted">No paid weeks yet.</p>
|
||||
<?php else: ?>
|
||||
<?php foreach ($rows as $i => $r): ?>
|
||||
<div class="worker-row">
|
||||
<a href="/admin/worker.php?id=<?= (int)$r['worker_id'] ?>" style="color:inherit; text-decoration:none;">
|
||||
<strong><?= e($r['name']) ?></strong> · Week of <?= e(dayLabel($r['week_start'])) ?>
|
||||
</a>
|
||||
<span class="worker-owed"><?= formatCurrency($owedByRow[$i] ?? 0) ?></span>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php require_once __DIR__ . '/../includes/footer.php'; ?>
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
AdminAuth::require();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'add_worker') {
|
||||
$name = trim($_POST['name'] ?? '');
|
||||
if ($name !== '') {
|
||||
db()->insert('workers', [
|
||||
'name' => $name,
|
||||
'entry_token' => generateToken(),
|
||||
'share_token' => generateToken(),
|
||||
'payer_token' => generateToken(),
|
||||
]);
|
||||
}
|
||||
header('Location: /admin/index.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'delete_worker') {
|
||||
$workerId = (int) ($_POST['worker_id'] ?? 0);
|
||||
if ($workerId > 0) {
|
||||
// day_entries and week_settlements both have ON DELETE CASCADE on worker_id,
|
||||
// so deleting the worker row removes all their related data too.
|
||||
db()->query("DELETE FROM workers WHERE id = :id", ['id' => $workerId]);
|
||||
}
|
||||
header('Location: /admin/index.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
$workers = db()->fetchAll("SELECT * FROM workers WHERE active = 1 ORDER BY name ASC");
|
||||
|
||||
$owedByWorker = [];
|
||||
$rows = db()->fetchAll("
|
||||
SELECT de.worker_id,
|
||||
SUM(CASE WHEN de.status='full' THEN " . FULL_DAY_RATE . " WHEN de.status='half' THEN " . HALF_DAY_RATE . " ELSE 0 END) AS owed
|
||||
FROM day_entries de
|
||||
LEFT JOIN week_settlements ws ON ws.worker_id = de.worker_id AND ws.week_start = de.week_start
|
||||
WHERE ws.paid IS NULL OR ws.paid = 0
|
||||
GROUP BY de.worker_id
|
||||
");
|
||||
foreach ($rows as $r) {
|
||||
$owedByWorker[$r['worker_id']] = (float) $r['owed'];
|
||||
}
|
||||
|
||||
$pageTitle = 'Admin Dashboard - ChuckCo Time Keeper';
|
||||
require_once __DIR__ . '/../includes/header.php';
|
||||
?>
|
||||
<div class="topbar">
|
||||
<h1>ChuckCo Time Keeper</h1>
|
||||
<a href="/admin/logout.php" class="logout">Log out</a>
|
||||
</div>
|
||||
<div class="container">
|
||||
|
||||
<div class="card">
|
||||
<h3>All Workers Overview</h3>
|
||||
<p class="text-muted" style="font-size:0.85rem;">One link showing everyone's current week together.</p>
|
||||
<a href="/all.php?t=<?= e(ALL_WORKERS_TOKEN) ?>" class="btn btn-secondary btn-block" target="_blank">Open All-Workers View</a>
|
||||
<a href="/admin/history.php" class="btn btn-secondary btn-block" style="margin-top:0.5rem;">Full History (Everyone)</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Add Worker</h3>
|
||||
<form method="POST">
|
||||
<input type="hidden" name="action" value="add_worker">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Name</label>
|
||||
<input type="text" name="name" class="form-input" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-block">Add Worker</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Workers</h3>
|
||||
<?php if (empty($workers)): ?>
|
||||
<p class="text-muted">No workers yet. Add one above.</p>
|
||||
<?php else: ?>
|
||||
<?php foreach ($workers as $w): ?>
|
||||
<div class="worker-row" style="display:block;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<a href="/admin/worker.php?id=<?= (int)$w['id'] ?>" class="worker-name" style="color:inherit; text-decoration:none;"><?= e($w['name']) ?></a>
|
||||
<span class="worker-owed"><?= formatCurrency($owedByWorker[$w['id']] ?? 0) ?> owed</span>
|
||||
</div>
|
||||
<div class="link-row">
|
||||
<a class="btn btn-sm btn-secondary" href="/w.php?t=<?= e($w['entry_token']) ?>" target="_blank">Entry Link</a>
|
||||
<a class="btn btn-sm btn-secondary" href="/s.php?t=<?= e($w['share_token']) ?>" target="_blank">Their Screenshot</a>
|
||||
<a class="btn btn-sm btn-secondary" href="/p.php?t=<?= e($w['payer_token']) ?>" target="_blank">My Screenshot</a>
|
||||
<form method="POST" style="display:inline;" onsubmit="return confirm('Permanently delete <?= e(addslashes($w['name'])) ?> and ALL their time entries and history? This cannot be undone.');">
|
||||
<input type="hidden" name="action" value="delete_worker">
|
||||
<input type="hidden" name="worker_id" value="<?= (int)$w['id'] ?>">
|
||||
<button type="submit" class="btn btn-sm" style="background:rgba(220,38,38,.1); color:var(--error);">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<?php require_once __DIR__ . '/../includes/footer.php'; ?>
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
|
||||
if (AdminAuth::isLoggedIn()) {
|
||||
header('Location: /admin/index.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
$error = '';
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$password = $_POST['password'] ?? '';
|
||||
if (AdminAuth::attempt($password)) {
|
||||
header('Location: /admin/index.php');
|
||||
exit;
|
||||
}
|
||||
$error = AdminAuth::isLockedOut($_SERVER['REMOTE_ADDR'] ?? 'unknown')
|
||||
? 'Too many failed attempts. Try again in 15 minutes.'
|
||||
: 'Incorrect password';
|
||||
}
|
||||
|
||||
$pageTitle = 'Login - ChuckCo Time Keeper';
|
||||
require_once __DIR__ . '/../includes/header.php';
|
||||
?>
|
||||
<div class="container" style="max-width:400px; padding-top: 4rem;">
|
||||
<div class="card">
|
||||
<h2 class="text-center">ChuckCo Time Keeper</h2>
|
||||
<?php if ($error): ?>
|
||||
<div class="alert alert-error"><?= e($error) ?></div>
|
||||
<?php endif; ?>
|
||||
<form method="POST">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Admin Password</label>
|
||||
<input type="password" name="password" class="form-input" autofocus required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-block">Log In</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<?php require_once __DIR__ . '/../includes/footer.php'; ?>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
AdminAuth::logout();
|
||||
header('Location: /admin/login.php');
|
||||
exit;
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
AdminAuth::require();
|
||||
|
||||
$workerId = (int) ($_GET['id'] ?? 0);
|
||||
$worker = db()->fetch("SELECT * FROM workers WHERE id = :id", ['id' => $workerId]);
|
||||
if (!$worker) {
|
||||
header('Location: /admin/index.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$action = $_POST['action'] ?? '';
|
||||
|
||||
if ($action === 'set_day') {
|
||||
$date = $_POST['date'] ?? '';
|
||||
$status = $_POST['status'] ?? '';
|
||||
$reason = trim($_POST['reason'] ?? '');
|
||||
$location = trim($_POST['location'] ?? '');
|
||||
if (in_array($status, ['full', 'half', 'absent'], true) && $date) {
|
||||
$weekStart = getWeekStart($date);
|
||||
db()->query("
|
||||
INSERT INTO day_entries (worker_id, work_date, week_start, status, absence_reason, location)
|
||||
VALUES (:wid, :date, :week, :status, :reason, :location)
|
||||
ON DUPLICATE KEY UPDATE status = :status2, absence_reason = :reason2, location = :location2, week_start = :week2
|
||||
", [
|
||||
'wid' => $workerId, 'date' => $date, 'week' => $weekStart,
|
||||
'status' => $status, 'reason' => $reason ?: null, 'location' => $location ?: null,
|
||||
'status2' => $status, 'reason2' => $reason ?: null, 'location2' => $location ?: null, 'week2' => $weekStart,
|
||||
]);
|
||||
}
|
||||
header('Location: /admin/worker.php?id=' . $workerId);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'clear_day') {
|
||||
$date = $_POST['date'] ?? '';
|
||||
db()->query("DELETE FROM day_entries WHERE worker_id = :wid AND work_date = :date", ['wid' => $workerId, 'date' => $date]);
|
||||
header('Location: /admin/worker.php?id=' . $workerId);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'toggle_paid') {
|
||||
$weekStart = $_POST['week_start'] ?? '';
|
||||
$existing = db()->fetch("SELECT * FROM week_settlements WHERE worker_id = :wid AND week_start = :w", ['wid' => $workerId, 'w' => $weekStart]);
|
||||
if ($existing) {
|
||||
$newPaid = $existing['paid'] ? 0 : 1;
|
||||
db()->update('week_settlements', ['paid' => $newPaid, 'paid_at' => $newPaid ? date('Y-m-d H:i:s') : null], 'id = :id', ['id' => $existing['id']]);
|
||||
} else {
|
||||
db()->insert('week_settlements', ['worker_id' => $workerId, 'week_start' => $weekStart, 'paid' => 1, 'paid_at' => date('Y-m-d H:i:s')]);
|
||||
}
|
||||
header('Location: /admin/worker.php?id=' . $workerId);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Gather all weeks that have entries, plus the current week even if empty
|
||||
$weekStarts = db()->fetchAll("SELECT DISTINCT week_start FROM day_entries WHERE worker_id = :wid ORDER BY week_start DESC", ['wid' => $workerId]);
|
||||
$weekStartList = array_column($weekStarts, 'week_start');
|
||||
if (!in_array(currentWeekStart(), $weekStartList, true)) {
|
||||
array_unshift($weekStartList, currentWeekStart());
|
||||
}
|
||||
rsort($weekStartList);
|
||||
|
||||
$settlements = db()->fetchAll("SELECT * FROM week_settlements WHERE worker_id = :wid", ['wid' => $workerId]);
|
||||
$paidMap = [];
|
||||
foreach ($settlements as $s) { $paidMap[$s['week_start']] = (bool) $s['paid']; }
|
||||
|
||||
$allEntries = db()->fetchAll("SELECT * FROM day_entries WHERE worker_id = :wid", ['wid' => $workerId]);
|
||||
$entriesByDate = [];
|
||||
foreach ($allEntries as $en) { $entriesByDate[$en['work_date']] = $en; }
|
||||
|
||||
$pageTitle = $worker['name'] . ' - ChuckCo Time Keeper';
|
||||
require_once __DIR__ . '/../includes/header.php';
|
||||
?>
|
||||
<div class="topbar">
|
||||
<h1><?= e($worker['name']) ?></h1>
|
||||
<a href="/admin/index.php" class="logout">← Back</a>
|
||||
</div>
|
||||
<div class="container">
|
||||
|
||||
<div class="card">
|
||||
<h3>Links</h3>
|
||||
<div class="link-row">
|
||||
<a class="btn btn-sm btn-secondary" href="/w.php?t=<?= e($worker['entry_token']) ?>" target="_blank">Entry Link</a>
|
||||
<a class="btn btn-sm btn-secondary" href="/s.php?t=<?= e($worker['share_token']) ?>" target="_blank">Their Screenshot</a>
|
||||
<a class="btn btn-sm btn-secondary" href="/p.php?t=<?= e($worker['payer_token']) ?>" target="_blank">My Screenshot</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php foreach ($weekStartList as $weekStart):
|
||||
$dates = getWeekDates($weekStart);
|
||||
$weekEntries = [];
|
||||
foreach ($dates as $d) { if (isset($entriesByDate[$d])) $weekEntries[] = $entriesByDate[$d]; }
|
||||
$owed = calcOwed($weekEntries);
|
||||
$isPaid = $paidMap[$weekStart] ?? false;
|
||||
?>
|
||||
<div class="card">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<h3 style="margin:0;">Week of <?= e(dayLabel($weekStart)) ?></h3>
|
||||
<span class="badge <?= $isPaid ? 'badge-full' : 'badge-absent' ?>"><?= $isPaid ? 'Paid' : 'Unpaid' ?></span>
|
||||
</div>
|
||||
|
||||
<?php foreach ($dates as $date):
|
||||
$entry = $entriesByDate[$date] ?? null;
|
||||
$status = $entry['status'] ?? null;
|
||||
?>
|
||||
<div class="day-tap" style="flex-wrap:wrap;">
|
||||
<div style="flex:1; min-width:100%;">
|
||||
<div class="date"><?= e(dayLabel($date)) ?></div>
|
||||
<?php if (!empty($entry['location'])): ?>
|
||||
<div class="reason-text"><?= e($entry['location']) ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if ($status === 'absent' && !empty($entry['absence_reason'])): ?>
|
||||
<div class="reason-text"><?= e($entry['absence_reason']) ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<form method="POST" style="width:100%; margin-top:0.5rem;">
|
||||
<input type="hidden" name="action" value="set_day">
|
||||
<input type="hidden" name="date" value="<?= e($date) ?>">
|
||||
<div class="day-btn-row">
|
||||
<button type="submit" name="status" value="full" class="day-btn <?= $status === 'full' ? 'active-full' : '' ?>">Full</button>
|
||||
<button type="submit" name="status" value="half" class="day-btn <?= $status === 'half' ? 'active-half' : '' ?>">Half</button>
|
||||
<button type="submit" name="status" value="absent" class="day-btn <?= $status === 'absent' ? 'active-absent' : '' ?>">Off</button>
|
||||
</div>
|
||||
<div class="day-field-row">
|
||||
<input type="text" name="location" class="form-input" placeholder="Location" value="<?= e($entry['location'] ?? '') ?>">
|
||||
</div>
|
||||
<div class="day-field-row">
|
||||
<input type="text" name="reason" class="form-input" placeholder="Reason if off" value="<?= e($entry['absence_reason'] ?? '') ?>">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<div class="owed-total">
|
||||
<span>Owed</span>
|
||||
<span><?= formatCurrency($owed) ?></span>
|
||||
</div>
|
||||
<form method="POST" style="margin-top:0.75rem;">
|
||||
<input type="hidden" name="action" value="toggle_paid">
|
||||
<input type="hidden" name="week_start" value="<?= e($weekStart) ?>">
|
||||
<button type="submit" class="btn <?= $isPaid ? 'btn-secondary' : 'btn-success' ?> btn-block">
|
||||
<?= $isPaid ? 'Mark as Unpaid' : 'Mark as Paid' ?>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
</div>
|
||||
<?php require_once __DIR__ . '/../includes/footer.php'; ?>
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/includes/functions.php';
|
||||
|
||||
$token = $_GET['t'] ?? '';
|
||||
if (!hash_equals(ALL_WORKERS_TOKEN, $token)) {
|
||||
http_response_code(404);
|
||||
echo 'Link not found.';
|
||||
exit;
|
||||
}
|
||||
|
||||
$weekStart = $_GET['week'] ?? '';
|
||||
$weekStart = isValidDateStr($weekStart) ? $weekStart : currentWeekStart();
|
||||
$dates = getWeekDates($weekStart);
|
||||
|
||||
$workers = db()->fetchAll("SELECT * FROM workers WHERE active = 1 ORDER BY name ASC");
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($dates), '?'));
|
||||
$allEntries = db()->query("SELECT * FROM day_entries WHERE work_date IN ({$placeholders})", $dates)->fetchAll();
|
||||
$entriesByWorkerDate = [];
|
||||
foreach ($allEntries as $en) { $entriesByWorkerDate[$en['worker_id']][$en['work_date']] = $en; }
|
||||
|
||||
$settlements = db()->fetchAll("SELECT worker_id, paid FROM week_settlements WHERE week_start = :w", ['w' => $weekStart]);
|
||||
$paidByWorker = [];
|
||||
foreach ($settlements as $s) { $paidByWorker[$s['worker_id']] = (bool) $s['paid']; }
|
||||
|
||||
$pageTitle = 'All Workers - ChuckCo Time Keeper';
|
||||
require_once __DIR__ . '/includes/header.php';
|
||||
?>
|
||||
<div class="container" style="padding-top:1.5rem;">
|
||||
<div class="card">
|
||||
<h2 class="text-center">Week of <?= e(dayLabel($weekStart)) ?></h2>
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="all-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Worker</th>
|
||||
<?php foreach ($dates as $d): ?><th><?= e((new DateTime($d))->format('D')) ?></th><?php endforeach; ?>
|
||||
<th>Owed</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($workers as $w):
|
||||
$wEntries = $entriesByWorkerDate[$w['id']] ?? [];
|
||||
$owed = calcOwed(array_values($wEntries));
|
||||
$isPaid = $paidByWorker[$w['id']] ?? false;
|
||||
?>
|
||||
<tr>
|
||||
<td><?= e($w['name']) ?></td>
|
||||
<?php foreach ($dates as $d):
|
||||
$entry = $wEntries[$d] ?? null;
|
||||
$status = $entry['status'] ?? null;
|
||||
$location = $entry['location'] ?? '';
|
||||
?>
|
||||
<td>
|
||||
<span class="badge badge-<?= $status ? e($status) : 'unset' ?>"><?= $status ? ucfirst(substr($status,0,1)) : '-' ?></span>
|
||||
<?php if ($location): ?>
|
||||
<div style="font-size:0.7rem; color:var(--text-muted); margin-top:2px; max-width:70px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;"><?= e($location) ?></div>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<?php endforeach; ?>
|
||||
<td><?= formatCurrency($owed) ?></td>
|
||||
<td><span class="badge <?= $isPaid ? 'badge-full' : 'badge-absent' ?>"><?= $isPaid ? 'Paid' : 'Unpaid' ?></span></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php require_once __DIR__ . '/includes/footer.php'; ?>
|
||||
@@ -0,0 +1,278 @@
|
||||
:root {
|
||||
--bg: #f5f6f8;
|
||||
--card: #ffffff;
|
||||
--border: #e2e5ea;
|
||||
--text: #1c1f26;
|
||||
--text-muted: #6b7280;
|
||||
--primary: #2563eb;
|
||||
--primary-dark: #1d4ed8;
|
||||
--success: #16a34a;
|
||||
--warning: #d97706;
|
||||
--error: #dc2626;
|
||||
--radius: 12px;
|
||||
--caution: #f5a623;
|
||||
--caution-dark: #1a1a1a;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 1.25rem 1rem 3rem;
|
||||
}
|
||||
|
||||
.brand-strip {
|
||||
background: var(--caution-dark);
|
||||
color: var(--caution);
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.3px;
|
||||
padding: 0.4rem;
|
||||
}
|
||||
.brand-strip span { color: #f4f4f4; }
|
||||
.brand-strip b { color: var(--caution); }
|
||||
|
||||
.topbar {
|
||||
background: var(--card);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0.9rem 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.topbar::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -4px; left: 0; right: 0;
|
||||
height: 4px;
|
||||
background: repeating-linear-gradient(
|
||||
45deg,
|
||||
var(--caution),
|
||||
var(--caution) 6px,
|
||||
var(--caution-dark) 6px,
|
||||
var(--caution-dark) 12px
|
||||
);
|
||||
}
|
||||
|
||||
.topbar h1 {
|
||||
font-size: 1.1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.topbar a.logout {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-top: none;
|
||||
border-radius: var(--radius);
|
||||
padding: 1.1rem;
|
||||
margin-bottom: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 5px;
|
||||
background: repeating-linear-gradient(
|
||||
45deg,
|
||||
var(--caution),
|
||||
var(--caution) 8px,
|
||||
var(--caution-dark) 8px,
|
||||
var(--caution-dark) 16px
|
||||
);
|
||||
}
|
||||
|
||||
.card h2, .card h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 0.65rem 1.1rem;
|
||||
border-radius: 9px;
|
||||
border: none;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-primary { background: var(--primary); color: #fff; }
|
||||
.btn-primary:hover { background: var(--primary-dark); }
|
||||
.btn-secondary { background: #eef0f3; color: var(--text); }
|
||||
.btn-success { background: var(--success); color: #fff; }
|
||||
.btn-block { display: block; width: 100%; }
|
||||
.btn-sm { padding: 0.4rem 0.75rem; font-size: 0.8rem; }
|
||||
|
||||
.form-group { margin-bottom: 0.9rem; }
|
||||
.form-label { display: block; font-size: 0.85rem; font-weight: 600; margin-bottom: 0.3rem; color: var(--text-muted); }
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.worker-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.worker-row:last-child { border-bottom: none; }
|
||||
.worker-name { font-weight: 600; }
|
||||
.worker-owed { color: var(--success); font-weight: 700; }
|
||||
|
||||
.link-row {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.55rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.badge-full { background: rgba(22,163,74,.12); color: var(--success); }
|
||||
.badge-half { background: rgba(217,119,6,.12); color: var(--warning); }
|
||||
.badge-absent { background: rgba(220,38,38,.12); color: var(--error); }
|
||||
.badge-unset { background: #eef0f3; color: var(--text-muted); }
|
||||
|
||||
.day-tap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.9rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
.day-tap .date { font-weight: 600; }
|
||||
.day-tap .actions { display: flex; gap: 0.4rem; }
|
||||
.day-btn-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
.day-btn {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: normal;
|
||||
overflow-wrap: break-word;
|
||||
padding: 0.6rem 0.3rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.day-field-row {
|
||||
width: 100%;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.day-field-row .form-input {
|
||||
width: 100%;
|
||||
}
|
||||
.day-btn.active-full { background: var(--success); color: #fff; border-color: var(--success); }
|
||||
.day-btn.active-half { background: var(--warning); color: #fff; border-color: var(--warning); }
|
||||
.day-btn.active-absent { background: var(--error); color: #fff; border-color: var(--error); }
|
||||
|
||||
.reason-text { font-size: 0.85rem; color: var(--text-muted); margin-top: 0.4rem; }
|
||||
|
||||
.text-muted { color: var(--text-muted); }
|
||||
.text-center { text-align: center; }
|
||||
|
||||
.alert {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.alert-error { background: rgba(220,38,38,.08); color: var(--error); }
|
||||
.alert-success { background: rgba(22,163,74,.08); color: var(--success); }
|
||||
|
||||
/* Screenshot page - sized for iPhone screenshot */
|
||||
.screenshot-card {
|
||||
position: relative;
|
||||
max-width: 390px;
|
||||
margin: 1rem auto;
|
||||
background: var(--card);
|
||||
border-radius: 18px;
|
||||
padding: 1.75rem 1.5rem 1.5rem;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.screenshot-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 6px;
|
||||
background: repeating-linear-gradient(
|
||||
45deg,
|
||||
var(--caution),
|
||||
var(--caution) 8px,
|
||||
var(--caution-dark) 8px,
|
||||
var(--caution-dark) 16px
|
||||
);
|
||||
}
|
||||
.screenshot-header { text-align: center; margin-bottom: 1.25rem; }
|
||||
.screenshot-header .name { font-size: 1.4rem; font-weight: 800; }
|
||||
.screenshot-header .range { color: var(--text-muted); font-size: 0.9rem; margin-top: 0.2rem; }
|
||||
.screenshot-day {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.7rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.screenshot-day:last-child { border-bottom: none; }
|
||||
.screenshot-day .date { font-weight: 600; }
|
||||
.more-link { text-align: center; margin-top: 1rem; }
|
||||
.more-link a { color: var(--primary); font-size: 0.85rem; text-decoration: none; font-weight: 600; }
|
||||
|
||||
.owed-total {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 2px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-weight: 800;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
table.all-table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
|
||||
table.all-table th, table.all-table td { padding: 0.5rem; text-align: left; border-bottom: 1px solid var(--border); }
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/includes/functions.php';
|
||||
|
||||
$token = $_GET['t'] ?? '';
|
||||
$worker = $token ? db()->fetch("SELECT * FROM workers WHERE payer_token = :t AND active = 1", ['t' => $token]) : null;
|
||||
|
||||
if (!$worker) {
|
||||
http_response_code(404);
|
||||
echo 'Link not found.';
|
||||
exit;
|
||||
}
|
||||
|
||||
$paidWeeks = db()->fetchAll("
|
||||
SELECT week_start, paid_at FROM week_settlements
|
||||
WHERE worker_id = :wid AND paid = 1
|
||||
ORDER BY week_start DESC
|
||||
", ['wid' => $worker['id']]);
|
||||
|
||||
$owedByWeek = [];
|
||||
foreach ($paidWeeks as $pw) {
|
||||
$dates = getWeekDates($pw['week_start']);
|
||||
$placeholders = implode(',', array_fill(0, count($dates), '?'));
|
||||
$entries = db()->query("SELECT * FROM day_entries WHERE worker_id = ? AND work_date IN ({$placeholders})", array_merge([$worker['id']], $dates))->fetchAll();
|
||||
$owedByWeek[$pw['week_start']] = calcOwed($entries);
|
||||
}
|
||||
|
||||
$pageTitle = 'Payment History - ChuckCo Time Keeper';
|
||||
require_once __DIR__ . '/includes/header.php';
|
||||
?>
|
||||
<div class="topbar">
|
||||
<h1><?= e($worker['name']) ?> - History</h1>
|
||||
<a href="/p.php?t=<?= e($token) ?>" class="logout">← Back</a>
|
||||
</div>
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<h3>Paid Weeks</h3>
|
||||
<?php if (empty($paidWeeks)): ?>
|
||||
<p class="text-muted">No paid weeks yet.</p>
|
||||
<?php else: ?>
|
||||
<?php foreach ($paidWeeks as $pw): ?>
|
||||
<div class="worker-row">
|
||||
<a href="/p.php?t=<?= e($token) ?>&week=<?= e($pw['week_start']) ?>" style="color:inherit; text-decoration:none;">
|
||||
Week of <?= e(dayLabel($pw['week_start'])) ?>
|
||||
</a>
|
||||
<span class="worker-owed"><?= formatCurrency($owedByWeek[$pw['week_start']] ?? 0) ?></span>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php require_once __DIR__ . '/includes/footer.php'; ?>
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/functions.php';
|
||||
|
||||
class AdminAuth {
|
||||
private const MAX_ATTEMPTS = 5;
|
||||
private const LOCKOUT_MINUTES = 15;
|
||||
|
||||
public static function attempt(string $password): bool {
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
||||
|
||||
if (self::isLockedOut($ip)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (password_verify($password, ADMIN_PASSWORD_HASH)) {
|
||||
db()->query("DELETE FROM login_attempts WHERE ip_address = :ip", ['ip' => $ip]);
|
||||
$_SESSION['wt_admin'] = true;
|
||||
session_regenerate_id(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
self::recordFailure($ip);
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function isLockedOut(string $ip): bool {
|
||||
$row = db()->fetch("SELECT * FROM login_attempts WHERE ip_address = :ip", ['ip' => $ip]);
|
||||
if (!$row || (int) $row['attempts'] < self::MAX_ATTEMPTS) {
|
||||
return false;
|
||||
}
|
||||
$lockoutUntil = strtotime($row['last_attempt']) + self::LOCKOUT_MINUTES * 60;
|
||||
return time() < $lockoutUntil;
|
||||
}
|
||||
|
||||
private static function recordFailure(string $ip): void {
|
||||
$row = db()->fetch("SELECT * FROM login_attempts WHERE ip_address = :ip", ['ip' => $ip]);
|
||||
if ($row) {
|
||||
$lockoutUntil = strtotime($row['last_attempt']) + self::LOCKOUT_MINUTES * 60;
|
||||
// Reset the counter once the previous lockout window has fully expired.
|
||||
$attempts = (time() < $lockoutUntil) ? (int) $row['attempts'] + 1 : 1;
|
||||
db()->update('login_attempts', ['attempts' => $attempts, 'last_attempt' => date('Y-m-d H:i:s')], 'ip_address = :ip', ['ip' => $ip]);
|
||||
} else {
|
||||
db()->insert('login_attempts', ['ip_address' => $ip, 'attempts' => 1, 'last_attempt' => date('Y-m-d H:i:s')]);
|
||||
}
|
||||
}
|
||||
|
||||
public static function isLoggedIn(): bool {
|
||||
return !empty($_SESSION['wt_admin']);
|
||||
}
|
||||
|
||||
public static function require(): void {
|
||||
if (!self::isLoggedIn()) {
|
||||
header('Location: /admin/login.php');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
public static function logout(): void {
|
||||
unset($_SESSION['wt_admin']);
|
||||
session_regenerate_id(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../includes/config.php';
|
||||
|
||||
class Database {
|
||||
private static ?PDO $pdo = null;
|
||||
|
||||
public static function get(): PDO {
|
||||
if (self::$pdo === null) {
|
||||
$dsn = 'mysql:host=' . DB_HOST . ';dbname=' . DB_NAME . ';charset=utf8mb4';
|
||||
self::$pdo = new PDO($dsn, DB_USER, DB_PASS, [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
}
|
||||
return self::$pdo;
|
||||
}
|
||||
|
||||
public function query(string $sql, array $params = []): PDOStatement {
|
||||
$stmt = self::get()->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
return $stmt;
|
||||
}
|
||||
|
||||
public function fetch(string $sql, array $params = []): ?array {
|
||||
$row = $this->query($sql, $params)->fetch();
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
public function fetchAll(string $sql, array $params = []): array {
|
||||
return $this->query($sql, $params)->fetchAll();
|
||||
}
|
||||
|
||||
public function insert(string $table, array $data) {
|
||||
$columns = implode(', ', array_keys($data));
|
||||
$placeholders = ':' . implode(', :', array_keys($data));
|
||||
$this->query("INSERT INTO {$table} ({$columns}) VALUES ({$placeholders})", $data);
|
||||
return self::get()->lastInsertId();
|
||||
}
|
||||
|
||||
public function update(string $table, array $data, string $where, array $whereParams = []) {
|
||||
$set = [];
|
||||
foreach (array_keys($data) as $column) {
|
||||
$set[] = "{$column} = :{$column}";
|
||||
}
|
||||
$sql = "UPDATE {$table} SET " . implode(', ', $set) . " WHERE {$where}";
|
||||
return $this->query($sql, array_merge($data, $whereParams))->rowCount();
|
||||
}
|
||||
}
|
||||
|
||||
function db(): Database {
|
||||
static $instance = null;
|
||||
if ($instance === null) {
|
||||
$instance = new Database();
|
||||
}
|
||||
return $instance;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/db.php';
|
||||
|
||||
session_start();
|
||||
|
||||
function formatCurrency(float $amount): string {
|
||||
return '$' . number_format($amount, 2);
|
||||
}
|
||||
|
||||
function generateToken(): string {
|
||||
return bin2hex(random_bytes(20));
|
||||
}
|
||||
|
||||
/**
|
||||
* Work weeks run Friday-Thursday (Sat/Sun excluded entirely, not tracked).
|
||||
* Returns the Friday date (Y-m-d) that the given date's work week belongs to.
|
||||
*/
|
||||
function getWeekStart(string $dateStr): string {
|
||||
$d = new DateTime($dateStr);
|
||||
$dow = (int) $d->format('N'); // 1=Mon ... 5=Fri, 6=Sat, 7=Sun
|
||||
if ($dow === 5) {
|
||||
// Friday itself
|
||||
return $d->format('Y-m-d');
|
||||
} elseif ($dow === 6) {
|
||||
// Saturday - previous day is Friday
|
||||
$d->modify('-1 day');
|
||||
return $d->format('Y-m-d');
|
||||
} elseif ($dow === 7) {
|
||||
// Sunday - 2 days back is Friday
|
||||
$d->modify('-2 days');
|
||||
return $d->format('Y-m-d');
|
||||
} else {
|
||||
// Mon(1)->Fri is 3 days prior; Tue(2)->4; Wed(3)->5; Thu(4)->6
|
||||
$daysBack = $dow + 2;
|
||||
$d->modify("-{$daysBack} days");
|
||||
return $d->format('Y-m-d');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the 5 workdate strings (Y-m-d) for the week starting on $weekStart (a Friday),
|
||||
* in order: Friday, Monday, Tuesday, Wednesday, Thursday.
|
||||
*/
|
||||
function getWeekDates(string $weekStart): array {
|
||||
$fri = new DateTime($weekStart);
|
||||
$dates = [$fri->format('Y-m-d')];
|
||||
$d = clone $fri;
|
||||
$d->modify('+3 days'); // Monday
|
||||
for ($i = 0; $i < 4; $i++) {
|
||||
$dates[] = $d->format('Y-m-d');
|
||||
$d->modify('+1 day');
|
||||
}
|
||||
return $dates;
|
||||
}
|
||||
|
||||
function dayLabel(string $dateStr): string {
|
||||
$d = new DateTime($dateStr);
|
||||
return $d->format('D, M j');
|
||||
}
|
||||
|
||||
function currentWeekStart(): string {
|
||||
return getWeekStart(date('Y-m-d'));
|
||||
}
|
||||
|
||||
function nextWeekStart(string $weekStart): string {
|
||||
$d = new DateTime($weekStart);
|
||||
$d->modify('+7 days');
|
||||
return $d->format('Y-m-d');
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the dollar amount owed for a set of day_entries rows.
|
||||
*/
|
||||
function calcOwed(array $entries): float {
|
||||
$total = 0.0;
|
||||
foreach ($entries as $e) {
|
||||
if ($e['status'] === 'full') $total += FULL_DAY_RATE;
|
||||
elseif ($e['status'] === 'half') $total += HALF_DAY_RATE;
|
||||
}
|
||||
return $total;
|
||||
}
|
||||
|
||||
function e(string $s): string {
|
||||
return htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a Y-m-d date string strictly (rejects malformed input that
|
||||
* would otherwise throw when passed to `new DateTime()`).
|
||||
*/
|
||||
function isValidDateStr(?string $s): bool {
|
||||
if (!$s) return false;
|
||||
$d = DateTime::createFromFormat('Y-m-d', $s);
|
||||
return $d !== false && $d->format('Y-m-d') === $s;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php $pageTitle = $pageTitle ?? 'ChuckCo Time Keeper'; ?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="apple-mobile-web-app-title" content="ChuckCo">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<title><?= e($pageTitle) ?></title>
|
||||
<link rel="stylesheet" href="/assets/style.css?v=<?= filemtime(__DIR__ . '/../assets/style.css') ?>">
|
||||
</head>
|
||||
<body>
|
||||
<div class="brand-strip">👷 <span>Chuck<b>Co</b> Time Keeper</span></div>
|
||||
@@ -0,0 +1,85 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="apple-mobile-web-app-title" content="ChuckCo">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<title>ChuckCo Time Keeper</title>
|
||||
<style>
|
||||
:root {
|
||||
--caution: #f5a623;
|
||||
--caution-dark: #1a1a1a;
|
||||
--steel: #2b2f36;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
background: var(--steel);
|
||||
overflow: hidden;
|
||||
}
|
||||
.tape {
|
||||
position: fixed;
|
||||
left: -10%;
|
||||
width: 120%;
|
||||
height: 46px;
|
||||
background: repeating-linear-gradient(
|
||||
135deg,
|
||||
var(--caution),
|
||||
var(--caution) 28px,
|
||||
var(--caution-dark) 28px,
|
||||
var(--caution-dark) 56px
|
||||
);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,.35);
|
||||
opacity: 0.9;
|
||||
}
|
||||
.tape.t1 { top: 12%; transform: rotate(-4deg); }
|
||||
.tape.t2 { top: 46%; transform: rotate(3deg); }
|
||||
.tape.t3 { bottom: 10%; transform: rotate(-3deg); }
|
||||
|
||||
.wrap {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 4.5rem;
|
||||
filter: drop-shadow(0 4px 10px rgba(0,0,0,.4));
|
||||
}
|
||||
|
||||
.wrap {
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
font-weight: 800;
|
||||
font-size: 1.5rem;
|
||||
letter-spacing: 0.5px;
|
||||
color: #f4f4f4;
|
||||
text-shadow: 0 2px 6px rgba(0,0,0,.4);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.brand span {
|
||||
color: var(--caution);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="tape t1"></div>
|
||||
<div class="tape t2"></div>
|
||||
<div class="tape t3"></div>
|
||||
<div class="wrap">
|
||||
<div class="icon">👷</div>
|
||||
<div class="brand">Chuck<span>Co</span> Time Keeper</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/includes/functions.php';
|
||||
|
||||
$token = $_GET['t'] ?? '';
|
||||
$worker = $token ? db()->fetch("SELECT * FROM workers WHERE payer_token = :t AND active = 1", ['t' => $token]) : null;
|
||||
|
||||
if (!$worker) {
|
||||
http_response_code(404);
|
||||
echo 'Link not found.';
|
||||
exit;
|
||||
}
|
||||
|
||||
$markPaidError = '';
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'mark_paid' && isValidDateStr($_POST['week_start'] ?? '')) {
|
||||
$weekStart = $_POST['week_start'];
|
||||
$weekDates = getWeekDates($weekStart);
|
||||
$ph = implode(',', array_fill(0, count($weekDates), '?'));
|
||||
$weekEntries = db()->query("SELECT work_date FROM day_entries WHERE worker_id = ? AND work_date IN ({$ph})", array_merge([$worker['id']], $weekDates))->fetchAll();
|
||||
$filledDates = array_column($weekEntries, 'work_date');
|
||||
$missing = array_diff($weekDates, $filledDates);
|
||||
|
||||
if (!empty($missing)) {
|
||||
$markPaidError = 'Cannot mark paid - ' . count($missing) . ' day(s) still need an entry.';
|
||||
} else {
|
||||
$existing = db()->fetch("SELECT * FROM week_settlements WHERE worker_id = :wid AND week_start = :w", ['wid' => $worker['id'], 'w' => $weekStart]);
|
||||
if ($existing) {
|
||||
db()->update('week_settlements', ['paid' => 1, 'paid_at' => date('Y-m-d H:i:s')], 'id = :id', ['id' => $existing['id']]);
|
||||
} else {
|
||||
db()->insert('week_settlements', ['worker_id' => $worker['id'], 'week_start' => $weekStart, 'paid' => 1, 'paid_at' => date('Y-m-d H:i:s')]);
|
||||
}
|
||||
header('Location: /p.php?t=' . urlencode($token) . '&week=' . urlencode(nextWeekStart($weekStart)));
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$weekStart = $_GET['week'] ?? '';
|
||||
$weekStart = isValidDateStr($weekStart) ? $weekStart : currentWeekStart();
|
||||
$dates = getWeekDates($weekStart);
|
||||
$detail = !empty($_GET['detail']);
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($dates), '?'));
|
||||
$entries = db()->query("SELECT * FROM day_entries WHERE worker_id = ? AND work_date IN ({$placeholders})", array_merge([$worker['id']], $dates))->fetchAll();
|
||||
$entriesByDate = [];
|
||||
foreach ($entries as $en) { $entriesByDate[$en['work_date']] = $en; }
|
||||
|
||||
$owed = calcOwed($entries);
|
||||
$settlement = db()->fetch("SELECT * FROM week_settlements WHERE worker_id = :wid AND week_start = :w", ['wid' => $worker['id'], 'w' => $weekStart]);
|
||||
$isPaid = $settlement ? (bool) $settlement['paid'] : false;
|
||||
|
||||
$hasLongReason = false;
|
||||
foreach ($entriesByDate as $en) {
|
||||
if (!empty($en['absence_reason']) && strlen($en['absence_reason']) > 40) $hasLongReason = true;
|
||||
}
|
||||
|
||||
$pageTitle = 'Week Summary (Payer) - ChuckCo Time Keeper';
|
||||
require_once __DIR__ . '/includes/header.php';
|
||||
?>
|
||||
<div class="container" style="padding-top:1.5rem;">
|
||||
<div class="screenshot-card">
|
||||
<div class="screenshot-header">
|
||||
<div class="name"><?= e($worker['name']) ?></div>
|
||||
<div class="range">Week of <?= e(dayLabel($weekStart)) ?> · <?= $isPaid ? 'Paid' : 'Unpaid' ?></div>
|
||||
</div>
|
||||
|
||||
<?php foreach ($dates as $date):
|
||||
$entry = $entriesByDate[$date] ?? null;
|
||||
$status = $entry['status'] ?? null;
|
||||
$reason = $entry['absence_reason'] ?? '';
|
||||
$location = $entry['location'] ?? '';
|
||||
?>
|
||||
<div class="screenshot-day">
|
||||
<div>
|
||||
<div class="date"><?= e(dayLabel($date)) ?></div>
|
||||
<?php if ($location): ?>
|
||||
<div class="reason-text"><?= e($location) ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if ($status === 'absent' && $reason): ?>
|
||||
<div class="reason-text"><?= e($detail ? $reason : (strlen($reason) > 40 ? substr($reason, 0, 40) . '…' : $reason)) ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<span class="badge badge-<?= $status ? e($status) : 'unset' ?>"><?= $status ? ucfirst($status) : 'Not set' ?></span>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<div class="owed-total">
|
||||
<span>Owed</span>
|
||||
<span><?= formatCurrency($owed) ?></span>
|
||||
</div>
|
||||
|
||||
<?php if ($hasLongReason && !$detail): ?>
|
||||
<div class="more-link"><a href="?t=<?= e($token) ?>&week=<?= e($weekStart) ?>&detail=1">More detail →</a></div>
|
||||
<?php elseif ($detail): ?>
|
||||
<div class="more-link"><a href="?t=<?= e($token) ?>&week=<?= e($weekStart) ?>">← Back</a></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($markPaidError): ?>
|
||||
<div class="alert alert-error" style="margin-top:1rem;"><?= e($markPaidError) ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if (!$isPaid): ?>
|
||||
<form method="POST" style="margin-top:1rem;" onsubmit="return confirm('Time will be locked in and start new week. Are you sure?');">
|
||||
<input type="hidden" name="action" value="mark_paid">
|
||||
<input type="hidden" name="week_start" value="<?= e($weekStart) ?>">
|
||||
<button type="submit" class="btn btn-success btn-block">Mark Paid & Advance to Next Week</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<div class="link-row" style="margin-top:0.75rem; justify-content:center;">
|
||||
<a class="btn btn-sm btn-secondary" href="/history.php?t=<?= e($token) ?>">View History</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php require_once __DIR__ . '/includes/footer.php'; ?>
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/includes/functions.php';
|
||||
|
||||
$token = $_GET['t'] ?? '';
|
||||
$worker = $token ? db()->fetch("SELECT * FROM workers WHERE share_token = :t AND active = 1", ['t' => $token]) : null;
|
||||
|
||||
if (!$worker) {
|
||||
http_response_code(404);
|
||||
echo 'Link not found.';
|
||||
exit;
|
||||
}
|
||||
|
||||
$weekStart = $_GET['week'] ?? '';
|
||||
$weekStart = isValidDateStr($weekStart) ? $weekStart : currentWeekStart();
|
||||
$dates = getWeekDates($weekStart);
|
||||
$detail = !empty($_GET['detail']);
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($dates), '?'));
|
||||
$entries = db()->query("SELECT * FROM day_entries WHERE worker_id = ? AND work_date IN ({$placeholders})", array_merge([$worker['id']], $dates))->fetchAll();
|
||||
$entriesByDate = [];
|
||||
foreach ($entries as $en) { $entriesByDate[$en['work_date']] = $en; }
|
||||
|
||||
$hasLongReason = false;
|
||||
foreach ($entriesByDate as $en) {
|
||||
if (!empty($en['absence_reason']) && strlen($en['absence_reason']) > 40) $hasLongReason = true;
|
||||
}
|
||||
|
||||
$pageTitle = 'Week Summary - ChuckCo Time Keeper';
|
||||
require_once __DIR__ . '/includes/header.php';
|
||||
?>
|
||||
<div class="container" style="padding-top:1.5rem;">
|
||||
<div class="screenshot-card">
|
||||
<div class="screenshot-header">
|
||||
<div class="name"><?= e($worker['name']) ?></div>
|
||||
<div class="range">Week of <?= e(dayLabel($weekStart)) ?></div>
|
||||
</div>
|
||||
|
||||
<?php foreach ($dates as $date):
|
||||
$entry = $entriesByDate[$date] ?? null;
|
||||
$status = $entry['status'] ?? null;
|
||||
$reason = $entry['absence_reason'] ?? '';
|
||||
$location = $entry['location'] ?? '';
|
||||
?>
|
||||
<div class="screenshot-day">
|
||||
<div>
|
||||
<div class="date"><?= e(dayLabel($date)) ?></div>
|
||||
<?php if ($location): ?>
|
||||
<div class="reason-text"><?= e($location) ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if ($status === 'absent' && $reason): ?>
|
||||
<div class="reason-text"><?= e($detail ? $reason : (strlen($reason) > 40 ? substr($reason, 0, 40) . '…' : $reason)) ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<span class="badge badge-<?= $status ? e($status) : 'unset' ?>"><?= $status ? ucfirst($status) : 'Not set' ?></span>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if ($hasLongReason && !$detail): ?>
|
||||
<div class="more-link"><a href="?t=<?= e($token) ?>&week=<?= e($weekStart) ?>&detail=1">More detail →</a></div>
|
||||
<?php elseif ($detail): ?>
|
||||
<div class="more-link"><a href="?t=<?= e($token) ?>&week=<?= e($weekStart) ?>">← Back</a></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php require_once __DIR__ . '/includes/footer.php'; ?>
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/includes/functions.php';
|
||||
|
||||
$token = $_GET['t'] ?? '';
|
||||
$worker = $token ? db()->fetch("SELECT * FROM workers WHERE entry_token = :t AND active = 1", ['t' => $token]) : null;
|
||||
|
||||
if (!$worker) {
|
||||
http_response_code(404);
|
||||
echo 'Link not found.';
|
||||
exit;
|
||||
}
|
||||
|
||||
$paidWeeks = db()->fetchAll("
|
||||
SELECT week_start, paid_at FROM week_settlements
|
||||
WHERE worker_id = :wid AND paid = 1
|
||||
ORDER BY week_start DESC
|
||||
", ['wid' => $worker['id']]);
|
||||
|
||||
$pageTitle = 'My History - ChuckCo Time Keeper';
|
||||
require_once __DIR__ . '/includes/header.php';
|
||||
?>
|
||||
<div class="topbar">
|
||||
<h1>My History</h1>
|
||||
<a href="/w.php?t=<?= e($token) ?>" class="logout">← Back</a>
|
||||
</div>
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<h3>Paid Weeks</h3>
|
||||
<?php if (empty($paidWeeks)): ?>
|
||||
<p class="text-muted">No paid weeks yet.</p>
|
||||
<?php else: ?>
|
||||
<?php foreach ($paidWeeks as $pw): ?>
|
||||
<?php
|
||||
$dates = getWeekDates($pw['week_start']);
|
||||
$placeholders = implode(',', array_fill(0, count($dates), '?'));
|
||||
$entries = db()->query("SELECT * FROM day_entries WHERE worker_id = ? AND work_date IN ({$placeholders})", array_merge([$worker['id']], $dates))->fetchAll();
|
||||
$entriesByDate = [];
|
||||
foreach ($entries as $en) { $entriesByDate[$en['work_date']] = $en; }
|
||||
?>
|
||||
<div class="card" style="background:var(--bg); box-shadow:none;">
|
||||
<h4 style="margin-top:0;">Week of <?= e(dayLabel($pw['week_start'])) ?></h4>
|
||||
<?php foreach ($dates as $d):
|
||||
$entry = $entriesByDate[$d] ?? null;
|
||||
if (!$entry) continue;
|
||||
?>
|
||||
<div class="screenshot-day">
|
||||
<div>
|
||||
<div class="date"><?= e(dayLabel($d)) ?></div>
|
||||
<?php if (!empty($entry['location'])): ?>
|
||||
<div class="reason-text"><?= e($entry['location']) ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<span class="badge badge-<?= e($entry['status']) ?>"><?= ucfirst($entry['status']) ?></span>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php require_once __DIR__ . '/includes/footer.php'; ?>
|
||||
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/includes/functions.php';
|
||||
|
||||
$token = $_GET['t'] ?? '';
|
||||
$worker = $token ? db()->fetch("SELECT * FROM workers WHERE entry_token = :t AND active = 1", ['t' => $token]) : null;
|
||||
|
||||
if (!$worker) {
|
||||
http_response_code(404);
|
||||
echo 'Link not found.';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'set_day') {
|
||||
$date = $_POST['date'] ?? '';
|
||||
$status = $_POST['status'] ?? '';
|
||||
$reason = trim($_POST['reason'] ?? '');
|
||||
$location = trim($_POST['location'] ?? '');
|
||||
$currentWeek = currentWeekStart();
|
||||
// Only allow editing the current week's dates via this self-entry page
|
||||
if (in_array($status, ['full', 'half', 'absent'], true) && in_array($date, getWeekDates($currentWeek), true)) {
|
||||
db()->query("
|
||||
INSERT INTO day_entries (worker_id, work_date, week_start, status, absence_reason, location)
|
||||
VALUES (:wid, :date, :week, :status, :reason, :location)
|
||||
ON DUPLICATE KEY UPDATE status = :status2, absence_reason = :reason2, location = :location2
|
||||
", [
|
||||
'wid' => $worker['id'], 'date' => $date, 'week' => $currentWeek,
|
||||
'status' => $status, 'reason' => $reason ?: null, 'location' => $location ?: null,
|
||||
'status2' => $status, 'reason2' => $reason ?: null, 'location2' => $location ?: null,
|
||||
]);
|
||||
}
|
||||
header('Location: /w.php?t=' . urlencode($token));
|
||||
exit;
|
||||
}
|
||||
|
||||
$markPaidError = '';
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'mark_paid' && ($_POST['week_start'] ?? '') === currentWeekStart()) {
|
||||
$weekStart = currentWeekStart();
|
||||
$weekDates = getWeekDates($weekStart);
|
||||
$ph = implode(',', array_fill(0, count($weekDates), '?'));
|
||||
$weekEntries = db()->query("SELECT work_date FROM day_entries WHERE worker_id = ? AND work_date IN ({$ph})", array_merge([$worker['id']], $weekDates))->fetchAll();
|
||||
$filledDates = array_column($weekEntries, 'work_date');
|
||||
$missing = array_diff($weekDates, $filledDates);
|
||||
|
||||
if (!empty($missing)) {
|
||||
$markPaidError = 'Cannot mark paid - ' . count($missing) . ' day(s) still need an entry.';
|
||||
} else {
|
||||
$existing = db()->fetch("SELECT * FROM week_settlements WHERE worker_id = :wid AND week_start = :w", ['wid' => $worker['id'], 'w' => $weekStart]);
|
||||
if ($existing) {
|
||||
db()->update('week_settlements', ['paid' => 1, 'paid_at' => date('Y-m-d H:i:s')], 'id = :id', ['id' => $existing['id']]);
|
||||
} else {
|
||||
db()->insert('week_settlements', ['worker_id' => $worker['id'], 'week_start' => $weekStart, 'paid' => 1, 'paid_at' => date('Y-m-d H:i:s')]);
|
||||
}
|
||||
header('Location: /w.php?t=' . urlencode($token));
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$currentWeek = currentWeekStart();
|
||||
$currentDates = getWeekDates($currentWeek);
|
||||
|
||||
$currentSettlement = db()->fetch("SELECT * FROM week_settlements WHERE worker_id = :wid AND week_start = :w", ['wid' => $worker['id'], 'w' => $currentWeek]);
|
||||
$currentWeekPaid = $currentSettlement ? (bool) $currentSettlement['paid'] : false;
|
||||
|
||||
$allEntries = db()->fetchAll("SELECT * FROM day_entries WHERE worker_id = :wid ORDER BY work_date DESC", ['wid' => $worker['id']]);
|
||||
$entriesByDate = [];
|
||||
foreach ($allEntries as $en) { $entriesByDate[$en['work_date']] = $en; }
|
||||
|
||||
// Past 2 weeks (read-only), excluding current
|
||||
$pastWeeks = [];
|
||||
foreach ($allEntries as $en) {
|
||||
if ($en['week_start'] !== $currentWeek && !in_array($en['week_start'], $pastWeeks, true)) {
|
||||
$pastWeeks[] = $en['week_start'];
|
||||
}
|
||||
}
|
||||
rsort($pastWeeks);
|
||||
$pastWeeks = array_slice($pastWeeks, 0, 2);
|
||||
|
||||
$pageTitle = 'My Work Week - ChuckCo Time Keeper';
|
||||
require_once __DIR__ . '/includes/header.php';
|
||||
?>
|
||||
<div class="topbar">
|
||||
<h1>Hi, <?= e($worker['name']) ?></h1>
|
||||
</div>
|
||||
<div class="container">
|
||||
|
||||
<div class="card">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<h3 style="margin:0;">This Week — <?= e(dayLabel($currentWeek)) ?></h3>
|
||||
<?php if ($currentWeekPaid): ?>
|
||||
<span class="badge badge-full">Paid</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if ($currentWeekPaid): ?>
|
||||
<?php foreach ($currentDates as $date):
|
||||
$entry = $entriesByDate[$date] ?? null;
|
||||
if (!$entry) continue;
|
||||
?>
|
||||
<div class="screenshot-day">
|
||||
<div>
|
||||
<div class="date"><?= e(dayLabel($date)) ?></div>
|
||||
<?php if (!empty($entry['location'])): ?>
|
||||
<div class="reason-text"><?= e($entry['location']) ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<span class="badge badge-<?= e($entry['status']) ?>"><?= ucfirst($entry['status']) ?></span>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<p class="text-muted" style="font-size:0.85rem; margin-top:0.75rem;">This week is locked in. Come back next week to enter new days.</p>
|
||||
<?php else: ?>
|
||||
<?php foreach ($currentDates as $date):
|
||||
$entry = $entriesByDate[$date] ?? null;
|
||||
$status = $entry['status'] ?? null;
|
||||
$reason = $entry['absence_reason'] ?? '';
|
||||
$location = $entry['location'] ?? '';
|
||||
?>
|
||||
<div class="day-tap" style="flex-wrap:wrap;">
|
||||
<div style="flex:1; min-width:100%;">
|
||||
<div class="date"><?= e(dayLabel($date)) ?></div>
|
||||
</div>
|
||||
<form method="POST" style="width:100%; margin-top:0.5rem;">
|
||||
<input type="hidden" name="action" value="set_day">
|
||||
<input type="hidden" name="date" value="<?= e($date) ?>">
|
||||
<div class="day-btn-row">
|
||||
<button type="submit" name="status" value="full" class="day-btn <?= $status === 'full' ? 'active-full' : '' ?>">Full Day</button>
|
||||
<button type="submit" name="status" value="half" class="day-btn <?= $status === 'half' ? 'active-half' : '' ?>">Half Day</button>
|
||||
<button type="submit" name="status" value="absent" class="day-btn <?= $status === 'absent' ? 'active-absent' : '' ?>">Off</button>
|
||||
</div>
|
||||
<div class="day-field-row">
|
||||
<input type="text" name="location" class="form-input" placeholder="Location" value="<?= e($location) ?>">
|
||||
</div>
|
||||
<div class="day-field-row">
|
||||
<input type="text" name="reason" class="form-input" placeholder="Reason if off (optional)" value="<?= e($reason) ?>">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if ($markPaidError): ?>
|
||||
<div class="alert alert-error" style="margin-top:1rem;"><?= e($markPaidError) ?></div>
|
||||
<?php endif; ?>
|
||||
<form method="POST" style="margin-top:1rem;" onsubmit="return confirm('Time will be locked in and start new week. Are you sure?');">
|
||||
<input type="hidden" name="action" value="mark_paid">
|
||||
<input type="hidden" name="week_start" value="<?= e($currentWeek) ?>">
|
||||
<button type="submit" class="btn btn-success btn-block">Mark Paid & Lock In Week</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="link-row" style="margin-top:0.75rem; justify-content:center;">
|
||||
<a class="btn btn-sm btn-secondary" href="/w-history.php?t=<?= e($token) ?>">View History</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php foreach ($pastWeeks as $weekStart):
|
||||
$dates = getWeekDates($weekStart);
|
||||
?>
|
||||
<div class="card">
|
||||
<h3>Week of <?= e(dayLabel($weekStart)) ?></h3>
|
||||
<?php foreach ($dates as $date):
|
||||
$entry = $entriesByDate[$date] ?? null;
|
||||
if (!$entry) continue;
|
||||
?>
|
||||
<div class="screenshot-day">
|
||||
<div>
|
||||
<div class="date"><?= e(dayLabel($date)) ?></div>
|
||||
<?php if (!empty($entry['location'])): ?>
|
||||
<div class="reason-text"><?= e($entry['location']) ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<span class="badge badge-<?= e($entry['status']) ?>"><?= ucfirst($entry['status']) ?></span>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
</div>
|
||||
<?php require_once __DIR__ . '/includes/footer.php'; ?>
|
||||
Reference in New Issue
Block a user