First pass of features
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
$rawEmbed = defined('GOOGLE_CALENDAR_EMBED_CODE') ? trim((string) GOOGLE_CALENDAR_EMBED_CODE) : '';
|
||||
$calId = defined('GOOGLE_CALENDAR_ID') ? trim((string) GOOGLE_CALENDAR_ID) : '';
|
||||
|
||||
/**
|
||||
* @return string|null HTTPS embed URL restricted to Google Calendar hosts
|
||||
*/
|
||||
function familyHub_google_calendar_embed_url(string $raw): ?string {
|
||||
$raw = trim($raw);
|
||||
if ($raw === '') {
|
||||
return null;
|
||||
}
|
||||
$allowed = static function (string $u): bool {
|
||||
$p = parse_url($u);
|
||||
if ($p === false || empty($p['scheme']) || empty($p['host'])) {
|
||||
return false;
|
||||
}
|
||||
if (strtolower($p['scheme']) !== 'https') {
|
||||
return false;
|
||||
}
|
||||
$h = strtolower($p['host']);
|
||||
if ($h === 'calendar.google.com') {
|
||||
return true;
|
||||
}
|
||||
if ($h === 'www.google.com' && isset($p['path']) && str_starts_with($p['path'], '/calendar')) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
if ($allowed($raw)) {
|
||||
return $raw;
|
||||
}
|
||||
if (preg_match('#<iframe\s[^>]*\ssrc\s*=\s*["\']([^"\']+)["\']#i', $raw, $m)) {
|
||||
$u = html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
return $allowed($u) ? $u : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
$embedUrl = familyHub_google_calendar_embed_url($rawEmbed);
|
||||
$fromIdOnly = false;
|
||||
if ($embedUrl === null && $calId !== '' && $calId !== 'your_calendar_id_here') {
|
||||
$embedUrl = 'https://calendar.google.com/calendar/embed?src=' . rawurlencode($calId);
|
||||
$fromIdOnly = true;
|
||||
}
|
||||
?>
|
||||
|
||||
<div id="calendar" class="tab-content">
|
||||
<h2 class="mb-2">Calendar</h2>
|
||||
<?php if ($embedUrl === null): ?>
|
||||
<p class="text-muted">Connect a shared family calendar by setting <strong>GOOGLE_CALENDAR_EMBED_CODE</strong> (embed URL or full <code><iframe></code> from Google Calendar) or <strong>GOOGLE_CALENDAR_ID</strong> in <code>.env</code>. See <a href="https://calendar.google.com/">Google Calendar</a> → calendar menu → <strong>Settings and sharing</strong> → <strong>Integrate calendar</strong>.</p>
|
||||
<?php else: ?>
|
||||
<?php if ($fromIdOnly): ?>
|
||||
<p class="small text-muted mb-2">Showing calendar from <code>GOOGLE_CALENDAR_ID</code>. For more control (view, height), paste the embed URL or iframe into <code>GOOGLE_CALENDAR_EMBED_CODE</code> instead.</p>
|
||||
<?php endif; ?>
|
||||
<div class="calendar-embed rounded border overflow-hidden bg-white">
|
||||
<iframe
|
||||
class="w-100 border-0 d-block"
|
||||
style="height: 70vh; min-height: 560px;"
|
||||
src="<?= htmlspecialchars($embedUrl, ENT_QUOTES, 'UTF-8') ?>"
|
||||
loading="lazy"
|
||||
title="Family calendar"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
+302
-14
@@ -1,25 +1,313 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/db.php';
|
||||
require_once __DIR__ . '/../includes/utils.php';
|
||||
require_once __DIR__ . '/../includes/persona.php';
|
||||
require_once __DIR__ . '/../includes/chore_helpers.php';
|
||||
|
||||
$chores = readJsonFile('chores.json');
|
||||
$rawChores = readJsonFile('chores.json');
|
||||
$chores = migrateAllChores(normalizeChoresList($rawChores), $people);
|
||||
|
||||
$nameById = [];
|
||||
foreach ($people as $p) {
|
||||
if (!empty($p['id'])) {
|
||||
$nameById[(string) $p['id']] = (string) ($p['name'] ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
$currencySymbol = $familySettings['currency_symbol'] ?? '★';
|
||||
|
||||
$canReview = $activePerson !== null
|
||||
&& ($activePerson['role'] ?? '') === ROLE_HEAD
|
||||
&& isHohVerified();
|
||||
|
||||
$actorId = $activePerson['id'] ?? '';
|
||||
$editId = isset($_GET['edit']) ? trim((string) $_GET['edit']) : '';
|
||||
$editChore = $editId !== '' ? findChoreById($chores, $editId) : null;
|
||||
$editNotFound = $editId !== '' && $editChore === null;
|
||||
|
||||
$editChoreAllowed = $editChore !== null && $activePerson !== null && (
|
||||
(string) ($editChore['author_id'] ?? '') === (string) $actorId
|
||||
|| $canReview
|
||||
);
|
||||
|
||||
$showChoreForm = $activePerson !== null
|
||||
&& !$editNotFound
|
||||
&& (
|
||||
($editChore !== null && $editChoreAllowed)
|
||||
|| ($editChore === null && $canReview)
|
||||
);
|
||||
|
||||
$pendingForReview = [];
|
||||
$activeChores = [];
|
||||
$completedChores = [];
|
||||
foreach ($chores as $c) {
|
||||
$st = (string) ($c['status'] ?? 'active');
|
||||
$pend = $c['pending_submission'] ?? null;
|
||||
if ($st === 'active' && is_array($pend) && $canReview) {
|
||||
$pendingForReview[] = $c;
|
||||
}
|
||||
if ($st === 'completed') {
|
||||
$completedChores[] = $c;
|
||||
} elseif ($st === 'active') {
|
||||
$activeChores[] = $c;
|
||||
}
|
||||
}
|
||||
|
||||
$prefillList = ['type' => 'checkbox', 'items' => []];
|
||||
if ($editChore) {
|
||||
$lists = $editChore['lists'] ?? [];
|
||||
if (is_array($lists) && count($lists) > 0) {
|
||||
$b = $lists[0];
|
||||
$t = $b['type'] ?? 'checkbox';
|
||||
$prefillList['type'] = in_array($t, ['ordered', 'unordered', 'checkbox'], true) ? $t : 'checkbox';
|
||||
$items = $b['items'] ?? [];
|
||||
if (is_array($items)) {
|
||||
foreach ($items as $it) {
|
||||
$prefillList['items'][] = (string) $it;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
?>
|
||||
|
||||
<div id="chores" class="tab-content">
|
||||
<h2>Chores</h2>
|
||||
<div class="chores-list">
|
||||
<?php if (empty($chores)): ?>
|
||||
<p>No chores added yet.</p>
|
||||
<?php else: ?>
|
||||
<ul>
|
||||
<?php foreach ($chores as $chore): ?>
|
||||
<li>
|
||||
<span class="chore-name"><?php echo sanitizeInput($chore['name']); ?></span>
|
||||
<span class="chore-assignee"><?php echo sanitizeInput($chore['assignee']); ?></span>
|
||||
<span class="chore-due-date"><?php echo formatDate($chore['due_date']); ?></span>
|
||||
<h2 class="mb-2">Chores</h2>
|
||||
|
||||
<?php if ($activePerson !== null): ?>
|
||||
<div class="alert alert-info small mb-3 chore-howto">
|
||||
<strong>How to finish a chore:</strong> The checklist bullets are reminders only (not clickable).
|
||||
If this chore is assigned to you, use <strong>Submit for approval</strong> on the card when the work is done.
|
||||
A Head of household then <strong>approves</strong> it so the reward is added to balances.
|
||||
<span class="d-block mt-1 text-muted">Use the <strong>person chips at the top</strong> to switch to the right family member if you don’t see that button.</span>
|
||||
<span class="d-block mt-2"><strong>New chores:</strong> Only a verified <strong>Head of household</strong> can add them (switch profile and enter PIN if needed).</span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($activePerson === null): ?>
|
||||
<div class="alert alert-warning">Choose who is using the hub (top of the page) to work with chores.</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($canReview && count($pendingForReview) > 0): ?>
|
||||
<div class="card border-warning mb-4">
|
||||
<div class="card-header bg-warning-subtle">Waiting for your approval</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<?php foreach ($pendingForReview as $c): ?>
|
||||
<?php
|
||||
$cid = htmlspecialchars($c['id'] ?? '', ENT_QUOTES, 'UTF-8');
|
||||
$sub = $c['pending_submission'] ?? [];
|
||||
$byId = is_array($sub) ? (string) ($sub['submitted_by'] ?? '') : '';
|
||||
$byName = $nameById[$byId] ?? $byId;
|
||||
?>
|
||||
<li class="list-group-item d-flex flex-column flex-md-row align-items-md-center justify-content-between gap-2">
|
||||
<div>
|
||||
<strong><?= sanitizeInput($c['title'] ?? '') ?></strong>
|
||||
<span class="text-muted small ms-1">— submitted by <?= sanitizeInput($byName) ?></span>
|
||||
<div class="small text-muted"><?= sanitizeInput((string) ($c['value'] ?? 0)) ?> <?= sanitizeInput($currencySymbol) ?> · assignees:
|
||||
<?php
|
||||
$ids = $c['assignee_ids'] ?? [];
|
||||
$nm = [];
|
||||
foreach ($ids as $i) {
|
||||
$nm[] = $nameById[(string) $i] ?? (string) $i;
|
||||
}
|
||||
echo sanitizeInput(implode(', ', $nm));
|
||||
?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-success btn-sm btn-chore-approve" data-id="<?= $cid ?>">Approve</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm btn-chore-reject" data-id="<?= $cid ?>">Reject</button>
|
||||
</div>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($editNotFound): ?>
|
||||
<div class="alert alert-warning">That chore was not found. <a href="?tab=chores">Back to chores</a></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($showChoreForm): ?>
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><?= $editChore ? 'Edit chore' : 'New chore' ?></span>
|
||||
<?php if ($editChore): ?>
|
||||
<a href="?tab=chores" class="btn btn-sm btn-outline-secondary">Cancel edit</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="choreForm" class="row g-3">
|
||||
<input type="hidden" name="id" id="chore_id" value="<?= $editChore ? htmlspecialchars($editChore['id'] ?? '', ENT_QUOTES, 'UTF-8') : '' ?>">
|
||||
|
||||
<div class="col-md-8">
|
||||
<label class="form-label" for="chore_title">Title</label>
|
||||
<input class="form-control" id="chore_title" required
|
||||
value="<?= $editChore ? sanitizeInput($editChore['title'] ?? '') : '' ?>">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="chore_value">Reward (<?= sanitizeInput($currencySymbol) ?>)</label>
|
||||
<input type="number" class="form-control" id="chore_value" min="0" step="0.01"
|
||||
value="<?= $editChore ? htmlspecialchars((string) ($editChore['value'] ?? '0'), ENT_QUOTES, 'UTF-8') : '0' ?>">
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="chore_description">Description</label>
|
||||
<textarea class="form-control" id="chore_description" rows="2"><?= $editChore ? sanitizeInput($editChore['description'] ?? '') : '' ?></textarea>
|
||||
</div>
|
||||
|
||||
<div class="col-md-8">
|
||||
<label class="form-label" for="chore_image">Image URL (optional)</label>
|
||||
<input class="form-control" id="chore_image" type="url" placeholder="https://…"
|
||||
value="<?= $editChore ? sanitizeInput($editChore['image'] ?? '') : '' ?>">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="chore_due_date">Due date</label>
|
||||
<input class="form-control" id="chore_due_date" type="date"
|
||||
value="<?= $editChore ? sanitizeInput($editChore['due_date'] ?? '') : '' ?>">
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="chore_schedule">Schedule</label>
|
||||
<select class="form-select" id="chore_schedule">
|
||||
<option value="once" <?= !$editChore || ($editChore['schedule'] ?? '') === CHORE_SCHEDULE_ONCE ? 'selected' : '' ?>>One-time</option>
|
||||
<option value="recurring" <?= $editChore && ($editChore['schedule'] ?? '') === CHORE_SCHEDULE_RECURRING ? 'selected' : '' ?>>Recurring</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6" id="chore_recurrence_wrap">
|
||||
<label class="form-label" for="chore_recurrence_days">Days until next due (recurring)</label>
|
||||
<input type="number" class="form-control" id="chore_recurrence_days" min="1" value="<?= $editChore ? (int) ($editChore['recurrence_days'] ?? 7) : 7 ?>">
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label">Assign to</label>
|
||||
<div class="d-flex flex-wrap gap-3">
|
||||
<?php foreach ($people as $p): ?>
|
||||
<?php
|
||||
$pid = (string) ($p['id'] ?? '');
|
||||
if ($pid === '') {
|
||||
continue;
|
||||
}
|
||||
$checked = $editChore && is_array($editChore['assignee_ids'] ?? null) && in_array($pid, $editChore['assignee_ids'], true);
|
||||
?>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input chore-assignee" type="checkbox" value="<?= htmlspecialchars($pid, ENT_QUOTES, 'UTF-8') ?>" id="as_<?= htmlspecialchars($pid, ENT_QUOTES, 'UTF-8') ?>" <?= $checked ? 'checked' : '' ?>>
|
||||
<label class="form-check-label" for="as_<?= htmlspecialchars($pid, ENT_QUOTES, 'UTF-8') ?>"><?= sanitizeInput($p['name'] ?? '') ?></label>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="chore_list_type">Checklist / list type</label>
|
||||
<select class="form-select" id="chore_list_type">
|
||||
<option value="checkbox" <?= $prefillList['type'] === 'checkbox' ? 'selected' : '' ?>>Checkbox list</option>
|
||||
<option value="unordered" <?= $prefillList['type'] === 'unordered' ? 'selected' : '' ?>>Bullet list</option>
|
||||
<option value="ordered" <?= $prefillList['type'] === 'ordered' ? 'selected' : '' ?>>Numbered list</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<label class="form-label" for="chore_list_items">List items (one per line)</label>
|
||||
<p class="form-text small mb-1">Shown on the card as a numbered, bullet, or checkbox-style list. Assignees tick these off in real life; finishing the chore is the <strong>Submit for approval</strong> button on the card.</p>
|
||||
<textarea class="form-control" id="chore_list_items" rows="4" placeholder="Take out recycling Wipe counters"><?= sanitizeInput(implode("\n", $prefillList['items'])) ?></textarea>
|
||||
</div>
|
||||
|
||||
<div class="col-12 d-flex flex-wrap gap-2">
|
||||
<button type="submit" class="btn btn-primary"><?= $editChore ? 'Save changes' : 'Add chore' ?></button>
|
||||
<?php if ($editChore): ?>
|
||||
<button type="button" class="btn btn-outline-danger" id="choreDeleteBtn" data-id="<?= htmlspecialchars($editChore['id'] ?? '', ENT_QUOTES, 'UTF-8') ?>">Delete chore</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="alert d-none" id="choreFormFeedback" role="status"></div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php elseif ($activePerson !== null && $editChore !== null && !$editChoreAllowed): ?>
|
||||
<div class="alert alert-warning mb-4">You can’t edit this chore. Only the person who created it or a verified Head of household can.</div>
|
||||
<?php elseif ($activePerson !== null && $editChore === null && !$canReview): ?>
|
||||
<p class="text-muted small mb-4">Switch to a verified <strong>Head of household</strong> (top of the page) to add new chores.</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<h3 class="h5 mt-4">Active chores</h3>
|
||||
<?php if (count($activeChores) === 0): ?>
|
||||
<p class="text-muted">No active chores.</p>
|
||||
<?php else: ?>
|
||||
<div class="row g-3">
|
||||
<?php foreach ($activeChores as $c): ?>
|
||||
<?php
|
||||
$cid = htmlspecialchars($c['id'] ?? '', ENT_QUOTES, 'UTF-8');
|
||||
$assignees = $c['assignee_ids'] ?? [];
|
||||
$isAssignee = $actorId !== '' && is_array($assignees) && in_array((string) $actorId, $assignees, true);
|
||||
$canEdit = $activePerson && ((string) ($c['author_id'] ?? '') === (string) $actorId || $canReview);
|
||||
$pending = is_array($c['pending_submission'] ?? null);
|
||||
?>
|
||||
<div class="col-12 col-md-6 col-xl-4">
|
||||
<div class="card chore-card h-100">
|
||||
<?php if (!empty($c['image']) && preg_match('#^https?://#i', (string) $c['image'])): ?>
|
||||
<img src="<?= sanitizeInput($c['image']) ?>" class="card-img-top chore-thumb" alt="">
|
||||
<?php endif; ?>
|
||||
<div class="card-body">
|
||||
<h4 class="h6 card-title"><?= sanitizeInput($c['title'] ?? '') ?></h4>
|
||||
<p class="card-text small"><?= nl2br(sanitizeInput($c['description'] ?? '')) ?></p>
|
||||
<p class="small text-muted mb-1">
|
||||
Reward: <strong><?= sanitizeInput((string) ($c['value'] ?? 0)) ?> <?= sanitizeInput($currencySymbol) ?></strong>
|
||||
<?php if (!empty($c['due_date'])): ?>
|
||||
· Due <?= sanitizeInput($c['due_date']) ?>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<p class="small text-muted mb-2">
|
||||
<?= ($c['schedule'] ?? '') === CHORE_SCHEDULE_RECURRING ? 'Recurring' : 'One-time' ?>
|
||||
<?php if ($pending): ?>
|
||||
<span class="badge text-bg-warning">Waiting for approval</span>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<?php if (!empty($c['lists']) && is_array($c['lists'])): ?>
|
||||
<p class="small text-muted mb-1"><strong>Steps</strong> (reference — use the button below when you’re done)</p>
|
||||
<?php endif; ?>
|
||||
<?php foreach (($c['lists'] ?? []) as $block): ?>
|
||||
<?php if (!is_array($block) || empty($block['items'])) { continue; } ?>
|
||||
<?php $t = $block['type'] ?? 'checkbox'; ?>
|
||||
<?php if ($t === 'ordered'): ?>
|
||||
<ol class="small mb-2"><?php foreach ($block['items'] as $it): ?><li><?= sanitizeInput((string) $it) ?></li><?php endforeach; ?></ol>
|
||||
<?php elseif ($t === 'unordered'): ?>
|
||||
<ul class="small mb-2"><?php foreach ($block['items'] as $it): ?><li><?= sanitizeInput((string) $it) ?></li><?php endforeach; ?></ul>
|
||||
<?php else: ?>
|
||||
<ul class="list-unstyled small mb-2 chore-checklist-display"><?php foreach ($block['items'] as $it): ?><li><i class="fa-regular fa-square me-1" aria-hidden="true"></i><span><?= sanitizeInput((string) $it) ?></span></li><?php endforeach; ?></ul>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
<div class="d-flex flex-wrap gap-2 mt-auto align-items-center">
|
||||
<?php if ($isAssignee && !$pending): ?>
|
||||
<button type="button" class="btn btn-sm btn-success btn-chore-submit" data-id="<?= $cid ?>">Submit for approval</button>
|
||||
<?php elseif ($activePerson !== null && !$pending && !$isAssignee): ?>
|
||||
<?php
|
||||
$nmAssign = [];
|
||||
foreach ($assignees as $aid) {
|
||||
$nmAssign[] = $nameById[(string) $aid] ?? (string) $aid;
|
||||
}
|
||||
$who = sanitizeInput(implode(', ', $nmAssign));
|
||||
?>
|
||||
<span class="small text-muted">To submit when done: switch to <?= $who !== '' ? $who : 'an assignee' ?> at the top.</span>
|
||||
<?php endif; ?>
|
||||
<?php if ($canEdit): ?>
|
||||
<a class="btn btn-sm btn-outline-primary" href="?tab=chores&edit=<?= $cid ?>">Edit</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (count($completedChores) > 0): ?>
|
||||
<details class="mt-4">
|
||||
<summary class="h6">Completed chores (<?= count($completedChores) ?>)</summary>
|
||||
<ul class="list-group mt-2">
|
||||
<?php foreach ($completedChores as $c): ?>
|
||||
<li class="list-group-item text-muted"><?= sanitizeInput($c['title'] ?? '') ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</details>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/db.php';
|
||||
require_once __DIR__ . '/../includes/utils.php';
|
||||
require_once __DIR__ . '/../includes/persona.php';
|
||||
require_once __DIR__ . '/../includes/expense_helpers.php';
|
||||
require_once __DIR__ . '/../includes/family_settings.php';
|
||||
|
||||
$sym = trim((string) ($familySettings['currency_symbol'] ?? '★'));
|
||||
$name = trim((string) ($familySettings['currency_name'] ?? ''));
|
||||
$tabTitle = currencyTabLabel($familySettings);
|
||||
|
||||
$canRecordExpense = $activePerson !== null
|
||||
&& ($activePerson['role'] ?? '') === ROLE_HEAD
|
||||
&& isHohVerified();
|
||||
|
||||
$leaderboard = $people;
|
||||
usort($leaderboard, static function ($a, $b) {
|
||||
$ba = is_numeric($a['currency_balance'] ?? null) ? (float) $a['currency_balance'] : 0.0;
|
||||
$bb = is_numeric($b['currency_balance'] ?? null) ? (float) $b['currency_balance'] : 0.0;
|
||||
$cmp = $bb <=> $ba;
|
||||
if ($cmp !== 0) {
|
||||
return $cmp;
|
||||
}
|
||||
return strcmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? ''));
|
||||
});
|
||||
|
||||
$nameById = [];
|
||||
foreach ($people as $p) {
|
||||
if (!empty($p['id'])) {
|
||||
$nameById[(string) $p['id']] = (string) ($p['name'] ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
$expenses = normalizeExpensesList(readJsonFile('expenses.json'));
|
||||
usort($expenses, static function ($a, $b) {
|
||||
$da = (string) ($a['date'] ?? '');
|
||||
$db = (string) ($b['date'] ?? '');
|
||||
if ($db !== $da) {
|
||||
return strcmp($db, $da);
|
||||
}
|
||||
return strcmp((string) ($b['created_at'] ?? ''), (string) ($a['created_at'] ?? ''));
|
||||
});
|
||||
$recentExpenses = array_slice($expenses, 0, 50);
|
||||
|
||||
$today = gmdate('Y-m-d');
|
||||
?>
|
||||
|
||||
<div id="currency" class="tab-content">
|
||||
<h2 class="mb-2"><?= htmlspecialchars($tabTitle, ENT_QUOTES, 'UTF-8') ?></h2>
|
||||
<p class="text-muted small mb-4">Leaderboard and expenses use the family currency from Family settings.</p>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-5">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<i class="fa fa-trophy me-1" aria-hidden="true"></i> Leaderboard
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<?php if (count($leaderboard) === 0): ?>
|
||||
<p class="p-3 text-muted mb-0">Add people in Family settings to see balances.</p>
|
||||
<?php else: ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped mb-0 leaderboard-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" class="text-muted">#</th>
|
||||
<th scope="col">Name</th>
|
||||
<th scope="col" class="text-end">Balance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($leaderboard as $rank => $p): ?>
|
||||
<?php
|
||||
$pid = (string) ($p['id'] ?? '');
|
||||
$bal = is_numeric($p['currency_balance'] ?? null) ? (float) $p['currency_balance'] : 0.0;
|
||||
$isSelf = $activePerson && ($activePerson['id'] ?? '') === $pid;
|
||||
?>
|
||||
<tr class="<?= $isSelf ? 'table-primary' : '' ?>">
|
||||
<td class="text-muted"><?= (int) $rank + 1 ?></td>
|
||||
<td><?= sanitizeInput($p['name'] ?? '') ?><?php if ($isSelf): ?> <span class="badge text-bg-info">You</span><?php endif; ?></td>
|
||||
<td class="text-end text-nowrap"><strong><?= htmlspecialchars(number_format($bal, 2, '.', ''), ENT_QUOTES, 'UTF-8') ?></strong> <?= htmlspecialchars($sym, ENT_QUOTES, 'UTF-8') ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-7">
|
||||
<?php if ($canRecordExpense): ?>
|
||||
<div class="card border-secondary mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="fa fa-minus-circle me-1"></i> Record expense</span>
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#expenseFormCollapse" aria-expanded="false" aria-controls="expenseFormCollapse">
|
||||
Show form
|
||||
</button>
|
||||
</div>
|
||||
<div class="collapse" id="expenseFormCollapse">
|
||||
<div class="card-body">
|
||||
<p class="small text-muted">Deducts from one person’s balance (e.g. spent allowance). Requires enough balance.</p>
|
||||
<form id="expenseForm" class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="expense_title">Title</label>
|
||||
<input class="form-control" id="expense_title" required maxlength="120">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="expense_value">Amount (<?= htmlspecialchars($sym, ENT_QUOTES, 'UTF-8') ?>)</label>
|
||||
<input type="number" class="form-control" id="expense_value" min="0.01" step="0.01" required>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="expense_date">Date</label>
|
||||
<input type="date" class="form-control" id="expense_date" value="<?= htmlspecialchars($today, ENT_QUOTES, 'UTF-8') ?>" required>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="expense_assignee">Charged to</label>
|
||||
<select class="form-select" id="expense_assignee" required>
|
||||
<option value="">Choose person…</option>
|
||||
<?php foreach ($people as $p): ?>
|
||||
<?php if (empty($p['id'])) { continue; } ?>
|
||||
<option value="<?= htmlspecialchars((string) $p['id'], ENT_QUOTES, 'UTF-8') ?>"><?= sanitizeInput($p['name'] ?? '') ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="expense_description">Description</label>
|
||||
<textarea class="form-control" id="expense_description" rows="2"></textarea>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-danger">Apply expense</button>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="alert d-none" id="expenseFormFeedback" role="status"></div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php elseif (count($people) > 0): ?>
|
||||
<p class="text-muted small">Switch to a verified Head of household to record expenses.</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">Recent expenses</div>
|
||||
<div class="card-body p-0">
|
||||
<?php if (count($recentExpenses) === 0): ?>
|
||||
<p class="p-3 text-muted mb-0">No expenses recorded yet.</p>
|
||||
<?php else: ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Title</th>
|
||||
<th>To</th>
|
||||
<th class="text-end">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($recentExpenses as $ex): ?>
|
||||
<tr>
|
||||
<td class="text-nowrap"><?= sanitizeInput((string) ($ex['date'] ?? '')) ?></td>
|
||||
<td><?= sanitizeInput((string) ($ex['title'] ?? '')) ?></td>
|
||||
<td><?= sanitizeInput($nameById[(string) ($ex['assignee_id'] ?? '')] ?? '') ?></td>
|
||||
<td class="text-end text-nowrap">−<?= htmlspecialchars(number_format((float) ($ex['value'] ?? 0), 2, '.', ''), ENT_QUOTES, 'UTF-8') ?> <?= htmlspecialchars($sym, ENT_QUOTES, 'UTF-8') ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
+223
-17
@@ -1,25 +1,231 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/db.php';
|
||||
require_once __DIR__ . '/../includes/utils.php';
|
||||
require_once __DIR__ . '/../includes/persona.php';
|
||||
require_once __DIR__ . '/../includes/grocery_helpers.php';
|
||||
|
||||
$groceries = readJsonFile('groceries.json');
|
||||
migrateLegacyGroceriesIfNeeded();
|
||||
ensureDefaultGroceryStore();
|
||||
|
||||
$stores = normalizeStoresList(readJsonFile('stores.json'));
|
||||
usort($stores, static function ($a, $b) {
|
||||
$sa = (int) ($a['sort'] ?? 0);
|
||||
$sb = (int) ($b['sort'] ?? 0);
|
||||
if ($sa !== $sb) {
|
||||
return $sa <=> $sb;
|
||||
}
|
||||
return strcasecmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? ''));
|
||||
});
|
||||
|
||||
$lists = normalizeGroceryLists(readJsonFile('grocery_lists.json'));
|
||||
$catalog = normalizeCatalogList(readJsonFile('grocery_catalog.json'));
|
||||
|
||||
$selectedStore = isset($_GET['store']) ? trim((string) $_GET['store']) : '';
|
||||
if ($selectedStore === '' && count($stores) > 0) {
|
||||
$selectedStore = (string) ($stores[0]['id'] ?? '');
|
||||
}
|
||||
$currentStore = findStoreById($stores, $selectedStore);
|
||||
|
||||
$filter = isset($_GET['filter']) ? trim((string) $_GET['filter']) : 'active';
|
||||
if (!in_array($filter, ['active', 'purchased', 'pending', 'all'], true)) {
|
||||
$filter = 'active';
|
||||
}
|
||||
|
||||
$allItems = $currentStore ? ($lists['byStore'][$selectedStore] ?? []) : [];
|
||||
$displayItems = [];
|
||||
foreach ($allItems as $it) {
|
||||
if (!is_array($it)) {
|
||||
continue;
|
||||
}
|
||||
$st = (string) ($it['status'] ?? 'active');
|
||||
if ($filter === 'all') {
|
||||
$displayItems[] = $it;
|
||||
} elseif ($filter === 'pending' && $st === 'pending_review') {
|
||||
$displayItems[] = $it;
|
||||
} elseif ($filter === 'purchased' && $st === 'purchased') {
|
||||
$displayItems[] = $it;
|
||||
} elseif ($filter === 'active' && $st === 'active') {
|
||||
$displayItems[] = $it;
|
||||
}
|
||||
}
|
||||
|
||||
$pickerOptions = $currentStore ? groceryCatalogPickerOptions($catalog, $selectedStore) : [];
|
||||
|
||||
$canReviewGroceries = $activePerson !== null
|
||||
&& ($activePerson['role'] ?? '') === ROLE_HEAD
|
||||
&& isHohVerified();
|
||||
?>
|
||||
|
||||
<div id="groceries" class="tab-content">
|
||||
<h2>Grocery List</h2>
|
||||
<div class="groceries-list">
|
||||
<?php if (empty($groceries)): ?>
|
||||
<p>No items in the grocery list yet.</p>
|
||||
<?php else: ?>
|
||||
<ul>
|
||||
<?php foreach ($groceries as $item): ?>
|
||||
<li>
|
||||
<span class="item-name"><?php echo sanitizeInput($item['name']); ?></span>
|
||||
<span class="item-quantity"><?php echo sanitizeInput($item['quantity']); ?></span>
|
||||
<span class="item-category"><?php echo sanitizeInput($item['category']); ?></span>
|
||||
</li>
|
||||
<div id="groceries" class="tab-content" data-store-id="<?= htmlspecialchars($selectedStore, ENT_QUOTES, 'UTF-8') ?>">
|
||||
<h2 class="mb-3">Grocery list</h2>
|
||||
|
||||
<?php if ($activePerson === null): ?>
|
||||
<div class="alert alert-warning">Choose who is using the hub (top) to edit your list.</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (count($stores) === 0): ?>
|
||||
<p class="text-muted">No stores yet. Add one under <strong>Family settings</strong>.</p>
|
||||
<?php else: ?>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-lg-3">
|
||||
<nav class="nav flex-lg-column nav-pills grocery-store-nav gap-1" aria-label="Stores">
|
||||
<?php foreach ($stores as $s): ?>
|
||||
<?php
|
||||
$sidRaw = (string) ($s['id'] ?? '');
|
||||
$sid = htmlspecialchars($sidRaw, ENT_QUOTES, 'UTF-8');
|
||||
$active = $sidRaw === $selectedStore;
|
||||
?>
|
||||
<a class="nav-link <?= $active ? 'active' : '' ?> py-2" href="?tab=groceries&store=<?= $sid ?>&filter=<?= htmlspecialchars($filter, ENT_QUOTES, 'UTF-8') ?>">
|
||||
<?= sanitizeInput($s['name'] ?? 'Store') ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
</nav>
|
||||
<p class="small text-muted mt-2 d-none d-lg-block">Manage store names under <a href="?tab=settings">Family settings</a>.</p>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-9">
|
||||
<?php if (!$currentStore): ?>
|
||||
<p class="alert alert-warning">Pick a store from the list.</p>
|
||||
<?php else: ?>
|
||||
|
||||
<div class="d-flex flex-wrap gap-2 mb-3 align-items-center">
|
||||
<span class="text-muted small me-1">Show:</span>
|
||||
<?php
|
||||
$filtHref = static function ($f) use ($selectedStore) {
|
||||
return '?tab=groceries&store=' . rawurlencode($selectedStore) . '&filter=' . rawurlencode($f);
|
||||
};
|
||||
?>
|
||||
<a class="btn btn-sm <?= $filter === 'active' ? 'btn-primary' : 'btn-outline-primary' ?>" href="<?= htmlspecialchars($filtHref('active'), ENT_QUOTES, 'UTF-8') ?>">To buy</a>
|
||||
<a class="btn btn-sm <?= $filter === 'purchased' ? 'btn-primary' : 'btn-outline-primary' ?>" href="<?= htmlspecialchars($filtHref('purchased'), ENT_QUOTES, 'UTF-8') ?>">Purchased</a>
|
||||
<a class="btn btn-sm <?= $filter === 'pending' ? 'btn-primary' : 'btn-outline-primary' ?>" href="<?= htmlspecialchars($filtHref('pending'), ENT_QUOTES, 'UTF-8') ?>">Pending review</a>
|
||||
<a class="btn btn-sm <?= $filter === 'all' ? 'btn-primary' : 'btn-outline-primary' ?>" href="<?= htmlspecialchars($filtHref('all'), ENT_QUOTES, 'UTF-8') ?>">All</a>
|
||||
</div>
|
||||
|
||||
<?php if ($activePerson !== null): ?>
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">Add item — <?= sanitizeInput($currentStore['name'] ?? '') ?></div>
|
||||
<div class="card-body">
|
||||
<form id="groceryAddForm" class="row g-3">
|
||||
<input type="hidden" id="grocery_store_id" value="<?= htmlspecialchars($selectedStore, ENT_QUOTES, 'UTF-8') ?>">
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="grocery_catalog_pick">Start from saved item (optional, deduped)</label>
|
||||
<select class="form-select" id="grocery_catalog_pick">
|
||||
<option value="">— New item —</option>
|
||||
<?php foreach ($pickerOptions as $opt): ?>
|
||||
<option
|
||||
value="<?= htmlspecialchars((string) ($opt['id'] ?? ''), ENT_QUOTES, 'UTF-8') ?>"
|
||||
data-name="<?= htmlspecialchars((string) ($opt['name'] ?? ''), ENT_QUOTES, 'UTF-8') ?>"
|
||||
data-description="<?= htmlspecialchars((string) ($opt['description'] ?? ''), ENT_QUOTES, 'UTF-8') ?>"
|
||||
data-size="<?= htmlspecialchars((string) ($opt['defaultSize'] ?? ''), ENT_QUOTES, 'UTF-8') ?>"
|
||||
data-image="<?= htmlspecialchars((string) ($opt['defaultImage'] ?? ''), ENT_QUOTES, 'UTF-8') ?>"
|
||||
><?= sanitizeInput($opt['name'] ?? '') ?><?php if (!empty($opt['defaultSize'])): ?> (<?= sanitizeInput((string) $opt['defaultSize']) ?>)<?php endif; ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="grocery_name">Name</label>
|
||||
<input class="form-control" id="grocery_name" required maxlength="200">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="grocery_quantity">Quantity</label>
|
||||
<input class="form-control" id="grocery_quantity" value="1">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="grocery_size">Size</label>
|
||||
<input class="form-control" id="grocery_size" placeholder="e.g. 1 gal">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="grocery_price">Price</label>
|
||||
<input class="form-control" id="grocery_price" placeholder="optional">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="grocery_description">Description</label>
|
||||
<textarea class="form-control" id="grocery_description" rows="2"></textarea>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<label class="form-label" for="grocery_image">Image URL</label>
|
||||
<input class="form-control" id="grocery_image" type="url" placeholder="https://…">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="grocery_recurring">Recurring (days after purchase)</label>
|
||||
<input type="number" class="form-control" id="grocery_recurring" min="0" value="0" title="0 = not recurring; e.g. 7 = suggest again 7 days after you mark it purchased">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-primary">Add to list</button>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="alert d-none" id="groceryFormFeedback" role="status"></div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<h3 class="h6 text-muted"><?= $filter === 'purchased' ? 'Purchased' : ($filter === 'pending' ? 'Pending review' : ($filter === 'all' ? 'All items' : 'To buy')) ?></h3>
|
||||
<?php if (count($displayItems) === 0): ?>
|
||||
<p class="text-muted">No items in this view.</p>
|
||||
<?php else: ?>
|
||||
<ul class="list-group grocery-item-list">
|
||||
<?php foreach ($displayItems as $it): ?>
|
||||
<?php
|
||||
$iid = htmlspecialchars((string) ($it['id'] ?? ''), ENT_QUOTES, 'UTF-8');
|
||||
$isPurchased = ($it['status'] ?? '') === 'purchased';
|
||||
$isPending = ($it['status'] ?? '') === 'pending_review';
|
||||
?>
|
||||
<li class="list-group-item grocery-line">
|
||||
<div class="d-flex flex-column flex-md-row gap-2 justify-content-between align-items-start">
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex align-items-start gap-2">
|
||||
<?php if (!empty($it['image']) && preg_match('#^https?://#i', (string) $it['image'])): ?>
|
||||
<img src="<?= sanitizeInput((string) $it['image']) ?>" alt="" class="grocery-line-thumb rounded">
|
||||
<?php endif; ?>
|
||||
<div>
|
||||
<strong><?= sanitizeInput((string) ($it['name'] ?? '')) ?></strong>
|
||||
<?php if (!empty($it['size'])): ?>
|
||||
<span class="text-muted small">· <?= sanitizeInput((string) $it['size']) ?></span>
|
||||
<?php endif; ?>
|
||||
<?php if ($isPending): ?>
|
||||
<span class="badge text-bg-warning ms-1">Pending review</span>
|
||||
<?php endif; ?>
|
||||
<?php if ((int) ($it['recurringIntervalDays'] ?? 0) > 0): ?>
|
||||
<span class="badge text-bg-info ms-1">Recurring</span>
|
||||
<?php endif; ?>
|
||||
<div class="small text-muted">Qty <?= sanitizeInput((string) ($it['quantity'] ?? '')) ?>
|
||||
<?php if (!empty($it['price'])): ?> · <?= sanitizeInput((string) $it['price']) ?><?php endif; ?>
|
||||
</div>
|
||||
<?php if (!empty($it['description'])): ?>
|
||||
<div class="small"><?= nl2br(sanitizeInput((string) $it['description'])) ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if ($isPurchased && !empty($it['purchasedAt'])): ?>
|
||||
<div class="small text-muted">Purchased <?= sanitizeInput((string) $it['purchasedAt']) ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-group flex-shrink-0">
|
||||
<?php if ($activePerson !== null && !$isPending): ?>
|
||||
<?php if (!$isPurchased): ?>
|
||||
<button type="button" class="btn btn-sm btn-success btn-grocery-purchase" data-item-id="<?= $iid ?>" data-purchased="1">Got it</button>
|
||||
<?php else: ?>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary btn-grocery-purchase" data-item-id="<?= $iid ?>" data-purchased="0">Mark not bought</button>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
<?php if ($canReviewGroceries && $isPending): ?>
|
||||
<button type="button" class="btn btn-sm btn-primary btn-grocery-approve" data-item-id="<?= $iid ?>">Approve</button>
|
||||
<?php endif; ?>
|
||||
<?php if ($activePerson !== null): ?>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger btn-grocery-delete" data-item-id="<?= $iid ?>">Remove</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
+363
-15
@@ -1,25 +1,373 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/db.php';
|
||||
require_once __DIR__ . '/../includes/utils.php';
|
||||
require_once __DIR__ . '/../includes/persona.php';
|
||||
require_once __DIR__ . '/../includes/meal_helpers.php';
|
||||
require_once __DIR__ . '/../includes/grocery_helpers.php';
|
||||
|
||||
$meals = readJsonFile('meals.json');
|
||||
if (!file_exists(DATA_PATH . '/meal_plans.json')) {
|
||||
writeJsonFile('meal_plans.json', normalizeMealPlan([]));
|
||||
}
|
||||
|
||||
$plan = normalizeMealPlan(readJsonFile('meal_plans.json'));
|
||||
$meals = migrateLegacyMealsList(normalizeMealsList(readJsonFile('meals.json')));
|
||||
|
||||
migrateLegacyGroceriesIfNeeded();
|
||||
ensureDefaultGroceryStore();
|
||||
$stores = normalizeStoresList(readJsonFile('stores.json'));
|
||||
|
||||
$detailId = isset($_GET['meal']) ? trim((string) $_GET['meal']) : '';
|
||||
$detailMeal = $detailId !== '' ? findMealById($meals, $detailId) : null;
|
||||
|
||||
$actorId = $activePerson['id'] ?? '';
|
||||
$canManageMeals = $activePerson !== null
|
||||
&& ($activePerson['role'] ?? '') === ROLE_HEAD
|
||||
&& isHohVerified();
|
||||
|
||||
$canEditMeal = static function (array $m) use ($actorId, $canManageMeals): bool {
|
||||
return $canManageMeals || (($m['author_id'] ?? '') === $actorId && $actorId !== '');
|
||||
};
|
||||
|
||||
$weekStart = $plan['weekStart'];
|
||||
$slots = $plan['slots'];
|
||||
|
||||
$mealTitleById = [];
|
||||
foreach ($meals as $m) {
|
||||
if (!empty($m['id'])) {
|
||||
$mealTitleById[(string) $m['id']] = (string) ($m['title'] ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
$prefillMeal = null;
|
||||
$editMealId = isset($_GET['edit']) ? trim((string) $_GET['edit']) : '';
|
||||
if ($editMealId !== '') {
|
||||
$prefillMeal = findMealById($meals, $editMealId);
|
||||
}
|
||||
|
||||
$listsPrefill = $prefillMeal['lists'] ?? [['type' => 'checkbox', 'items' => []]];
|
||||
if (!is_array($listsPrefill) || count($listsPrefill) === 0) {
|
||||
$listsPrefill = [['type' => 'checkbox', 'items' => []]];
|
||||
}
|
||||
$firstList = $listsPrefill[0];
|
||||
$listType = in_array($firstList['type'] ?? '', ['ordered', 'unordered', 'checkbox'], true) ? $firstList['type'] : 'checkbox';
|
||||
$listItems = $firstList['items'] ?? [];
|
||||
if (!is_array($listItems)) {
|
||||
$listItems = [];
|
||||
}
|
||||
?>
|
||||
|
||||
<?php if ($detailMeal !== null): ?>
|
||||
<div id="meals" class="tab-content">
|
||||
<h2>Meal Planning</h2>
|
||||
<div class="meals-list">
|
||||
<?php if (empty($meals)): ?>
|
||||
<p>No meals planned yet.</p>
|
||||
<p class="mb-2"><a href="?tab=meals" class="btn btn-sm btn-outline-secondary">← Back to meal plan</a></p>
|
||||
<h2 class="mb-2"><?= sanitizeInput($detailMeal['title'] ?? '') ?></h2>
|
||||
<?php if (!empty($detailMeal['image']) && preg_match('#^https?://#i', (string) $detailMeal['image'])): ?>
|
||||
<img src="<?= sanitizeInput((string) $detailMeal['image']) ?>" class="img-fluid rounded mb-3 meal-hero" alt="">
|
||||
<?php endif; ?>
|
||||
<p class="lead"><?= nl2br(sanitizeInput((string) ($detailMeal['description'] ?? ''))) ?></p>
|
||||
|
||||
<?php if (count($detailMeal['tags'] ?? []) > 0): ?>
|
||||
<p><?php foreach ($detailMeal['tags'] as $t): ?><span class="badge text-bg-secondary me-1"><?= sanitizeInput((string) $t) ?></span><?php endforeach; ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<h3 class="h5">Directions</h3>
|
||||
<div class="mb-4"><?= nl2br(sanitizeInput((string) ($detailMeal['directions'] ?? ''))) ?></div>
|
||||
|
||||
<?php foreach (($detailMeal['lists'] ?? []) as $block): ?>
|
||||
<?php if (!is_array($block) || empty($block['items'])) { continue; } ?>
|
||||
<?php $t = $block['type'] ?? 'checkbox'; ?>
|
||||
<?php if ($t === 'ordered'): ?>
|
||||
<ol><?php foreach ($block['items'] as $it): ?><li><?= sanitizeInput((string) $it) ?></li><?php endforeach; ?></ol>
|
||||
<?php elseif ($t === 'unordered'): ?>
|
||||
<ul><?php foreach ($block['items'] as $it): ?><li><?= sanitizeInput((string) $it) ?></li><?php endforeach; ?></ul>
|
||||
<?php else: ?>
|
||||
<ul>
|
||||
<?php foreach ($meals as $meal): ?>
|
||||
<li>
|
||||
<span class="meal-name"><?php echo sanitizeInput($meal['name']); ?></span>
|
||||
<span class="meal-date"><?php echo formatDate($meal['date']); ?></span>
|
||||
<span class="meal-type"><?php echo sanitizeInput($meal['type']); ?></span>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<ul class="list-unstyled"><?php foreach ($block['items'] as $it): ?><li><i class="fa-regular fa-square me-1"></i><?= sanitizeInput((string) $it) ?></li><?php endforeach; ?></ul>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<h3 class="h5">Pantry / staples (ingredients)</h3>
|
||||
<?php if (count($detailMeal['ingredients'] ?? []) === 0): ?>
|
||||
<p class="text-muted small">None listed.</p>
|
||||
<?php else: ?>
|
||||
<ul class="list-group mb-3">
|
||||
<?php foreach ($detailMeal['ingredients'] as $ing): ?>
|
||||
<li class="list-group-item d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||
<span><?= sanitizeInput((string) $ing) ?></span>
|
||||
<?php if ($activePerson !== null && count($stores) > 0): ?>
|
||||
<span class="d-flex flex-wrap gap-1 align-items-center">
|
||||
<select class="form-select form-select-sm ingredient-store" style="width:auto; min-width:8rem;" aria-label="Store for grocery">
|
||||
<?php foreach ($stores as $st): ?>
|
||||
<option value="<?= htmlspecialchars((string) ($st['id'] ?? ''), ENT_QUOTES, 'UTF-8') ?>"><?= sanitizeInput($st['name'] ?? '') ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary btn-ingredient-to-grocery" data-meal-id="<?= htmlspecialchars($detailId, ENT_QUOTES, 'UTF-8') ?>" data-ingredient="<?= htmlspecialchars((string) $ing, ENT_QUOTES, 'UTF-8') ?>">Add to grocery list</button>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
|
||||
<h3 class="h5">Shopping items</h3>
|
||||
<?php if (count($detailMeal['items'] ?? []) === 0): ?>
|
||||
<p class="text-muted small">None listed.</p>
|
||||
<?php else: ?>
|
||||
<ul class="list-group mb-3">
|
||||
<?php foreach ($detailMeal['items'] as $it): ?>
|
||||
<li class="list-group-item"><?= sanitizeInput((string) ($it['name'] ?? '')) ?>
|
||||
<?php if (!empty($it['quantity'])): ?> · <?= sanitizeInput((string) $it['quantity']) ?><?php endif; ?>
|
||||
<?php if (!empty($it['size'])): ?> (<?= sanitizeInput((string) $it['size']) ?>)<?php endif; ?>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($activePerson !== null && count($detailMeal['items'] ?? []) > 0): ?>
|
||||
<button type="button" class="btn btn-primary mb-3" id="btnPushMealGrocery" data-meal-id="<?= htmlspecialchars($detailId, ENT_QUOTES, 'UTF-8') ?>">Send shopping items to grocery (pending review)</button>
|
||||
<div class="alert d-none" id="pushMealFeedback" role="status"></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($detailMeal && $canEditMeal($detailMeal)): ?>
|
||||
<p><a class="btn btn-outline-primary" href="?tab=meals&edit=<?= htmlspecialchars($detailId, ENT_QUOTES, 'UTF-8') ?>">Edit meal</a></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php else: ?>
|
||||
|
||||
<div id="meals" class="tab-content" data-week-start="<?= htmlspecialchars($weekStart, ENT_QUOTES, 'UTF-8') ?>">
|
||||
<h2 class="mb-2">Meal plan</h2>
|
||||
<p class="text-muted small">Week starts on <strong><?= sanitizeInput($weekStart) ?></strong> (day 0). Assign meals to each slot; shopping items go to the grocery list as <strong>pending review</strong>.</p>
|
||||
|
||||
<?php if ($activePerson === null): ?>
|
||||
<div class="alert alert-warning">Choose who is using the hub to use the meal planner.</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($canManageMeals): ?>
|
||||
<div class="card mb-4">
|
||||
<div class="card-body row g-2 align-items-end">
|
||||
<div class="col-md-auto">
|
||||
<label class="form-label" for="meal_week_start">Week starting (Monday)</label>
|
||||
<input type="date" class="form-control" id="meal_week_start" value="<?= htmlspecialchars($weekStart, ENT_QUOTES, 'UTF-8') ?>">
|
||||
</div>
|
||||
<div class="col-md-auto">
|
||||
<button type="button" class="btn btn-primary" id="btnMealWeekApply">Set planning week</button>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="alert d-none" id="mealWeekFeedback" role="status"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="table-responsive mb-4">
|
||||
<table class="table table-bordered meal-grid-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Breakfast</th>
|
||||
<th>Lunch</th>
|
||||
<th>Dinner</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php for ($d = 0; $d < 7; $d++): ?>
|
||||
<tr>
|
||||
<th scope="row" class="text-nowrap"><?= sanitizeInput(mealDayShortLabel($weekStart, $d)) ?></th>
|
||||
<?php foreach (mealSlotTypes() as $mt): ?>
|
||||
<?php
|
||||
$mid = $slots[(string) $d][$mt] ?? null;
|
||||
$label = $mid && isset($mealTitleById[$mid]) ? $mealTitleById[$mid] : '—';
|
||||
?>
|
||||
<td>
|
||||
<?php if ($mid): ?>
|
||||
<a href="?tab=meals&meal=<?= htmlspecialchars($mid, ENT_QUOTES, 'UTF-8') ?>"><?= sanitizeInput($label) ?></a>
|
||||
<?php else: ?>
|
||||
<span class="text-muted">—</span>
|
||||
<?php endif; ?>
|
||||
<?php if ($activePerson !== null): ?>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary ms-1 btn-open-slot-picker"
|
||||
data-day="<?= (int) $d ?>"
|
||||
data-meal-type="<?= htmlspecialchars($mt, ENT_QUOTES, 'UTF-8') ?>"
|
||||
data-current-meal-id="<?= htmlspecialchars((string) ($mid ?? ''), ENT_QUOTES, 'UTF-8') ?>"
|
||||
><?= $mid ? 'Change' : '+' ?></button>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<?php endforeach; ?>
|
||||
</tr>
|
||||
<?php endfor; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3 class="h5">Meal library</h3>
|
||||
<?php if ($canManageMeals): ?>
|
||||
<p><a href="?tab=meals&edit=new" class="btn btn-success btn-sm">New meal</a></p>
|
||||
<?php else: ?>
|
||||
<p class="text-muted small">Only a verified Head of household can add meals.</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (count($meals) === 0): ?>
|
||||
<p class="text-muted">No meals in the library yet.</p>
|
||||
<?php else: ?>
|
||||
<ul class="list-group mb-4">
|
||||
<?php foreach ($meals as $m): ?>
|
||||
<?php $mid = htmlspecialchars((string) ($m['id'] ?? ''), ENT_QUOTES, 'UTF-8'); ?>
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<div>
|
||||
<a href="?tab=meals&meal=<?= $mid ?>"><strong><?= sanitizeInput($m['title'] ?? '') ?></strong></a>
|
||||
<?php foreach (($m['tags'] ?? []) as $t): ?>
|
||||
<span class="badge text-bg-light border ms-1"><?= sanitizeInput((string) $t) ?></span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<span>
|
||||
<?php if ($canEditMeal($m)): ?>
|
||||
<a class="btn btn-sm btn-outline-primary" href="?tab=meals&edit=<?= $mid ?>">Edit</a>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger btn-meal-delete" data-id="<?= $mid ?>">Delete</button>
|
||||
<?php endif; ?>
|
||||
</span>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($canManageMeals && ($editMealId === 'new' || $prefillMeal !== null)): ?>
|
||||
<div class="card mb-4 border-primary" id="mealEditorCard">
|
||||
<div class="card-header"><?= $editMealId === 'new' ? 'New meal' : 'Edit meal' ?></div>
|
||||
<div class="card-body">
|
||||
<?php if ($editMealId !== 'new' && $prefillMeal === null): ?>
|
||||
<div class="alert alert-warning">Meal not found.</div>
|
||||
<?php else: ?>
|
||||
<form id="mealSaveForm" class="row g-3">
|
||||
<input type="hidden" id="meal_id" value="<?= $prefillMeal ? htmlspecialchars((string) ($prefillMeal['id'] ?? ''), ENT_QUOTES, 'UTF-8') : '' ?>">
|
||||
<div class="col-md-8">
|
||||
<label class="form-label" for="meal_title">Title</label>
|
||||
<input class="form-control" id="meal_title" required value="<?= $prefillMeal ? sanitizeInput($prefillMeal['title'] ?? '') : '' ?>">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Tags</label>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<?php
|
||||
$tagSet = $prefillMeal ? array_flip($prefillMeal['tags'] ?? []) : [];
|
||||
foreach (['breakfast', 'lunch', 'dinner'] as $tg):
|
||||
?>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input meal-tag-cb" type="checkbox" value="<?= $tg ?>" id="tag_<?= $tg ?>" <?= isset($tagSet[$tg]) ? 'checked' : '' ?>>
|
||||
<label class="form-check-label" for="tag_<?= $tg ?>"><?= ucfirst($tg) ?></label>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="meal_description">Description</label>
|
||||
<textarea class="form-control" id="meal_description" rows="2"><?= $prefillMeal ? sanitizeInput($prefillMeal['description'] ?? '') : '' ?></textarea>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="meal_image">Image URL</label>
|
||||
<input class="form-control" id="meal_image" type="url" value="<?= $prefillMeal ? sanitizeInput($prefillMeal['image'] ?? '') : '' ?>">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="meal_directions">Directions</label>
|
||||
<textarea class="form-control" id="meal_directions" rows="4"><?= $prefillMeal ? sanitizeInput($prefillMeal['directions'] ?? '') : '' ?></textarea>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="meal_list_type">Recipe list type</label>
|
||||
<select class="form-select" id="meal_list_type">
|
||||
<option value="checkbox" <?= $listType === 'checkbox' ? 'selected' : '' ?>>Checkbox</option>
|
||||
<option value="unordered" <?= $listType === 'unordered' ? 'selected' : '' ?>>Bullets</option>
|
||||
<option value="ordered" <?= $listType === 'ordered' ? 'selected' : '' ?>>Numbered</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<label class="form-label" for="meal_list_items">Recipe list (one per line)</label>
|
||||
<textarea class="form-control" id="meal_list_items" rows="3"><?= sanitizeInput(implode("\n", $listItems)) ?></textarea>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="meal_ingredients">Ingredients / staples (one per line)</label>
|
||||
<textarea class="form-control" id="meal_ingredients" rows="3" placeholder="Salt Olive oil"><?= $prefillMeal ? sanitizeInput(implode("\n", $prefillMeal['ingredients'] ?? [])) : '' ?></textarea>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Shopping items</label>
|
||||
<p class="small text-muted">Per row: name, optional store, quantity, size, notes — store blank uses your first grocery store.</p>
|
||||
<div id="mealGroceryRows" class="d-flex flex-column gap-2">
|
||||
<?php
|
||||
$shopItems = $prefillMeal['items'] ?? [];
|
||||
if (!is_array($shopItems) || count($shopItems) === 0) {
|
||||
$shopItems = [['name' => '', 'storeId' => '', 'quantity' => '1', 'size' => '', 'description' => '', 'price' => '', 'image' => '']];
|
||||
}
|
||||
foreach ($shopItems as $si):
|
||||
?>
|
||||
<div class="row g-2 align-items-end meal-grocery-row">
|
||||
<div class="col-md-4">
|
||||
<input class="form-control form-control-sm g-name" placeholder="Name" value="<?= sanitizeInput((string) ($si['name'] ?? '')) ?>">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<select class="form-select form-select-sm g-store">
|
||||
<option value="">Default store</option>
|
||||
<?php foreach ($stores as $st): ?>
|
||||
<option value="<?= htmlspecialchars((string) ($st['id'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" <?= (($si['storeId'] ?? '') === ($st['id'] ?? '')) ? 'selected' : '' ?>><?= sanitizeInput($st['name'] ?? '') ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<input class="form-control form-control-sm g-qty" placeholder="Qty" value="<?= sanitizeInput((string) ($si['quantity'] ?? '1')) ?>">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<input class="form-control form-control-sm g-size" placeholder="Size" value="<?= sanitizeInput((string) ($si['size'] ?? '')) ?>">
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary mt-2" id="btnAddGroceryRow">Add shopping row</button>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-primary">Save meal</button>
|
||||
<a href="?tab=meals" class="btn btn-link">Cancel</a>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="alert d-none" id="mealSaveFeedback" role="status"></div>
|
||||
</div>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="mealSlotModal" tabindex="-1" aria-labelledby="mealSlotModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title h5" id="mealSlotModalLabel">Choose a meal</h2>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="slotPickerDay" value="">
|
||||
<input type="hidden" id="slotPickerType" value="">
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Filter by tag (optional)</label>
|
||||
<select class="form-select" id="slotPickerFilter">
|
||||
<option value="">All meals</option>
|
||||
<option value="breakfast">Breakfast</option>
|
||||
<option value="lunch">Lunch</option>
|
||||
<option value="dinner">Dinner</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="slotPickerPushGrocery" checked>
|
||||
<label class="form-check-label" for="slotPickerPushGrocery">Add this meal’s shopping items to grocery (pending review)</label>
|
||||
</div>
|
||||
<ul class="list-group" id="slotPickerList"></ul>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm mt-2" id="slotPickerClear">Clear slot</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="application/json" id="mealsLibraryJson"><?= json_encode(array_map(static function ($m) {
|
||||
return [
|
||||
'id' => (string) ($m['id'] ?? ''),
|
||||
'title' => (string) ($m['title'] ?? ''),
|
||||
'tags' => array_values($m['tags'] ?? []),
|
||||
];
|
||||
}, $meals), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_UNESCAPED_UNICODE) ?></script>
|
||||
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/db.php';
|
||||
require_once __DIR__ . '/../includes/utils.php';
|
||||
require_once __DIR__ . '/../includes/persona.php';
|
||||
require_once __DIR__ . '/../includes/grocery_helpers.php';
|
||||
|
||||
$canManage = count($people) === 0
|
||||
|| (
|
||||
$activePerson !== null
|
||||
&& ($activePerson['role'] ?? '') === ROLE_HEAD
|
||||
&& isHohVerified()
|
||||
);
|
||||
|
||||
$permanenceOptions = [
|
||||
'permanent' => 'Permanent',
|
||||
'weekly' => 'Weekly',
|
||||
'biweekly' => 'Bi-weekly',
|
||||
'monthly' => 'Monthly',
|
||||
'quarterly' => 'Quarterly',
|
||||
'yearly' => 'Yearly',
|
||||
];
|
||||
?>
|
||||
|
||||
<div id="settings" class="tab-content">
|
||||
<h2 class="mb-3">Family settings</h2>
|
||||
<p class="text-muted">People and chore currency defaults apply across the whole hub. Heads of household verify with a PIN when switching profiles at the top of the page.</p>
|
||||
|
||||
<?php if (count($people) === 0): ?>
|
||||
<div class="card mb-4 border-primary" id="firstSetupCard">
|
||||
<div class="card-header bg-primary text-white">First-time setup</div>
|
||||
<div class="card-body">
|
||||
<p>Create the first <strong>Head of household</strong> profile so you can manage family members and settings.</p>
|
||||
<form id="firstHeadForm" class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="first_name">Name</label>
|
||||
<input class="form-control" id="first_name" name="name" required autocomplete="name">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="first_pin">PIN (4+ characters)</label>
|
||||
<input type="password" class="form-control" id="first_pin" name="pin" required minlength="4" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="first_favoriteColor">Favorite color</label>
|
||||
<input type="color" class="form-control form-control-color" id="first_favoriteColor" name="favoriteColor" value="#4a90e2">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-primary btn-lg">Create profile</button>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="alert d-none" id="firstHeadFeedback" role="status"></div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">Chore economy</div>
|
||||
<div class="card-body">
|
||||
<?php if (!$canManage && count($people) > 0): ?>
|
||||
<p class="text-muted">Switch to a verified Head of household to edit these settings.</p>
|
||||
<?php endif; ?>
|
||||
<form id="familySettingsForm" class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="currency_symbol">Currency symbol</label>
|
||||
<input class="form-control" id="currency_symbol" name="currency_symbol" maxlength="8"
|
||||
value="<?= sanitizeInput($familySettings['currency_symbol'] ?? '') ?>"
|
||||
<?= !$canManage ? 'disabled' : '' ?>>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="currency_name">Currency name</label>
|
||||
<input class="form-control" id="currency_name" name="currency_name"
|
||||
value="<?= sanitizeInput($familySettings['currency_name'] ?? '') ?>"
|
||||
<?= !$canManage ? 'disabled' : '' ?>>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="currency_permanence">Balance reset</label>
|
||||
<select class="form-select" id="currency_permanence" name="currency_permanence" <?= !$canManage ? 'disabled' : '' ?>>
|
||||
<?php foreach ($permanenceOptions as $val => $label): ?>
|
||||
<option value="<?= htmlspecialchars($val, ENT_QUOTES, 'UTF-8') ?>" <?= ($familySettings['currency_permanence'] ?? '') === $val ? 'selected' : '' ?>>
|
||||
<?= htmlspecialchars($label, ENT_QUOTES, 'UTF-8') ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="timezone">Timezone</label>
|
||||
<input class="form-control" id="timezone" name="timezone"
|
||||
value="<?= sanitizeInput($familySettings['timezone'] ?? 'UTC') ?>"
|
||||
<?= !$canManage ? 'disabled' : '' ?>>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="week_starts_on">Week starts on (0 = Sunday)</label>
|
||||
<input type="number" class="form-control" id="week_starts_on" name="week_starts_on" min="0" max="6"
|
||||
value="<?= (int) ($familySettings['week_starts_on'] ?? 0) ?>"
|
||||
<?= !$canManage ? 'disabled' : '' ?>>
|
||||
</div>
|
||||
<?php if ($canManage): ?>
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-primary">Save settings</button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="col-12">
|
||||
<div class="alert d-none" id="familySettingsFeedback" role="status"></div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
$storesList = normalizeStoresList(readJsonFile('stores.json'));
|
||||
?>
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">Grocery stores</div>
|
||||
<div class="card-body">
|
||||
<p class="small text-muted">Each store has its own list in the <strong>Grocery list</strong> tab.</p>
|
||||
<?php if ($canManage): ?>
|
||||
<form id="storeAddForm" class="row g-2 align-items-end mb-3">
|
||||
<div class="col-md-8">
|
||||
<label class="form-label" for="new_store_name">New store name</label>
|
||||
<input class="form-control" id="new_store_name" required maxlength="80" placeholder="e.g. Costco">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<button type="submit" class="btn btn-primary">Add store</button>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="alert d-none" id="storeAddFeedback" role="status"></div>
|
||||
</div>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<p class="text-muted small">Only a verified Head of household can manage stores.</p>
|
||||
<?php endif; ?>
|
||||
<?php if (count($storesList) === 0): ?>
|
||||
<p class="text-muted mb-0">No stores yet. Add one above (or open the Grocery list tab to get a default Home store).</p>
|
||||
<?php else: ?>
|
||||
<ul class="list-group">
|
||||
<?php foreach ($storesList as $st): ?>
|
||||
<?php $stid = htmlspecialchars((string) ($st['id'] ?? ''), ENT_QUOTES, 'UTF-8'); ?>
|
||||
<li class="list-group-item d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||
<span><?= sanitizeInput($st['name'] ?? '') ?></span>
|
||||
<?php if ($canManage): ?>
|
||||
<span class="btn-group">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary btn-store-rename" data-id="<?= $stid ?>" data-name="<?= htmlspecialchars((string) ($st['name'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">Rename</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger btn-store-delete" data-id="<?= $stid ?>">Delete</button>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (count($people) > 0): ?>
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>People</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<?php if ($canManage): ?>
|
||||
<form id="addPersonForm" class="row g-3 mb-4 pb-3 border-bottom">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="add_name">Name</label>
|
||||
<input class="form-control" id="add_name" required>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="add_role">Role</label>
|
||||
<select class="form-select" id="add_role">
|
||||
<option value="<?= ROLE_CHILD ?>">Child</option>
|
||||
<option value="<?= ROLE_ADULT ?>">Adult</option>
|
||||
<option value="<?= ROLE_HEAD ?>">Head of household</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4" id="add_pin_wrap">
|
||||
<label class="form-label" for="add_pin">PIN (required for Head of household)</label>
|
||||
<input type="password" class="form-control" id="add_pin" minlength="4" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="add_favoriteColor">Favorite color</label>
|
||||
<input type="color" class="form-control form-control-color" id="add_favoriteColor" value="#4a90e2">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-success">Add person</button>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="alert d-none" id="addPersonFeedback" role="status"></div>
|
||||
</div>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<p class="text-muted">Only a verified Head of household can add or edit people.</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle" id="peopleTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Role</th>
|
||||
<th class="text-end">Balance</th>
|
||||
<?php if ($canManage): ?><th></th><?php endif; ?>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($people as $p): ?>
|
||||
<tr data-person-id="<?= htmlspecialchars($p['id'] ?? '', ENT_QUOTES, 'UTF-8') ?>">
|
||||
<td><?= sanitizeInput($p['name'] ?? '') ?></td>
|
||||
<td><?= sanitizeInput($p['role'] ?? '') ?></td>
|
||||
<td class="text-end"><?= sanitizeInput((string) ($p['currency_balance'] ?? 0)) ?> <?= sanitizeInput($familySettings['currency_symbol'] ?? '') ?></td>
|
||||
<?php if ($canManage): ?>
|
||||
<td class="text-end text-nowrap">
|
||||
<?php if (($p['role'] ?? '') === ROLE_HEAD): ?>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary btn-reset-pin" data-id="<?= htmlspecialchars($p['id'] ?? '', ENT_QUOTES, 'UTF-8') ?>">Reset PIN</button>
|
||||
<?php endif; ?>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger btn-delete-person" data-id="<?= htmlspecialchars($p['id'] ?? '', ENT_QUOTES, 'UTF-8') ?>">Remove</button>
|
||||
</td>
|
||||
<?php endif; ?>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
Reference in New Issue
Block a user