progress.php 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877
  1. <?php
  2. error_reporting(E_ALL);
  3. ini_set("display_errors", 0);
  4. ini_set("log_errors", 1);
  5. date_default_timezone_set("Australia/Hobart");
  6. // Adjust path if your contracts live elsewhere.
  7. if (!defined('CONTRACTS_DIR')) {
  8. define('CONTRACTS_DIR', realpath(__DIR__ . '/contracts'));
  9. }
  10. function contract_path_for_client(string $clientid): string {
  11. $id = preg_replace('/[^A-Za-z0-9_-]/', '', $clientid);
  12. return rtrim(CONTRACTS_DIR, '/\\') . DIRECTORY_SEPARATOR . $id . '.md';
  13. }
  14. /** Very small front-matter puller; same idea as contracts admin */
  15. function extract_front_matter_fields_progress(string $file): array {
  16. $out = [];
  17. $txt = @file_get_contents($file);
  18. if (!$txt) return $out;
  19. if (!preg_match('/^---\s*\R(.*?)\R---/s', $txt, $m)) return $out;
  20. $fm = $m[1];
  21. // admin.secret or admin_secret
  22. if (preg_match('/^\s*admin\s*:\s*$(.*?)^(?=\S)/ms', $fm."\nX", $block)) {
  23. $adminBlock = $block[1];
  24. if (preg_match('/^\s*secret\s*:\s*["\']?([^"\']+)["\']?/mi', $adminBlock, $mm)) {
  25. $out['admin_secret'] = trim($mm[1]);
  26. }
  27. }
  28. if (empty($out['admin_secret']) && preg_match('/^\s*admin_secret\s*:\s*["\']?([^"\']+)["\']?/mi', $fm, $mm)) {
  29. $out['admin_secret'] = trim($mm[1]);
  30. }
  31. return $out;
  32. }
  33. /** Build the exact token we expect for the public progress page */
  34. function progress_expected_token(string $clientid, $appId): ?string {
  35. $path = contract_path_for_client($clientid);
  36. $fm = extract_front_matter_fields_progress($path);
  37. $secret = $fm['admin_secret'] ?? '';
  38. if ($secret === '') return null;
  39. return hash_hmac('sha256', 'progress|' . (string)$appId, $secret);
  40. }
  41. $cfg = require __DIR__ . '/config.php';
  42. $dsn = 'mysql:host=' . $cfg['db_host'] . ';dbname=' . $cfg['db_name'] . ';charset=utf8mb4';
  43. $options = [
  44. PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
  45. PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
  46. ];
  47. try {
  48. $pdo = new PDO($dsn, $cfg['db_username'], $cfg['db_password'], $options);
  49. } catch (PDOException $e) {
  50. error_log('Database connection failed: ' . $e->getMessage());
  51. http_response_code(500);
  52. exit('Service unavailable');
  53. }
  54. $app_id_raw = $_GET['id'] ?? '';
  55. $token = $_GET['token'] ?? '';
  56. $app_id = preg_match('/^\d+$/', $app_id_raw) ? $app_id_raw : '0';
  57. // Verify token (optional: match your token logic)
  58. $stmt = $pdo->prepare("SELECT client_email, reference, created_at, submission_date, required_by, clock_paused, clock_paused_at, clock_pause_reason FROM applications WHERE id = ?");
  59. $stmt->execute([$app_id]);
  60. $app = $stmt->fetch(PDO::FETCH_ASSOC);
  61. if (!$app) {
  62. http_response_code(404);
  63. exit("Application not found.");
  64. }
  65. // Fetch stages
  66. $stmt = $pdo->prepare("SELECT * FROM application_stages WHERE application_id = ? ORDER BY position ASC");
  67. $stmt->execute([$app_id]);
  68. $stages = $stmt->fetchAll(PDO::FETCH_ASSOC);
  69. $totalStages = 7;
  70. $currentStage = count(array_filter($stages, function ($s) {
  71. return strtolower(trim($s['status'] ?? '')) === 'complete';
  72. }));
  73. $progress = round(($currentStage / $totalStages) * 100);
  74. $isPaused = (int)($app['clock_paused'] ?? 0) === 1;
  75. $pauseReason = trim((string)($app['clock_pause_reason'] ?? ''));
  76. $decisionDate = null;
  77. // 1) Look for an explicit 'Council Decision Due' stage date
  78. $decisionStage = null;
  79. foreach ($stages as $s) {
  80. if (stripos($s['title'] ?? '', 'decision') !== false && !empty($s['stage_date'])) {
  81. $decisionStage = $s;
  82. break;
  83. }
  84. }
  85. if ($decisionStage) {
  86. $decisionDate = new DateTime($decisionStage['stage_date'], new DateTimeZone('Australia/Hobart'));
  87. } elseif (!empty($app['required_by'])) {
  88. $decisionDate = new DateTime($app['required_by'], new DateTimeZone('Australia/Hobart'));
  89. } elseif (!empty($app['submission_date'])) {
  90. $decisionDate = (new DateTime($app['submission_date'], new DateTimeZone('Australia/Hobart')))->modify('+42 days');
  91. }
  92. // set a friendly “end of business day” time so the countdown isn’t midnight-awkward
  93. if ($decisionDate) { $decisionDate->setTime(17, 0, 0); }
  94. $decisionIso = $decisionDate ? $decisionDate->format('c') : '';
  95. // --- Create correspondence entry ---
  96. if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'add_correspondence') {
  97. $tz = new DateTimeZone('Australia/Hobart');
  98. $typeAllow = ['incoming','outgoing','note'];
  99. $channelAllow = ['email','phone','portal','letter','meeting','other'];
  100. $visibilityAllow= ['client','internal'];
  101. $type = in_array($_POST['type'] ?? 'note', $typeAllow, true) ? $_POST['type'] : 'note';
  102. $channel = in_array($_POST['channel'] ?? 'other', $channelAllow, true) ? $_POST['channel'] : 'other';
  103. $visibility = in_array($_POST['visibility'] ?? 'client', $visibilityAllow, true) ? $_POST['visibility'] : 'client';
  104. $subject = trim($_POST['subject'] ?? '') ?: null;
  105. $author = trim($_POST['author'] ?? '') ?: null;
  106. $pin = isset($_POST['pin']) ? 1 : 0;
  107. $bodyRaw = trim($_POST['body'] ?? '');
  108. if ($bodyRaw === '') { $bodyRaw = '(no content)'; }
  109. // event_at: prefer user input, else "now"
  110. $eventAtRaw = trim($_POST['event_at'] ?? '');
  111. try {
  112. $eventAt = $eventAtRaw ? new DateTime($eventAtRaw, $tz) : new DateTime('now', $tz);
  113. } catch (Exception $e) {
  114. $eventAt = new DateTime('now', $tz);
  115. }
  116. $stmt = $pdo->prepare("
  117. INSERT INTO application_correspondence
  118. (application_id, event_at, type, channel, subject, body, author, visibility, pin)
  119. VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
  120. ");
  121. $stmt->execute([
  122. $app_id,
  123. $eventAt->format('Y-m-d H:i:s'),
  124. $type,
  125. $channel,
  126. $subject,
  127. $bodyRaw,
  128. $author,
  129. $visibility,
  130. $pin
  131. ]);
  132. // Redirect to avoid resubmission and jump to the timeline section
  133. header("Location: ".$_SERVER['REQUEST_URI']."#correspondence");
  134. exit;
  135. }
  136. // Fetch timeline (newest first; pinned first)
  137. $stmt = $pdo->prepare("
  138. SELECT id, event_at, type, channel, subject, body, author, visibility, pin, created_at
  139. FROM application_correspondence
  140. WHERE application_id = ?
  141. ORDER BY pin DESC, event_at DESC, id DESC
  142. LIMIT 200
  143. ");
  144. $stmt->execute([$app_id]);
  145. $correspondence = $stmt->fetchAll(PDO::FETCH_ASSOC);
  146. /* NEW: attachment counts (and optional details) */
  147. $fileCounts = [];
  148. $filesByCorr = []; // if you also want to list the files
  149. if (!empty($correspondence)) {
  150. $ids = array_column($correspondence, 'id');
  151. $ph = implode(',', array_fill(0, count($ids), '?'));
  152. // Count files per correspondence
  153. $qc = $pdo->prepare("
  154. SELECT correspondence_id, COUNT(*) AS n
  155. FROM application_correspondence_files
  156. WHERE correspondence_id IN ($ph)
  157. GROUP BY correspondence_id
  158. ");
  159. $qc->execute($ids);
  160. foreach ($qc->fetchAll(PDO::FETCH_ASSOC) as $r) {
  161. $fileCounts[(int)$r['correspondence_id']] = (int)$r['n'];
  162. }
  163. // OPTIONAL: load file details if you want links
  164. $qd = $pdo->prepare("
  165. SELECT correspondence_id, original_name, file_url
  166. FROM application_correspondence_files
  167. WHERE correspondence_id IN ($ph)
  168. ORDER BY id ASC
  169. ");
  170. $qd->execute($ids);
  171. foreach ($qd->fetchAll(PDO::FETCH_ASSOC) as $f) {
  172. $cid = (int)$f['correspondence_id'];
  173. if (!isset($filesByCorr[$cid])) $filesByCorr[$cid] = [];
  174. $filesByCorr[$cid][] = $f;
  175. }
  176. }
  177. // ------------------ HELPERS ------------------
  178. function render_body_html(string $text): string {
  179. // escape first
  180. $s = htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
  181. // linkify http(s)
  182. $s = preg_replace('~(https?://[^\s<]+)~i', '<a href="$1" target="_blank" rel="noopener">$1</a>', $s);
  183. // newlines to <br>
  184. return nl2br($s);
  185. }
  186. function stage_icon_for(string $title): string {
  187. $t = strtolower($title);
  188. if (str_contains($t,'submit')) return 'bi-upload';
  189. if (str_contains($t,'ack')) return 'bi-inbox';
  190. if (str_contains($t,'fee') || str_contains($t,'paid')) return 'bi-cash-coin';
  191. if (str_contains($t,'valid') || str_contains($t,'confirm')) return 'bi-check2-circle';
  192. if (str_contains($t,'advert')) return 'bi-megaphone';
  193. if (str_contains($t,'decision')) return 'bi-check-circle-fill';
  194. return 'bi-flag';
  195. }
  196. // Admin bypass: valid HTTP Basic Auth skips the client token check
  197. $_au = $cfg['admin_user'] ?? '';
  198. $_ap = $cfg['admin_pass'] ?? '';
  199. $isAdmin = $_au !== '' && $_ap !== ''
  200. && isset($_SERVER['PHP_AUTH_USER'])
  201. && $_SERVER['PHP_AUTH_USER'] === $_au
  202. && ($_SERVER['PHP_AUTH_PW'] ?? '') === $_ap;
  203. if (!$isAdmin) {
  204. // --- Require signed token from Contracts Admin link ---
  205. $clientid = $_GET['clientid'] ?? '';
  206. $token = $_GET['token'] ?? '';
  207. if (!preg_match('/^[A-Za-z0-9_-]+$/', $clientid)) {
  208. http_response_code(400);
  209. exit('Bad link (clientid).');
  210. }
  211. if ($token === '') {
  212. header('WWW-Authenticate: Basic realm="Modulos Contracts Admin"');
  213. http_response_code(403);
  214. exit('Missing token.');
  215. }
  216. // Build expected token from the .md front matter secret
  217. $expected = progress_expected_token($clientid, $app_id);
  218. if (!$expected || !hash_equals($expected, $token)) {
  219. http_response_code(403);
  220. exit('Invalid or expired link.');
  221. }
  222. }
  223. unset($_au, $_ap);
  224. ?>
  225. <!doctype html>
  226. <html lang="en">
  227. <head>
  228. <meta charset="utf-8">
  229. <meta name="viewport" content="width=device-width, initial-scale=1">
  230. <title> <?= htmlspecialchars($app['reference']) ?> – Application Progress</title>
  231. <link rel="shortcut icon" href="../../internal/images/blueprint.ico" type="image/x-icon">
  232. <meta name="robots" content="noindex">
  233. <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-LN+7fdVzj6u52u30Kp6M/trliBMCMKTyK833zpbD+pXdCLuTusPj697FH4R/5mcr" crossorigin="anonymous">
  234. <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/js/bootstrap.bundle.min.js" integrity="sha384-ndDqU0Gzau9qJ1lfW4pNLlhNTkCfHzAVBReH9diLvGRem5+R9g2FzA8ZGN954O5Q" crossorigin="anonymous"></script>
  235. <link href="../../internal/css/blueprint.css" rel="stylesheet">
  236. <link href="../../internal/css/print.css" rel="stylesheet" media="print">
  237. <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.min.css">
  238. <link href="progress.css" rel="stylesheet">
  239. <style>
  240. .popover-attachments {
  241. max-width: 360px;
  242. }
  243. .popover-attachments .popover-body {
  244. padding: .5rem .75rem;
  245. }
  246. /* wrapper so lots of steps can scroll horizontally on small screens */
  247. .timeline-wrap {
  248. overflow-x: auto;
  249. -webkit-overflow-scrolling: touch;
  250. padding-bottom: .25rem;
  251. /* keep scrollbar off arrows */
  252. }
  253. /* your snippet */
  254. #timeline {
  255. list-style: none;
  256. display: flex;
  257. width: 100%;
  258. margin: 0;
  259. }
  260. #timeline .icon {
  261. font-size: 14px;
  262. }
  263. #timeline li {
  264. flex: 1 1 0;
  265. min-width: 0;
  266. }
  267. #timeline li a {
  268. color: #FFF;
  269. display: block;
  270. background: #3498db;
  271. text-decoration: none;
  272. position: relative;
  273. height: 40px;
  274. line-height: 40px;
  275. padding: 0 12px 0 8px;
  276. text-align: center;
  277. margin-right: 23px;
  278. white-space: nowrap;
  279. }
  280. #timeline li:nth-child(even) a {
  281. background-color: #2980b9;
  282. }
  283. #timeline li:nth-child(even) a:before {
  284. border-color: #2980b9;
  285. border-left-color: transparent;
  286. }
  287. #timeline li:nth-child(even) a:after {
  288. border-left-color: #2980b9;
  289. }
  290. #timeline li:first-child a {
  291. padding-left: 15px;
  292. border-radius: 0;
  293. }
  294. #timeline li:first-child a:before {
  295. border: none;
  296. }
  297. #timeline li:last-child a {
  298. padding-right: 15px;
  299. border-radius: 0;
  300. }
  301. #timeline li:last-child a:after {
  302. border: none;
  303. }
  304. #timeline li a:before,
  305. #timeline li a:after {
  306. content: "";
  307. position: absolute;
  308. top: 0;
  309. border: 0 solid #3498db;
  310. border-width: 20px 10px;
  311. width: 0;
  312. height: 0;
  313. }
  314. #timeline li a:before {
  315. left: -20px;
  316. border-left-color: transparent;
  317. }
  318. #timeline li a:after {
  319. left: 100%;
  320. border-color: transparent;
  321. border-left-color: #3498db;
  322. }
  323. #timeline li a:hover {
  324. background-color: #1abc9c;
  325. }
  326. #timeline li a:hover:before {
  327. border-color: #1abc9c;
  328. border-left-color: transparent;
  329. }
  330. #timeline li a:hover:after {
  331. border-left-color: #1abc9c;
  332. }
  333. #timeline li a:active {
  334. background-color: #16a085;
  335. }
  336. #timeline li a:active:before {
  337. border-color: #16a085;
  338. border-left-color: transparent;
  339. }
  340. #timeline li a:active:after {
  341. border-left-color: #16a085;
  342. }
  343. /* Icon tweaks inside the pill */
  344. #timeline li a .bi {
  345. font-size: 1rem;
  346. vertical-align: -1px;
  347. margin-right: .35rem;
  348. }
  349. #timeline li a small {
  350. opacity: .9;
  351. margin-left: .35rem;
  352. }
  353. /* Stack on small screens (match original breakpoint) */
  354. @media (max-width: 1399px) {
  355. #timeline {
  356. display: block;
  357. }
  358. /* stop flex row */
  359. #timeline li {
  360. flex: 0 0 100%;
  361. max-width: 100%;
  362. margin: .5rem 0;
  363. }
  364. #timeline li a {
  365. height: auto;
  366. line-height: 1.3;
  367. padding: .65rem .9rem;
  368. /* comfy pill */
  369. border-radius: .25rem;
  370. }
  371. /* remove arrow heads when stacked */
  372. #timeline li a:before,
  373. #timeline li a:after {
  374. content: none !important;
  375. border: 0 !important;
  376. }
  377. }
  378. /* ---- STATE COLOURS (match original) ---- */
  379. #timeline li.is-current a {
  380. background: #97846E;
  381. /* brown */
  382. color: #EADFD7;
  383. /* cream text */
  384. }
  385. #timeline li.is-current a:before {
  386. border-color: #97846E;
  387. border-left-color: transparent;
  388. }
  389. #timeline li.is-current a:after {
  390. border-left-color: #97846E;
  391. }
  392. /* “Complete” = green pill + dark green text */
  393. #timeline li.is-complete a {
  394. background: #d1e7dd;
  395. color: #0f5132;
  396. }
  397. #timeline li.is-complete a:before {
  398. border-color: #d1e7dd;
  399. border-left-color: transparent;
  400. }
  401. #timeline li.is-complete a:after {
  402. border-left-color: #d1e7dd;
  403. }
  404. /* “Pending / Upcoming” greys */
  405. #timeline li.is-pending a {
  406. background: #e9ecef;
  407. color: #055160;
  408. /* same as original */
  409. }
  410. #timeline li.is-pending a:before {
  411. border-color: #e9ecef;
  412. border-left-color: transparent;
  413. }
  414. #timeline li.is-pending a:after {
  415. border-left-color: #e9ecef;
  416. }
  417. /* optional alias if you ever emit `is-upcoming` */
  418. #timeline li.is-upcoming a {
  419. background: #dee2e6;
  420. color: #495057;
  421. }
  422. #timeline li.is-upcoming a:before {
  423. border-color: #dee2e6;
  424. border-left-color: transparent;
  425. }
  426. #timeline li.is-upcoming a:after {
  427. border-left-color: #dee2e6;
  428. }
  429. /* Ensure state colours don’t change on hover */
  430. #timeline li.is-current a:hover,
  431. #timeline li.is-complete a:hover,
  432. #timeline li.is-pending a:hover,
  433. #timeline li.is-upcoming a:hover {
  434. background: inherit;
  435. color: inherit;
  436. }
  437. #timeline li.is-current a:hover:before,
  438. #timeline li.is-complete a:hover:before,
  439. #timeline li.is-pending a:hover:before,
  440. #timeline li.is-upcoming a:hover:before {
  441. border-color: inherit;
  442. border-left-color: transparent;
  443. }
  444. #timeline li.is-current a:hover:after,
  445. #timeline li.is-complete a:hover:after,
  446. #timeline li.is-pending a:hover:after,
  447. #timeline li.is-upcoming a:hover:after {
  448. border-left-color: currentColor;
  449. /* overridden below for exact bg match */
  450. }
  451. /* Keep triangle colour exactly the same as the pill bg */
  452. #timeline li.is-current a:hover:after {
  453. border-left-color: #97846E;
  454. }
  455. #timeline li.is-complete a:hover:after {
  456. border-left-color: #d1e7dd;
  457. }
  458. #timeline li.is-pending a:hover:after {
  459. border-left-color: #e9ecef;
  460. }
  461. #timeline li.is-upcoming a:hover:after {
  462. border-left-color: #dee2e6;
  463. }
  464. /* Dates row colours (xxl-only row in your markup) */
  465. .step-dates li.is-complete {
  466. color: #0f5132;
  467. }
  468. .step-dates li.is-current {
  469. color: #7c6a57;
  470. font-weight: 600;
  471. }
  472. .step-dates li.is-pending,
  473. .step-dates li.is-upcoming {
  474. color: #6c757d;
  475. }
  476. /* Stack rules you already added still apply */
  477. @media (max-width: 1399px) {
  478. #timeline {
  479. display: block;
  480. }
  481. #timeline li {
  482. flex: 0 0 100%;
  483. max-width: 100%;
  484. margin: .5rem 0;
  485. }
  486. #timeline li a {
  487. height: auto;
  488. line-height: 1.3;
  489. padding: .65rem .9rem;
  490. border-radius: .25rem;
  491. }
  492. #timeline li a:before,
  493. #timeline li a:after {
  494. content: none !important;
  495. border: 0 !important;
  496. }
  497. }
  498. </style>
  499. </head>
  500. <body>
  501. <!--
  502. <nav class="navbar bg-dark border-bottom border-body d-print-none" data-bs-theme="dark"><div class="container"><a class="navbar-brand text-white" href="#"><img src="../internal/images/blueprint-logo-light.png" width="30" height="24" class="d-inline-block align-text-top" alt="Modulos">
  503. Modulos Design
  504. </a></div></nav>
  505. -->
  506. <main class="container my-4">
  507. <div class="bg-white p-4 p-md-5 rounded-0 shadow-sm">
  508. <div class="row align-items-center page-header">
  509. <div class="col-12 col-md-6 text-start">
  510. <!-- CUSTOMER DETAILS HERE -->
  511. </div>
  512. <div class="col-12 col-md-6 text-end pt-2">
  513. <h3 class="fw-bold mb-1 text-dark">Development Application</h3>
  514. <h3 class="fw-bold mb-1 text-dark">Application No: <?= htmlspecialchars($app['reference']) ?> </h3>
  515. <h5 class="mb-0 text-muted">Started: <?= date("d M Y", strtotime($app['created_at'])) ?> </h5>
  516. </div>
  517. </div>
  518. <hr class="my-4">
  519. <div class="row my-4">
  520. <div class="col-12 align-self-center">
  521. <div class="steps"> <?php
  522. // Build a flexible steps array straight from DB rows (ordered by position)
  523. $steps = [];
  524. foreach ($stages as $row) {
  525. $steps[] = [
  526. 'title' => trim($row['title'] ?? '') ?: ('Stage ' . (string)($row['position'] + 1)),
  527. 'status' => in_array(strtolower($row['status'] ?? ''), ['complete','current','pending'], true)
  528. ? strtolower($row['status'])
  529. : 'pending',
  530. 'date' => $row['stage_date'] ?: ($row['updated_at'] ?: ($row['created_at'] ?? null)),
  531. ];
  532. }
  533. // Ensure we always have a “current”
  534. $hasCurrent = false;
  535. foreach ($steps as $s) { if (($s['status'] ?? '') === 'current') { $hasCurrent = true; break; } }
  536. if (!$hasCurrent && !empty($steps)) {
  537. $lastComplete = -1;
  538. foreach ($steps as $i => $s) if (($s['status'] ?? '') === 'complete') $lastComplete = $i;
  539. if ($lastComplete >= 0) $steps[$lastComplete]['status'] = 'current';
  540. else $steps[0]['status'] = 'current';
  541. }
  542. // formatter
  543. $fmtDate = function ($v) {
  544. $t = $v ? strtotime($v) : 0;
  545. return $t ? date('D d M y', $t) : '';
  546. };
  547. ?> <div class="timeline-wrap text-center my-4">
  548. <ul id="timeline" class="mb-0 w-100 "> <?php foreach ($steps as $s):
  549. $state = 'is-' . ($s['status'] ?? 'pending');
  550. $titleSafe = htmlspecialchars($s['title'], ENT_QUOTES, 'UTF-8');
  551. $dateText = $fmtDate($s['date']);
  552. $iconClass = stage_icon_for($s['title']);
  553. $isComplete = ($s['status'] === 'complete');
  554. $isCurrent = ($s['status'] === 'current');
  555. $prefix = (!$isComplete && !$isCurrent && $dateText) ? 'Due ' : '';
  556. $tooltip = $titleSafe . ($dateText ? ' — ' . $prefix . $dateText : '');
  557. ?>
  558. <li class="
  559. <?= $state ?>">
  560. <a href="#" title="
  561. <?= $tooltip ?>">
  562. <span class="bi
  563. <?= $iconClass ?>">
  564. </span> <?= $titleSafe ?> <?php if ($dateText): ?> <small class="d-block d-xxl-none opacity-75 ms-4"> <?= htmlspecialchars($prefix . $dateText, ENT_QUOTES, 'UTF-8') ?> </small> <?php endif; ?> </a>
  565. </li> <?php endforeach; ?> </ul>
  566. <ul class="step-dates d-none d-xxl-flex"> <?php foreach ($steps as $s):
  567. $state = 'is-' . ($s['status'] ?? 'pending');
  568. $dateText = $fmtDate($s['date']);
  569. $isComplete = ($s['status'] === 'complete');
  570. $isCurrent = ($s['status'] === 'current');
  571. $prefix = (!$isComplete && !$isCurrent && $dateText) ? 'Due ' : '';
  572. ?>
  573. <li class="
  574. <?= htmlspecialchars($state, ENT_QUOTES, 'UTF-8') ?>"> <?= $dateText ? htmlspecialchars($prefix . $dateText, ENT_QUOTES, 'UTF-8') : '&nbsp;' ?> </li> <?php endforeach; ?> </ul>
  575. </div>
  576. <div class="row px-5">
  577. <div class="col-12"> <?php if (!empty($decisionIso)): ?> <?php if ($isPaused): ?> <div class="alert alert-warning d-flex align-items-center" role="alert">
  578. <i class="bi bi-pause-circle me-2"></i>
  579. <div> Statutory clock paused by council request. <?php if ($pauseReason !== ''): ?> <span class="text-muted">Reason: <?= htmlspecialchars($pauseReason) ?> </span> <?php endif; ?> </div>
  580. </div>
  581. <!-- No ticking countdown shown while paused --> <?php else: ?> <div class="countdown" data-target-date="
  582. <?= htmlspecialchars($decisionIso) ?>">
  583. </div> <?php endif; ?> <?php endif; ?> </div>
  584. </div>
  585. <div class="row"> <?php if (empty($stages)): ?> <div class="col-12">
  586. <div class="alert alert-warning">This application has not started yet.</div>
  587. </div> <?php endif; ?> </div>
  588. <hr class="my-4">
  589. <div class="row">
  590. <div class="col-12 text-center">
  591. <h4>Timeline of Correspondence</h4>
  592. </div>
  593. </div>
  594. <div class="row py-3">
  595. <div class="col">
  596. <div class="timeline"> <?php
  597. $badgeMap = [
  598. 'email_incoming' => 'bi-envelope-arrow-up',
  599. 'email_outgoing' => 'bi-send-check',
  600. 'phone_incoming' => 'bi-telephone-inbound',
  601. 'phone_outgoing' => 'bi-telephone-outbound',
  602. 'note' => 'bi-journal-text'
  603. ];
  604. $fallbackByChannel = [
  605. 'email' => 'bi-envelope',
  606. 'phone' => 'bi-telephone',
  607. 'meeting' => 'bi-people',
  608. 'other' => 'bi-chat-dots'
  609. ];
  610. $typeLabel = ['incoming'=>'Incoming','outgoing'=>'Outgoing','note'=>'Note'];
  611. $chLabel = ['email'=>'Email','phone'=>'Phone','meeting'=>'Meeting','other'=>'Other'];
  612. foreach ($correspondence as $row):
  613. $id = (int)$row['id'];
  614. $typeVal = strtolower(trim($row['type'] ?? 'note'));
  615. $channelVal = strtolower(trim($row['channel'] ?? 'other'));
  616. $key = ($typeVal === 'note') ? 'note' : "{$channelVal}_{$typeVal}";
  617. $icon = $badgeMap[$key] ?? ($fallbackByChannel[$channelVal] ?? 'bi-journal-text');
  618. $when = (new DateTime($row['event_at'], new DateTimeZone('Australia/Hobart')))->format('d M Y, h:ia');
  619. $visBadge = $row['visibility']==='internal' ? '
  620. <span class="badge rounded-pill text-bg-secondary ms-2">Internal</span>' : '';
  621. $typeClass = 'type-'.preg_replace('/[^a-z]/','', $typeVal);
  622. $numFiles = $fileCounts[$id] ?? 0; // <- NEW
  623. $hasFiles = $numFiles > 0; // <- NEW
  624. ?> <div class="timeline-item
  625. <?= $row['pin'] ? 'pinned' : '' ?>">
  626. <div class="timeline-badge">
  627. <i class="bi
  628. <?= $icon ?>">
  629. </i>
  630. </div>
  631. <div class="timeline-panel
  632. <?= $typeClass ?>">
  633. <div class="timeline-heading d-flex justify-content-between align-items-start">
  634. <div>
  635. <h6 class="mb-1"> <?= $when ?> • <?= $typeLabel[$typeVal] ?? ucfirst($typeVal) ?> via <?= $chLabel[$channelVal] ?? ucfirst($channelVal) ?> <?= $visBadge ?> <?php if ($row['pin']): ?> <i class="bi bi-pin-angle-fill text-warning ms-1" title="Pinned"></i> <?php endif; ?> <?php if (!empty($filesByCorr[$id])): ?> <span class="ms-2 att-pop" role="button" tabindex="0" data-bs-toggle="popover" data-bs-trigger="hover focus" data-bs-placement="top" data-bs-custom-class="popover-attachments" data-content-id="att-popover-
  636. <?= $id ?>" title="Attachments (
  637. <?= (int)$numFiles ?>)">
  638. <i class="bi bi-paperclip"></i>
  639. </span> <?php endif; ?> </h6>
  640. <small class="text-muted"> <?= htmlspecialchars($row['subject'] ?: ucfirst($typeVal)) ?> <?= $row['author'] ? ' • by: '.htmlspecialchars($row['author']) : '' ?> </small>
  641. </div>
  642. </div>
  643. <div class="timeline-body mt-2 small"> <?= render_body_html($row['body']) ?> </div>
  644. </div>
  645. </div> <?php endforeach; ?> <?php if (empty($correspondence)): ?> <div class="text-muted">No correspondence recorded yet.</div> <?php endif; ?> </div>
  646. </div>
  647. </div>
  648. </div> <?php foreach ($filesByCorr as $cid => $files): ?> <div id="att-popover-
  649. <?= (int)$cid ?>" class="d-none"> <?php foreach ($files as $f): ?> <div class="py-1">
  650. <a href="
  651. <?= htmlspecialchars($f['file_url'], ENT_QUOTES) ?>" target="_blank" rel="noopener">
  652. <i class="bi bi-file-earmark-pdf"></i> <?= htmlspecialchars($f['original_name'], ENT_QUOTES) ?> </a>
  653. </div> <?php endforeach; ?> </div> <?php endforeach; ?>
  654. </main>
  655. <script>
  656. const countdownEls = document.querySelectorAll(".countdown")
  657. countdownEls.forEach(countdownEl => createCountdown(countdownEl))
  658. function createCountdown(countdownEl) {
  659. const target = new Date((countdownEl.dataset.targetDate || '').trim());
  660. const parts = {
  661. days: {
  662. text: ["days", "day"],
  663. dots: 30
  664. },
  665. hours: {
  666. text: ["hours", "hour"],
  667. dots: 24
  668. },
  669. minutes: {
  670. text: ["minutes", "minute"],
  671. dots: 60
  672. },
  673. seconds: {
  674. text: ["seconds", "second"],
  675. dots: 60
  676. },
  677. }
  678. Object.entries(parts).forEach(([key, value]) => {
  679. const partEl = document.createElement("div");
  680. partEl.classList.add("part", key);
  681. partEl.style.setProperty("--dots", value.dots);
  682. value.element = partEl;
  683. const remainingEl = document.createElement("div");
  684. remainingEl.classList.add("remaining");
  685. remainingEl.innerHTML = `
  686. <span class="number"></span>
  687. <span class="text"></span>`
  688. partEl.append(remainingEl);
  689. for (let i = 0; i < value.dots; i++) {
  690. const dotContainerEl = document.createElement("div");
  691. dotContainerEl.style.setProperty("--dot-idx", i);
  692. dotContainerEl.classList.add("dot-container")
  693. const dotEl = document.createElement("div");
  694. dotEl.classList.add("dot")
  695. dotContainerEl.append(dotEl);
  696. partEl.append(dotContainerEl);
  697. }
  698. countdownEl.append(partEl);
  699. })
  700. getRemainingTime(target, parts)
  701. }
  702. function getRemainingTime(target, parts, first = true) {
  703. const now = new Date();
  704. const diff = target - now;
  705. // Past or invalid date — freeze at all zeros
  706. if (isNaN(diff) || diff <= 0) {
  707. Object.entries(parts).forEach(([key, value]) => {
  708. value.element.querySelector(".number").innerText = 0;
  709. value.element.querySelector(".text").innerText = value.text[0];
  710. value.element.querySelectorAll(".dot").forEach(dot => {
  711. dot.dataset.active = false;
  712. dot.dataset.lastactive = false;
  713. });
  714. });
  715. return;
  716. }
  717. let seconds = Math.floor(diff / 1000);
  718. let minutes = Math.floor(seconds / 60);
  719. let hours = Math.floor(minutes / 60);
  720. let days = Math.floor(hours / 24);
  721. hours = hours - (days * 24);
  722. minutes = minutes - (days * 24 * 60) - (hours * 60);
  723. seconds = seconds - (days * 24 * 60 * 60) - (hours * 60 * 60) - (minutes * 60);
  724. Object.entries({ days, hours, minutes, seconds }).forEach(([key, value]) => {
  725. const remaining = parts[key].element.querySelector(".number");
  726. const text = parts[key].element.querySelector(".text");
  727. remaining.innerText = value;
  728. text.innerText = parts[key].text[Number(value === 1)];
  729. parts[key].element.querySelectorAll(".dot").forEach((dot, idx) => {
  730. dot.dataset.active = idx <= value;
  731. dot.dataset.lastactive = idx === value;
  732. });
  733. });
  734. window.requestAnimationFrame(() => getRemainingTime(target, parts, false));
  735. }
  736. document.getElementById('tryParse')?.addEventListener('click', function(e) {
  737. e.preventDefault();
  738. const body = document.getElementById('corrBody').value || '';
  739. const subj = /(?:^|\n)Subject:\s*(.+)/i.exec(body);
  740. const from = /(?:^|\n)From:\s*(.+)/i.exec(body);
  741. const date = /(?:^|\n)Date:\s*(.+)/i.exec(body);
  742. if (subj) document.getElementById('corrSubject').value = subj[1].trim();
  743. if (from) document.getElementById('corrAuthor').value = from[1].trim();
  744. if (date) {
  745. const guess = new Date(date[1]);
  746. if (!isNaN(guess.getTime())) {
  747. // to local datetime-local string
  748. const pad = n => String(n).padStart(2, '0');
  749. const v = guess.getFullYear() + '-' + pad(guess.getMonth() + 1) + '-' + pad(guess.getDate()) + 'T' + pad(guess.getHours()) + ':' + pad(guess.getMinutes());
  750. const el = document.querySelector('input[name="event_at"]');
  751. if (el) el.value = v;
  752. }
  753. }
  754. });
  755. document.addEventListener('DOMContentLoaded', () => {
  756. document.querySelectorAll('[data-bs-toggle="popover"][data-content-id]').forEach(el => {
  757. const id = el.getAttribute('data-content-id');
  758. const tpl = document.getElementById(id);
  759. const content = tpl ? tpl.innerHTML : '';
  760. new bootstrap.Popover(el, {
  761. html: true,
  762. content,
  763. container: 'body',
  764. sanitize: true, // keep Bootstrap’s sanitizer on
  765. trigger: 'hover focus' // hover on desktop, tap/focus on mobile
  766. });
  767. });
  768. });
  769. </script>
  770. </body>
  771. </html>