Skip to content

Commit 3034de3

Browse files
Merge pull request #71 from niteeshkanna-sh/claude/stoic-rubin-fnglq7
The Inquiries badge counts what nobody has opened
2 parents f71f1c4 + 684106f commit 3034de3

6 files changed

Lines changed: 148 additions & 16 deletions

File tree

‎admin/admin.css‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -965,6 +965,16 @@ label.finance-toggle {
965965
transition: box-shadow 0.15s ease;
966966
}
967967
.enquiry-card:hover { box-shadow: var(--shadow); }
968+
/* Not yet opened by anyone. A gold edge and a heavier name, so the ones that
969+
still need a first look are findable in the list and not only countable in
970+
the sidebar. Deliberately quiet: this marks "new to you", not "urgent". */
971+
.enquiry-card.is-unread { border-left: 3px solid var(--accent); }
972+
.enquiry-card.is-unread .enquiry-name { font-weight: 800; }
973+
.enquiry-card.is-unread .num::after {
974+
content: 'Unread'; margin-left: 8px; padding: 1px 7px; border-radius: 999px;
975+
background: #FFF3DC; color: var(--accent-deep); font-size: 0.68rem;
976+
font-weight: 700; letter-spacing: 0.02em; vertical-align: 1px;
977+
}
968978
.enquiry-card .row { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
969979
.enquiry-card .num { font-weight: 800; color: var(--accent-deep); font-size: 0.9rem; }
970980
.enquiry-card .enquiry-name { font-weight: 700; color: var(--ink); font-size: 1rem; }

‎admin/admin.js‎

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -496,23 +496,41 @@ document.getElementById('enquirySearch')?.addEventListener('input', (e) => {
496496
}, 300);
497497
});
498498

499+
/**
500+
* The number beside Inquiries in the sidebar: how many nobody has opened.
501+
*
502+
* Unread, not unresolved. An enquiry stays Contacted for days while somebody
503+
* waits on a callback, and a badge that sits on 1 the whole time is a badge
504+
* nobody reads -- the morning it says 3 looks like every other morning. The
505+
* server counts it, because the list on screen is filtered and the badge is
506+
* about the whole table.
507+
*/
508+
function setInquiryBadge(unread) {
509+
const badge = document.getElementById('inquiryCount');
510+
if (!badge) return;
511+
// Emptied rather than zeroed: the stylesheet hides an empty badge, and a
512+
// grey "0" beside Inquiries is a thing to read every morning that never
513+
// means anything.
514+
badge.textContent = Number(unread) || '';
515+
}
516+
499517
async function renderInquiries() {
500518
const wrap = document.getElementById('inquiriesWrap');
501-
const countBadge = document.getElementById('inquiryCount');
502519
if (!wrap) return;
503520

504-
let enquiries;
521+
let answer;
505522
try {
506-
enquiries = (await api.enquiries.list(enquiryStatusFilter, enquirySearch)).enquiries;
523+
answer = await api.enquiries.list(enquiryStatusFilter, enquirySearch);
507524
} catch (err) {
508525
wrap.innerHTML = '<div class="empty-state">Could not load enquiries.</div>';
509526
showError(err);
510527
return;
511528
}
512-
513-
// The badge counts what still needs attention, not everything ever received.
514-
const outstanding = enquiries.filter((e) => ['New', 'Contacted', 'Pending'].includes(e.status)).length;
515-
if (countBadge) countBadge.textContent = outstanding || '';
529+
const enquiries = answer.enquiries;
530+
setInquiryBadge(answer.unread);
531+
// Only mark cards when the database knows what has been read. Otherwise
532+
// every viewed_at is null and the whole list would claim to be unread.
533+
const tracksRead = answer.tracks_read !== false;
516534

517535
if (!enquiries.length) {
518536
wrap.innerHTML = enquirySearch || enquiryStatusFilter
@@ -522,7 +540,7 @@ async function renderInquiries() {
522540
}
523541

524542
wrap.innerHTML = enquiries.map((e) => `
525-
<div class="enquiry-card" data-id="${e.id}">
543+
<div class="enquiry-card${tracksRead && !e.viewed_at ? ' is-unread' : ''}" data-id="${e.id}">
526544
<div class="row">
527545
<span class="num">${e.enquiry_number}</span>
528546
<span class="enquiry-name">${escapeHTML(e.name)}</span>
@@ -576,7 +594,12 @@ async function renderEnquiry(id) {
576594
const body = document.getElementById('enquiryModalBody');
577595
let e;
578596
try {
579-
e = (await api.enquiries.get(id)).enquiry;
597+
const answer = await api.enquiries.get(id);
598+
e = answer.enquiry;
599+
// Opening it is what marks it read, so the badge is right the moment the
600+
// modal appears rather than after the next refresh of the list behind it.
601+
setInquiryBadge(answer.unread);
602+
document.querySelector(`.enquiry-card[data-id="${id}"]`)?.classList.remove('is-unread');
580603
} catch (err) {
581604
body.innerHTML = '<p class="detail-empty">Could not load this enquiry.</p>';
582605
showError(err);

‎admin/api/enquiries.php‎

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
declare(strict_types=1);
33

44
require_once __DIR__ . '/../src/booking.php';
5+
require_once __DIR__ . '/../src/enquiry.php';
56

67
/**
78
* Enquiry management.
@@ -50,13 +51,28 @@
5051
$params
5152
);
5253

53-
json_out(['enquiries' => array_map('present_enquiry', $rows)]);
54+
json_out([
55+
'enquiries' => array_map('present_enquiry', $rows),
56+
// Sent with the list so the badge never needs a request of its own,
57+
// and counted over the whole table rather than the rows returned --
58+
// the list is filtered and paged, the badge is not.
59+
'unread' => enquiry_unread(),
60+
// Whether this database tracks reading at all. Without it every
61+
// row's viewed_at is null, which is indistinguishable from never
62+
// opened -- and a panel one migration behind would mark the whole
63+
// list unread. Nothing is marked until the column exists.
64+
'tracks_read' => enquiry_read_ready(),
65+
]);
5466
}
5567

5668
// ----------------------------------------------------------------- get --
5769
case 'get': {
58-
api_guard('enquiry.view');
59-
$row = fetch_one(
70+
// Not a write guard: this is a GET, and passing true would demand a
71+
// POST. Marking the enquiry read below is a side effect of reading it,
72+
// the way opening a message marks it read, and the worst a forged
73+
// request could do is clear a badge for somebody already signed in.
74+
$user = api_guard('enquiry.view');
75+
$row = fetch_one(
6076
'SELECT e.*, v.name AS vehicle_name, b.booking_number
6177
FROM enquiries e
6278
LEFT JOIN vehicles v ON v.id = e.vehicle_id
@@ -67,7 +83,14 @@
6783
if ($row === null) {
6884
json_error('That enquiry no longer exists.', 404);
6985
}
70-
json_out(['enquiry' => present_enquiry($row, true)]);
86+
// Opening it is what counts as reading it -- the badge clears itself
87+
// rather than waiting for somebody to change the status.
88+
enquiry_mark_seen((int) $row['id'], (int) $user['id']);
89+
90+
json_out([
91+
'enquiry' => present_enquiry($row, true),
92+
'unread' => enquiry_unread(),
93+
]);
7194
}
7295

7396
// --------------------------------------------------------------- status --
@@ -315,6 +338,7 @@ function present_enquiry(array $row, bool $detailed = false): array
315338
'source' => $row['source'],
316339
'booking_number' => $row['booking_number'],
317340
'created_at' => $row['created_at'],
341+
'viewed_at' => $row['viewed_at'] ?? null,
318342
];
319343

320344
if ($detailed) {

‎admin/sql/013_enquiry_read.sql‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
-- An enquiry that has been looked at, and who looked.
2+
--
3+
-- The sidebar badge used to count everything not yet resolved, which meant it
4+
-- sat on "1" from the moment an enquiry arrived until somebody closed it out
5+
-- days later. A number that is always on is a number nobody reads, and the
6+
-- one morning it says 3 looks the same as every other morning.
7+
--
8+
-- Read and resolved are different questions. Status says where the enquiry
9+
-- got to; this says whether anyone has seen it. The badge asks the second.
10+
ALTER TABLE enquiries
11+
ADD COLUMN viewed_at DATETIME NULL AFTER handled_at,
12+
ADD COLUMN viewed_by INT UNSIGNED NULL AFTER viewed_at;
13+
14+
ALTER TABLE enquiries
15+
ADD KEY ix_enquiries_unread (viewed_at),
16+
ADD CONSTRAINT fk_enquiries_viewer FOREIGN KEY (viewed_by) REFERENCES users (id);
17+
18+
-- Everything already dealt with counts as seen. Without this the badge would
19+
-- light up with the whole history the first time the panel loads after the
20+
-- update, which is exactly the noise this is meant to remove.
21+
UPDATE enquiries SET viewed_at = COALESCE(handled_at, created_at)
22+
WHERE status <> 'New' AND viewed_at IS NULL;

‎admin/src/enquiry.php‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
require_once __DIR__ . '/db.php';
5+
6+
/**
7+
* Which enquiries have been looked at.
8+
*
9+
* Read and resolved are different questions. Status says where an enquiry got
10+
* to -- contacted, accepted, converted. This says whether anybody has opened
11+
* it yet, which is what the sidebar badge is actually asking.
12+
*/
13+
14+
/**
15+
* Whether this database knows what has been read.
16+
*
17+
* Guarded like every other new column, so a panel that has not run the
18+
* migration yet keeps working -- it simply never shows an unread count.
19+
*/
20+
function enquiry_read_ready(): bool
21+
{
22+
return table_has_column('enquiries', 'viewed_at');
23+
}
24+
25+
/**
26+
* How many enquiries nobody has opened.
27+
*
28+
* This is what the sidebar badge counts. Not "how many are unresolved": an
29+
* enquiry stays Contacted for days while somebody waits for a callback, and a
30+
* badge that sits on 1 the whole time is one nobody looks at, so the morning
31+
* it says 3 reads the same as every other morning.
32+
*/
33+
function enquiry_unread(): int
34+
{
35+
if (!enquiry_read_ready()) {
36+
return 0;
37+
}
38+
return (int) (fetch_one(
39+
'SELECT COUNT(*) AS n FROM enquiries WHERE viewed_at IS NULL'
40+
)['n'] ?? 0);
41+
}
42+
43+
/** Stamps an enquiry as seen, the first time it is opened and never again. */
44+
function enquiry_mark_seen(int $id, int $userId): void
45+
{
46+
if (!enquiry_read_ready()) {
47+
return;
48+
}
49+
// The condition matters: opening an enquiry a second time must not move
50+
// the timestamp, or "when was this first picked up" stops being answerable.
51+
query('UPDATE enquiries SET viewed_at = NOW(), viewed_by = ?
52+
WHERE id = ? AND viewed_at IS NULL', [$userId, $id]);
53+
}

‎build.json‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
2-
"publishedAt": "2026-09-22T10:11:10.934Z",
3-
"commit": "81fb4734",
4-
"commitAt": "2026-09-22T09:50:03+00:00",
2+
"publishedAt": "2026-09-22T10:27:20.417Z",
3+
"commit": "6d3ff940",
4+
"commitAt": "2026-09-22T10:11:50+00:00",
55
"assets": [
66
"About-DF8GNQ_N.js",
77
"Bikes-igQ3kzmM.js",

0 commit comments

Comments
 (0)