[orbis] Weekly backup 2026-07-07 — 318 files changed, 48597 insertions(+), 7 deletions(-)

This commit is contained in:
DO Server Backup
2026-07-07 15:58:55 +00:00
parent 5fda5a1536
commit fe18800d18
318 changed files with 48597 additions and 7 deletions
@@ -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">&larr; 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> &middot; 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">&larr; 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">&larr; 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)) ?> &middot; <?= $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 &rarr;</a></div>
<?php elseif ($detail): ?>
<div class="more-link"><a href="?t=<?= e($token) ?>&week=<?= e($weekStart) ?>">&larr; 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 &amp; 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 &rarr;</a></div>
<?php elseif ($detail): ?>
<div class="more-link"><a href="?t=<?= e($token) ?>&week=<?= e($weekStart) ?>">&larr; 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">&larr; 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 &amp; 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'; ?>