client.php 45 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840
  1. <?php
  2. /**
  3. * dashboard/consultant/client.php
  4. *
  5. * Per-client detail view for the Consultant Dashboard.
  6. *
  7. * Tabs:
  8. * Soil Tests — history table + pH trend + nutrient trend charts
  9. * Plant Tests — history table
  10. * Water Tests — history table
  11. * Alerts — out-of-range nutrients from latest soil test
  12. * Timeline — chronological feed across all test types
  13. *
  14. * URL params:
  15. * cid int client_records.id
  16. */
  17. require_once __DIR__ . '/../../config/database.php';
  18. require_once __DIR__ . '/../../lib/auth.php';
  19. require_once __DIR__ . '/../../lib/consultant.php';
  20. if (session_status() === PHP_SESSION_NONE) {
  21. session_start();
  22. }
  23. requireConsultant();
  24. $pdo = getDBConnection();
  25. $userId = (int) getCurrentUserId();
  26. $clientId = (int) ($_GET['cid'] ?? 0);
  27. if (!$clientId) {
  28. header('Location: /dashboard/consultant/index.php');
  29. exit;
  30. }
  31. // ── Load client record ────────────────────────────────────────────────────────
  32. $stmt = $pdo->prepare("SELECT * FROM client_records WHERE id = ? AND modx_user_id = ?");
  33. $stmt->execute([$clientId, $userId]);
  34. $client = $stmt->fetch();
  35. if (!$client) {
  36. http_response_code(404);
  37. die('Client not found or access denied.');
  38. }
  39. // ── Load soil tests ───────────────────────────────────────────────────────────
  40. $stmtSoil = $pdo->prepare("
  41. SELECT id, date_sampled, lab_no, sample_id, site_id, crop_type,
  42. soil_type, ph_cacl2, ph_h2o, NO3_N, p_morgan, k_morgan,
  43. ca_morgan, mg_morgan, ec, ocarbon, rand, status
  44. FROM soil_records
  45. WHERE CAST(client_records_id AS UNSIGNED) = ?
  46. ORDER BY date_sampled DESC, id DESC
  47. ");
  48. $stmtSoil->execute([$clientId]);
  49. $soilTests = $stmtSoil->fetchAll();
  50. // ── Load plant tests ──────────────────────────────────────────────────────────
  51. $stmtPlant = $pdo->prepare("
  52. SELECT id, date_sampled, lab_no, sample_id, site_id, crop_type,
  53. n, p, k, s, ca, mg, rand, status
  54. FROM plant_records
  55. WHERE client_records_id = ?
  56. ORDER BY date_sampled DESC, id DESC
  57. ");
  58. $stmtPlant->execute([$clientId]);
  59. $plantTests = $stmtPlant->fetchAll();
  60. // ── Load water tests ──────────────────────────────────────────────────────────
  61. $stmtWater = $pdo->prepare("
  62. SELECT id, date_sampled, lab_no, sample_id, site_id, crop_type,
  63. ph, cond_dsm, no3, p, k, ca, mg, na, rand, status
  64. FROM water_records
  65. WHERE client_records_id = ?
  66. ORDER BY date_sampled DESC, id DESC
  67. ");
  68. $stmtWater->execute([$clientId]);
  69. $waterTests = $stmtWater->fetchAll();
  70. // ── Alerts from latest soil test ──────────────────────────────────────────────
  71. $alertData = ['summary' => ['critical' => 0, 'watch' => 0], 'items' => []];
  72. $latestSoil = null;
  73. if (!empty($soilTests)) {
  74. // Fetch full row for latest test (we only selected subset above)
  75. $stmtFull = $pdo->prepare("SELECT * FROM soil_records WHERE id = ? LIMIT 1");
  76. $stmtFull->execute([$soilTests[0]['id']]);
  77. $latestSoil = $stmtFull->fetch();
  78. $spec = null;
  79. if (!empty($latestSoil['soil_type'])) {
  80. $stmtSpec = $pdo->prepare("SELECT * FROM soil_specifications WHERE soil_type = ? LIMIT 1");
  81. $stmtSpec->execute([$latestSoil['soil_type']]);
  82. $spec = $stmtSpec->fetch() ?: null;
  83. }
  84. $alertData = generateAlerts($latestSoil, $spec);
  85. }
  86. // ── Timeline: union across all test types ─────────────────────────────────────
  87. $timeline = [];
  88. foreach ($soilTests as $r) {
  89. $timeline[] = [
  90. 'type' => 'soil',
  91. 'date' => $r['date_sampled'],
  92. 'lab_no' => $r['lab_no'],
  93. 'sample_id' => $r['sample_id'],
  94. 'site_id' => $r['site_id'],
  95. 'crop_type' => $r['crop_type'],
  96. 'id' => $r['id'],
  97. 'rand' => $r['rand'],
  98. ];
  99. }
  100. foreach ($plantTests as $r) {
  101. $timeline[] = [
  102. 'type' => 'plant',
  103. 'date' => $r['date_sampled'],
  104. 'lab_no' => $r['lab_no'],
  105. 'sample_id' => $r['sample_id'],
  106. 'site_id' => $r['site_id'],
  107. 'crop_type' => $r['crop_type'],
  108. 'id' => $r['id'],
  109. 'rand' => $r['rand'],
  110. ];
  111. }
  112. foreach ($waterTests as $r) {
  113. $timeline[] = [
  114. 'type' => 'water',
  115. 'date' => $r['date_sampled'],
  116. 'lab_no' => $r['lab_no'],
  117. 'sample_id' => $r['sample_id'],
  118. 'site_id' => $r['site_id'],
  119. 'crop_type' => $r['crop_type'],
  120. 'id' => $r['id'],
  121. 'rand' => $r['rand'],
  122. ];
  123. }
  124. usort($timeline, fn($a, $b) => strcmp($b['date'] ?? '', $a['date'] ?? ''));
  125. // ── Chart.js data (soil tests in chronological order for trend lines) ─────────
  126. $chartSoil = array_reverse($soilTests); // oldest first
  127. $chartLabels = [];
  128. $chartPhCaCl2 = [];
  129. $chartPhH2O = [];
  130. $chartNO3N = [];
  131. $chartP = [];
  132. $chartK = [];
  133. $chartCa = [];
  134. $chartMg = [];
  135. foreach ($chartSoil as $r) {
  136. if (!$r['date_sampled']) continue;
  137. $chartLabels[] = date('j M Y', strtotime($r['date_sampled']));
  138. $chartPhCaCl2[] = $r['ph_cacl2'] !== '' && $r['ph_cacl2'] !== null ? (float) $r['ph_cacl2'] : null;
  139. $chartPhH2O[] = $r['ph_h2o'] !== '' && $r['ph_h2o'] !== null ? (float) $r['ph_h2o'] : null;
  140. $chartNO3N[] = $r['NO3_N'] !== '' && $r['NO3_N'] !== null ? (float) $r['NO3_N'] : null;
  141. $chartP[] = $r['p_morgan'] !== '' && $r['p_morgan'] !== null ? (float) $r['p_morgan'] : null;
  142. $chartK[] = $r['k_morgan'] !== '' && $r['k_morgan'] !== null ? (float) $r['k_morgan'] : null;
  143. $chartCa[] = $r['ca_morgan']!== '' && $r['ca_morgan']!== null ? (float) $r['ca_morgan']: null;
  144. $chartMg[] = $r['mg_morgan']!== '' && $r['mg_morgan']!== null ? (float) $r['mg_morgan']: null;
  145. }
  146. // ── Page setup ────────────────────────────────────────────────────────────────
  147. $clientName = htmlspecialchars($client['client'] ?? '—', ENT_QUOTES, 'UTF-8');
  148. $company = htmlspecialchars($client['company'] ?? '', ENT_QUOTES, 'UTF-8');
  149. $pageTitle = $clientName . ' — Client Detail';
  150. $siteName = 'Crop Monitor';
  151. $activeTab = $_GET['tab'] ?? 'soil';
  152. $totalAlerts = $alertData['summary']['critical'] + $alertData['summary']['watch'];
  153. include __DIR__ . '/../../layouts/header.php';
  154. include __DIR__ . '/../../layouts/navbar.php';
  155. ?>
  156. <div id="layoutSidenav">
  157. <div id="layoutSidenav_nav">
  158. <?php include __DIR__ . '/../../layouts/consultant-sidebar.php'; ?>
  159. </div>
  160. <div id="layoutSidenav_content">
  161. <main>
  162. <div class="container-fluid px-4">
  163. <!-- ── Breadcrumb ─────────────────────────────────────────── -->
  164. <div class="d-flex align-items-center justify-content-between mt-4 mb-2 flex-wrap gap-2">
  165. <div>
  166. <ol class="breadcrumb mb-1">
  167. <li class="breadcrumb-item"><a href="/dashboard/dashboard.php">Dashboard</a></li>
  168. <li class="breadcrumb-item"><a href="/dashboard/consultant/index.php">Consultant</a></li>
  169. <li class="breadcrumb-item active"><?= $clientName ?></li>
  170. </ol>
  171. <h1 class="h3 mb-0 fw-bold"><?= $clientName ?></h1>
  172. <?php if ($company): ?>
  173. <div class="text-muted"><?= $company ?></div>
  174. <?php endif; ?>
  175. </div>
  176. <div class="d-flex gap-2 flex-wrap">
  177. <?php if ($client['email']): ?>
  178. <a href="mailto:<?= htmlspecialchars($client['email'], ENT_QUOTES, 'UTF-8') ?>"
  179. class="btn btn-sm btn-outline-secondary">
  180. <i class="fas fa-envelope me-1"></i>
  181. <?= htmlspecialchars($client['email'], ENT_QUOTES, 'UTF-8') ?>
  182. </a>
  183. <?php endif; ?>
  184. <?php if ($client['phone'] || $client['mobile']): ?>
  185. <span class="btn btn-sm btn-outline-secondary disabled">
  186. <i class="fas fa-phone me-1"></i>
  187. <?= htmlspecialchars($client['phone'] ?: $client['mobile'], ENT_QUOTES, 'UTF-8') ?>
  188. </span>
  189. <?php endif; ?>
  190. </div>
  191. </div>
  192. <!-- ── Client meta row ────────────────────────────────────── -->
  193. <?php if ($client['address'] || $client['state_postcode']): ?>
  194. <p class="text-muted small mb-3">
  195. <i class="fas fa-map-marker-alt me-1"></i>
  196. <?= htmlspecialchars(trim($client['address'] . ' ' . $client['state_postcode']), ENT_QUOTES, 'UTF-8') ?>
  197. </p>
  198. <?php endif; ?>
  199. <!-- ── Summary badges ─────────────────────────────────────── -->
  200. <div class="d-flex flex-wrap gap-2 mb-4">
  201. <span class="badge rounded-pill bg-success bg-opacity-15 text-success border border-success border-opacity-25 px-3 py-2">
  202. <i class="fas fa-globe-asia me-1"></i><?= count($soilTests) ?> Soil Tests
  203. </span>
  204. <span class="badge rounded-pill bg-info bg-opacity-15 text-info border border-info border-opacity-25 px-3 py-2">
  205. <i class="fab fa-pagelines me-1"></i><?= count($plantTests) ?> Plant Tests
  206. </span>
  207. <span class="badge rounded-pill bg-primary bg-opacity-15 text-primary border border-primary border-opacity-25 px-3 py-2">
  208. <i class="fas fa-tint me-1"></i><?= count($waterTests) ?> Water Tests
  209. </span>
  210. <?php if ($totalAlerts > 0): ?>
  211. <span class="badge rounded-pill bg-<?= $alertData['summary']['critical'] > 0 ? 'danger' : 'warning' ?> px-3 py-2">
  212. <i class="fas fa-exclamation-triangle me-1"></i>
  213. <?= $alertData['summary']['critical'] ?> Critical &nbsp;·&nbsp; <?= $alertData['summary']['watch'] ?> Watch
  214. </span>
  215. <?php else: ?>
  216. <span class="badge rounded-pill bg-success px-3 py-2">
  217. <i class="fas fa-check me-1"></i>All nutrients in range
  218. </span>
  219. <?php endif; ?>
  220. </div>
  221. <!-- ── Tab nav ───────────────────────────────────────────── -->
  222. <ul class="nav nav-tabs mb-4" id="clientTabs" role="tablist">
  223. <li class="nav-item">
  224. <button class="nav-link <?= $activeTab === 'soil' ? 'active' : '' ?>"
  225. data-bs-toggle="tab" data-bs-target="#tab-soil" type="button">
  226. <i class="fas fa-globe-asia me-1"></i>
  227. Soil Tests
  228. <span class="badge bg-secondary ms-1"><?= count($soilTests) ?></span>
  229. </button>
  230. </li>
  231. <li class="nav-item">
  232. <button class="nav-link <?= $activeTab === 'plant' ? 'active' : '' ?>"
  233. data-bs-toggle="tab" data-bs-target="#tab-plant" type="button">
  234. <i class="fab fa-pagelines me-1"></i>
  235. Plant Tests
  236. <span class="badge bg-secondary ms-1"><?= count($plantTests) ?></span>
  237. </button>
  238. </li>
  239. <li class="nav-item">
  240. <button class="nav-link <?= $activeTab === 'water' ? 'active' : '' ?>"
  241. data-bs-toggle="tab" data-bs-target="#tab-water" type="button">
  242. <i class="fas fa-tint me-1"></i>
  243. Water Tests
  244. <span class="badge bg-secondary ms-1"><?= count($waterTests) ?></span>
  245. </button>
  246. </li>
  247. <li class="nav-item">
  248. <button class="nav-link <?= $activeTab === 'alerts' ? 'active' : '' ?>"
  249. data-bs-toggle="tab" data-bs-target="#tab-alerts" type="button">
  250. <i class="fas fa-exclamation-triangle me-1"></i>
  251. Alerts
  252. <?php if ($totalAlerts > 0): ?>
  253. <span class="badge bg-<?= $alertData['summary']['critical'] > 0 ? 'danger' : 'warning' ?> ms-1">
  254. <?= $totalAlerts ?>
  255. </span>
  256. <?php endif; ?>
  257. </button>
  258. </li>
  259. <li class="nav-item">
  260. <button class="nav-link <?= $activeTab === 'timeline' ? 'active' : '' ?>"
  261. data-bs-toggle="tab" data-bs-target="#tab-timeline" type="button">
  262. <i class="fas fa-history me-1"></i>Timeline
  263. </button>
  264. </li>
  265. </ul>
  266. <div class="tab-content" id="clientTabsContent">
  267. <!-- ════════════════════════════════════════════════════
  268. TAB: SOIL TESTS
  269. ════════════════════════════════════════════════════ -->
  270. <div class="tab-pane fade <?= $activeTab === 'soil' ? 'show active' : '' ?>"
  271. id="tab-soil" role="tabpanel">
  272. <?php if (empty($soilTests)): ?>
  273. <p class="text-muted">No soil tests recorded for this client.</p>
  274. <?php else: ?>
  275. <!-- Soil test table -->
  276. <div class="card shadow-sm border-0 mb-4">
  277. <div class="card-header d-flex justify-content-between align-items-center">
  278. <span class="fw-semibold">Soil Test History</span>
  279. <a href="/dashboard/crop-analysis/soil-test-data/index.php"
  280. class="btn btn-sm btn-success">
  281. <i class="fas fa-plus me-1"></i>New Test
  282. </a>
  283. </div>
  284. <div class="table-responsive">
  285. <table class="table table-hover table-sm mb-0 align-middle">
  286. <thead class="table-light">
  287. <tr>
  288. <th>Date Sampled</th>
  289. <th>Lab No.</th>
  290. <th>Sample / Site</th>
  291. <th>Crop</th>
  292. <th class="text-center">pH CaCl₂</th>
  293. <th class="text-center">pH H₂O</th>
  294. <th class="text-center">NO₃-N</th>
  295. <th class="text-center">P</th>
  296. <th class="text-center">K</th>
  297. <th class="text-center">EC</th>
  298. <th></th>
  299. </tr>
  300. </thead>
  301. <tbody>
  302. <?php foreach ($soilTests as $r):
  303. $link = '/dashboard/crop-analysis/soil-test-data/soil-analysis.php'
  304. . '?rid=' . (int)$r['id']
  305. . '&rand=' . urlencode($r['rand'])
  306. . '&cid=' . $clientId;
  307. ?>
  308. <tr>
  309. <td><?= fmtDate($r['date_sampled']) ?></td>
  310. <td class="text-muted small"><?= htmlspecialchars($r['lab_no'] ?? '—', ENT_QUOTES, 'UTF-8') ?></td>
  311. <td>
  312. <?= htmlspecialchars($r['sample_id'] ?? '', ENT_QUOTES, 'UTF-8') ?>
  313. <?php if ($r['site_id']): ?>
  314. <br><span class="text-muted small"><?= htmlspecialchars($r['site_id'], ENT_QUOTES, 'UTF-8') ?></span>
  315. <?php endif; ?>
  316. </td>
  317. <td class="small"><?= htmlspecialchars($r['crop_type'] ?? '—', ENT_QUOTES, 'UTF-8') ?></td>
  318. <td class="text-center"><?= soilCell($r['ph_cacl2'], 5.5, 7.5) ?></td>
  319. <td class="text-center"><?= soilCell($r['ph_h2o'], 6.0, 8.0) ?></td>
  320. <td class="text-center"><?= numCell($r['NO3_N']) ?></td>
  321. <td class="text-center"><?= numCell($r['p_morgan']) ?></td>
  322. <td class="text-center"><?= numCell($r['k_morgan']) ?></td>
  323. <td class="text-center"><?= soilCell($r['ec'], null, 1.5) ?></td>
  324. <td>
  325. <a href="<?= $link ?>"
  326. class="btn btn-xs btn-outline-success btn-sm">
  327. View
  328. </a>
  329. </td>
  330. </tr>
  331. <?php endforeach; ?>
  332. </tbody>
  333. </table>
  334. </div>
  335. </div>
  336. <?php if (count($soilTests) >= 2): ?>
  337. <!-- Charts -->
  338. <div class="row g-4 mb-4">
  339. <!-- pH trend -->
  340. <div class="col-lg-6">
  341. <div class="card shadow-sm border-0 h-100">
  342. <div class="card-header fw-semibold">
  343. <i class="fas fa-chart-line me-1 text-success"></i>pH Trend
  344. </div>
  345. <div class="card-body">
  346. <canvas id="chartPh" height="220"></canvas>
  347. </div>
  348. </div>
  349. </div>
  350. <!-- Nutrient trend -->
  351. <div class="col-lg-6">
  352. <div class="card shadow-sm border-0 h-100">
  353. <div class="card-header fw-semibold">
  354. <i class="fas fa-chart-line me-1 text-info"></i>Key Nutrients (mg/kg)
  355. </div>
  356. <div class="card-body">
  357. <canvas id="chartNutrients" height="220"></canvas>
  358. </div>
  359. </div>
  360. </div>
  361. </div>
  362. <?php endif; ?>
  363. <?php endif; ?>
  364. </div>
  365. <!-- ════════════════════════════════════════════════════
  366. TAB: PLANT TESTS
  367. ════════════════════════════════════════════════════ -->
  368. <div class="tab-pane fade <?= $activeTab === 'plant' ? 'show active' : '' ?>"
  369. id="tab-plant" role="tabpanel">
  370. <?php if (empty($plantTests)): ?>
  371. <p class="text-muted">No plant tests recorded for this client.</p>
  372. <?php else: ?>
  373. <div class="card shadow-sm border-0">
  374. <div class="card-header d-flex justify-content-between align-items-center">
  375. <span class="fw-semibold">Plant Test History</span>
  376. <a href="/dashboard/crop-analysis/plant-test-data/index.php"
  377. class="btn btn-sm btn-info text-white">
  378. <i class="fas fa-plus me-1"></i>New Test
  379. </a>
  380. </div>
  381. <div class="table-responsive">
  382. <table class="table table-hover table-sm mb-0 align-middle">
  383. <thead class="table-light">
  384. <tr>
  385. <th>Date Sampled</th>
  386. <th>Lab No.</th>
  387. <th>Sample / Site</th>
  388. <th>Crop</th>
  389. <th class="text-center">N%</th>
  390. <th class="text-center">P%</th>
  391. <th class="text-center">K%</th>
  392. <th class="text-center">S%</th>
  393. <th class="text-center">Ca%</th>
  394. <th class="text-center">Mg%</th>
  395. <th></th>
  396. </tr>
  397. </thead>
  398. <tbody>
  399. <?php foreach ($plantTests as $r):
  400. $link = '/dashboard/crop-analysis/plant-test-data/plant-analysis.php'
  401. . '?rid=' . (int)$r['id']
  402. . '&rand=' . urlencode($r['rand'])
  403. . '&cid=' . $clientId;
  404. ?>
  405. <tr>
  406. <td><?= fmtDate($r['date_sampled']) ?></td>
  407. <td class="text-muted small"><?= htmlspecialchars($r['lab_no'] ?? '—', ENT_QUOTES, 'UTF-8') ?></td>
  408. <td>
  409. <?= htmlspecialchars($r['sample_id'] ?? '', ENT_QUOTES, 'UTF-8') ?>
  410. <?php if ($r['site_id']): ?>
  411. <br><span class="text-muted small"><?= htmlspecialchars($r['site_id'], ENT_QUOTES, 'UTF-8') ?></span>
  412. <?php endif; ?>
  413. </td>
  414. <td class="small"><?= htmlspecialchars($r['crop_type'] ?? '—', ENT_QUOTES, 'UTF-8') ?></td>
  415. <td class="text-center"><?= numCell($r['n']) ?></td>
  416. <td class="text-center"><?= numCell($r['p']) ?></td>
  417. <td class="text-center"><?= numCell($r['k']) ?></td>
  418. <td class="text-center"><?= numCell($r['s']) ?></td>
  419. <td class="text-center"><?= numCell($r['ca']) ?></td>
  420. <td class="text-center"><?= numCell($r['mg']) ?></td>
  421. <td>
  422. <a href="<?= $link ?>"
  423. class="btn btn-sm btn-outline-info">View</a>
  424. </td>
  425. </tr>
  426. <?php endforeach; ?>
  427. </tbody>
  428. </table>
  429. </div>
  430. </div>
  431. <?php endif; ?>
  432. </div>
  433. <!-- ════════════════════════════════════════════════════
  434. TAB: WATER TESTS
  435. ════════════════════════════════════════════════════ -->
  436. <div class="tab-pane fade <?= $activeTab === 'water' ? 'show active' : '' ?>"
  437. id="tab-water" role="tabpanel">
  438. <?php if (empty($waterTests)): ?>
  439. <p class="text-muted">No water tests recorded for this client.</p>
  440. <?php else: ?>
  441. <div class="card shadow-sm border-0">
  442. <div class="card-header d-flex justify-content-between align-items-center">
  443. <span class="fw-semibold">Water Test History</span>
  444. <a href="/dashboard/crop-analysis/water-test-data/index.php"
  445. class="btn btn-sm btn-primary">
  446. <i class="fas fa-plus me-1"></i>New Test
  447. </a>
  448. </div>
  449. <div class="table-responsive">
  450. <table class="table table-hover table-sm mb-0 align-middle">
  451. <thead class="table-light">
  452. <tr>
  453. <th>Date Sampled</th>
  454. <th>Lab No.</th>
  455. <th>Sample / Site</th>
  456. <th>Crop</th>
  457. <th class="text-center">pH</th>
  458. <th class="text-center">EC (dS/m)</th>
  459. <th class="text-center">NO₃</th>
  460. <th class="text-center">P</th>
  461. <th class="text-center">K</th>
  462. <th class="text-center">Na</th>
  463. <th></th>
  464. </tr>
  465. </thead>
  466. <tbody>
  467. <?php foreach ($waterTests as $r):
  468. $link = '/dashboard/crop-analysis/water-test-data/water-report.php'
  469. . '?rid=' . (int)$r['id']
  470. . '&rand=' . urlencode($r['rand'])
  471. . '&cid=' . $clientId;
  472. ?>
  473. <tr>
  474. <td><?= fmtDate($r['date_sampled']) ?></td>
  475. <td class="text-muted small"><?= htmlspecialchars($r['lab_no'] ?? '—', ENT_QUOTES, 'UTF-8') ?></td>
  476. <td>
  477. <?= htmlspecialchars($r['sample_id'] ?? '', ENT_QUOTES, 'UTF-8') ?>
  478. <?php if ($r['site_id']): ?>
  479. <br><span class="text-muted small"><?= htmlspecialchars($r['site_id'], ENT_QUOTES, 'UTF-8') ?></span>
  480. <?php endif; ?>
  481. </td>
  482. <td class="small"><?= htmlspecialchars($r['crop_type'] ?? '—', ENT_QUOTES, 'UTF-8') ?></td>
  483. <td class="text-center"><?= soilCell($r['ph'], 6.0, 8.0) ?></td>
  484. <td class="text-center"><?= soilCell($r['cond_dsm'], null, 1.5) ?></td>
  485. <td class="text-center"><?= numCell($r['no3']) ?></td>
  486. <td class="text-center"><?= numCell($r['p']) ?></td>
  487. <td class="text-center"><?= numCell($r['k']) ?></td>
  488. <td class="text-center"><?= numCell($r['na']) ?></td>
  489. <td>
  490. <a href="<?= $link ?>"
  491. class="btn btn-sm btn-outline-primary">View</a>
  492. </td>
  493. </tr>
  494. <?php endforeach; ?>
  495. </tbody>
  496. </table>
  497. </div>
  498. </div>
  499. <?php endif; ?>
  500. </div>
  501. <!-- ════════════════════════════════════════════════════
  502. TAB: ALERTS
  503. ════════════════════════════════════════════════════ -->
  504. <div class="tab-pane fade <?= $activeTab === 'alerts' ? 'show active' : '' ?>"
  505. id="tab-alerts" role="tabpanel">
  506. <?php if (empty($soilTests)): ?>
  507. <p class="text-muted">No soil tests available to generate alerts.</p>
  508. <?php elseif (empty($alertData['items'])): ?>
  509. <div class="alert alert-success">
  510. <i class="fas fa-check-circle me-2"></i>
  511. All measured nutrients are within acceptable ranges based on the latest soil test
  512. (<?= fmtDate($soilTests[0]['date_sampled']) ?>).
  513. </div>
  514. <?php else: ?>
  515. <p class="text-muted small mb-3">
  516. Based on soil test
  517. <strong><?= htmlspecialchars($soilTests[0]['lab_no'] ?? '#' . $soilTests[0]['id'], ENT_QUOTES, 'UTF-8') ?></strong>
  518. sampled <?= fmtDate($soilTests[0]['date_sampled']) ?>.
  519. <?php if ($latestSoil && $latestSoil['soil_type']): ?>
  520. Soil type: <strong><?= htmlspecialchars($latestSoil['soil_type'], ENT_QUOTES, 'UTF-8') ?></strong>.
  521. <?php endif; ?>
  522. </p>
  523. <div class="row g-3">
  524. <?php foreach ($alertData['items'] as $alert):
  525. $isCritical = $alert['severity'] === 'critical';
  526. $colour = $isCritical ? 'danger' : 'warning';
  527. $icon = $isCritical ? 'exclamation-circle' : 'exclamation-triangle';
  528. // How far from target (%)
  529. $pct = null;
  530. if ($alert['min'] !== null && $alert['min'] > 0) {
  531. $pct = round(($alert['measured'] / $alert['min']) * 100);
  532. }
  533. ?>
  534. <div class="col-md-6 col-xl-4">
  535. <div class="card border-<?= $colour ?> border-opacity-50 h-100">
  536. <div class="card-body">
  537. <div class="d-flex justify-content-between align-items-center mb-2">
  538. <span class="fw-semibold">
  539. <i class="fas fa-<?= $icon ?> text-<?= $colour ?> me-1"></i>
  540. <?= htmlspecialchars($alert['nutrient'], ENT_QUOTES, 'UTF-8') ?>
  541. </span>
  542. <span class="badge bg-<?= $colour ?>">
  543. <?= strtoupper($alert['severity']) ?>
  544. </span>
  545. </div>
  546. <div class="d-flex justify-content-between text-muted small mb-1">
  547. <span>Measured</span>
  548. <span>Target min</span>
  549. </div>
  550. <div class="d-flex justify-content-between fw-bold mb-2">
  551. <span class="text-<?= $colour ?>"><?= $alert['measured'] ?></span>
  552. <span><?= $alert['min'] ?? '—' ?></span>
  553. </div>
  554. <?php if ($pct !== null): ?>
  555. <div class="progress" style="height:6px">
  556. <div class="progress-bar bg-<?= $colour ?>"
  557. style="width:<?= min($pct, 100) ?>%"
  558. title="<?= $pct ?>% of target">
  559. </div>
  560. </div>
  561. <div class="text-muted" style="font-size:.72rem; margin-top:3px">
  562. <?= $pct ?>% of target minimum
  563. </div>
  564. <?php endif; ?>
  565. </div>
  566. </div>
  567. </div>
  568. <?php endforeach; ?>
  569. </div>
  570. <?php endif; ?>
  571. </div>
  572. <!-- ════════════════════════════════════════════════════
  573. TAB: TIMELINE
  574. ════════════════════════════════════════════════════ -->
  575. <div class="tab-pane fade <?= $activeTab === 'timeline' ? 'show active' : '' ?>"
  576. id="tab-timeline" role="tabpanel">
  577. <?php if (empty($timeline)): ?>
  578. <p class="text-muted">No tests recorded yet.</p>
  579. <?php else: ?>
  580. <?php
  581. $typeConfig = [
  582. 'soil' => ['label' => 'Soil', 'icon' => 'globe-asia', 'colour' => 'success'],
  583. 'plant' => ['label' => 'Plant', 'icon' => 'pagelines', 'colour' => 'info', 'fa' => 'fab'],
  584. 'water' => ['label' => 'Water', 'icon' => 'tint', 'colour' => 'primary'],
  585. ];
  586. $prevDate = null;
  587. foreach ($timeline as $entry):
  588. $cfg = $typeConfig[$entry['type']];
  589. $entryDate = $entry['date'] ? date('F Y', strtotime($entry['date'])) : 'Unknown';
  590. $fa = $cfg['fa'] ?? 'fas';
  591. // Month/year separator
  592. if ($entryDate !== $prevDate):
  593. $prevDate = $entryDate;
  594. ?>
  595. <div class="text-muted small fw-semibold text-uppercase mb-2 mt-3 border-bottom pb-1">
  596. <?= htmlspecialchars($entryDate, ENT_QUOTES, 'UTF-8') ?>
  597. </div>
  598. <?php endif; ?>
  599. <div class="d-flex gap-3 mb-3 align-items-start">
  600. <div class="rounded-circle bg-<?= $cfg['colour'] ?> bg-opacity-15 p-2 flex-shrink-0 mt-1"
  601. style="width:36px;height:36px;display:flex;align-items:center;justify-content:center">
  602. <<?= $fa ?> class="fa-<?= $cfg['icon'] ?> text-<?= $cfg['colour'] ?> fa-sm"></i>
  603. </div>
  604. <div class="flex-fill">
  605. <div class="d-flex justify-content-between align-items-start">
  606. <div>
  607. <span class="badge bg-<?= $cfg['colour'] ?> bg-opacity-75 me-1"><?= $cfg['label'] ?></span>
  608. <strong><?= htmlspecialchars($entry['sample_id'] ?: ($entry['site_id'] ?: ('Test #' . $entry['id'])), ENT_QUOTES, 'UTF-8') ?></strong>
  609. <?php if ($entry['site_id'] && $entry['site_id'] !== $entry['sample_id']): ?>
  610. <span class="text-muted small ms-1">— <?= htmlspecialchars($entry['site_id'], ENT_QUOTES, 'UTF-8') ?></span>
  611. <?php endif; ?>
  612. </div>
  613. <span class="text-muted small flex-shrink-0 ms-2"><?= fmtDate($entry['date']) ?></span>
  614. </div>
  615. <div class="text-muted small mt-1">
  616. <?php if ($entry['lab_no']): ?>
  617. Lab: <?= htmlspecialchars($entry['lab_no'], ENT_QUOTES, 'UTF-8') ?>
  618. <?php endif; ?>
  619. <?php if ($entry['crop_type']): ?>
  620. · <?= htmlspecialchars($entry['crop_type'], ENT_QUOTES, 'UTF-8') ?>
  621. <?php endif; ?>
  622. </div>
  623. </div>
  624. </div>
  625. <?php endforeach; ?>
  626. <?php endif; ?>
  627. </div>
  628. </div><!-- /.tab-content -->
  629. </div>
  630. </main>
  631. </div>
  632. </div>
  633. <?php if (count($soilTests) >= 2): ?>
  634. <!-- Chart.js data -->
  635. <script>
  636. const chartLabels = <?= json_encode($chartLabels) ?>;
  637. const chartPhCaCl2 = <?= json_encode($chartPhCaCl2) ?>;
  638. const chartPhH2O = <?= json_encode($chartPhH2O) ?>;
  639. const chartNO3N = <?= json_encode($chartNO3N) ?>;
  640. const chartP = <?= json_encode($chartP) ?>;
  641. const chartK = <?= json_encode($chartK) ?>;
  642. const chartCa = <?= json_encode($chartCa) ?>;
  643. const chartMg = <?= json_encode($chartMg) ?>;
  644. // ── pH Chart ─────────────────────────────────────────────────────────────────
  645. const ctxPh = document.getElementById('chartPh');
  646. if (ctxPh) {
  647. new Chart(ctxPh, {
  648. type: 'line',
  649. data: {
  650. labels: chartLabels,
  651. datasets: [
  652. {
  653. label: 'Min (5.5)',
  654. data: Array(chartLabels.length).fill(5.5),
  655. borderColor: 'rgba(25,135,84,0.3)',
  656. borderDash: [5, 5],
  657. borderWidth: 1,
  658. pointRadius: 0,
  659. fill: '+1',
  660. backgroundColor: 'rgba(25,135,84,0.06)',
  661. spanGaps: true,
  662. },
  663. {
  664. label: 'Max (7.5)',
  665. data: Array(chartLabels.length).fill(7.5),
  666. borderColor: 'rgba(25,135,84,0.3)',
  667. borderDash: [5, 5],
  668. borderWidth: 1,
  669. pointRadius: 0,
  670. fill: false,
  671. spanGaps: true,
  672. },
  673. {
  674. label: 'pH CaCl₂',
  675. data: chartPhCaCl2,
  676. borderColor: '#198754',
  677. backgroundColor: 'rgba(25,135,84,0.08)',
  678. tension: 0.3,
  679. pointRadius: 4,
  680. fill: false,
  681. spanGaps: true,
  682. },
  683. {
  684. label: 'pH H₂O',
  685. data: chartPhH2O,
  686. borderColor: '#0dcaf0',
  687. backgroundColor: 'rgba(13,202,240,0.08)',
  688. tension: 0.3,
  689. pointRadius: 4,
  690. fill: false,
  691. spanGaps: true,
  692. },
  693. ],
  694. },
  695. options: {
  696. responsive: true,
  697. interaction: { mode: 'index', intersect: false },
  698. plugins: {
  699. legend: { position: 'top' },
  700. tooltip: {
  701. callbacks: {
  702. afterBody: () => 'Ideal range: 5.5 – 7.5',
  703. },
  704. },
  705. },
  706. scales: {
  707. y: {
  708. title: { display: true, text: 'pH' },
  709. min: 4,
  710. max: 9,
  711. },
  712. },
  713. },
  714. });
  715. }
  716. // ── Nutrients Chart ───────────────────────────────────────────────────────────
  717. const ctxNut = document.getElementById('chartNutrients');
  718. if (ctxNut) {
  719. new Chart(ctxNut, {
  720. type: 'line',
  721. data: {
  722. labels: chartLabels,
  723. datasets: [
  724. { label: 'NO₃-N', data: chartNO3N, borderColor: '#0d6efd', tension: 0.3, pointRadius: 4, fill: false, spanGaps: true },
  725. { label: 'P', data: chartP, borderColor: '#fd7e14', tension: 0.3, pointRadius: 4, fill: false, spanGaps: true },
  726. { label: 'K', data: chartK, borderColor: '#6f42c1', tension: 0.3, pointRadius: 4, fill: false, spanGaps: true },
  727. { label: 'Ca', data: chartCa, borderColor: '#d63384', tension: 0.3, pointRadius: 4, fill: false, spanGaps: true },
  728. { label: 'Mg', data: chartMg, borderColor: '#20c997', tension: 0.3, pointRadius: 4, fill: false, spanGaps: true },
  729. ],
  730. },
  731. options: {
  732. responsive: true,
  733. interaction: { mode: 'index', intersect: false },
  734. plugins: { legend: { position: 'top' } },
  735. scales: {
  736. y: { title: { display: true, text: 'mg/kg' }, beginAtZero: true },
  737. },
  738. },
  739. });
  740. }
  741. </script>
  742. <?php endif; ?>
  743. <?php include __DIR__ . '/../../layouts/footer.php'; ?>
  744. <?php
  745. // ── Helper functions (local to this page) ─────────────────────────────────────
  746. /**
  747. * Render a colour-coded table cell for a value with optional min/max range.
  748. * Green = in range, yellow = watch (near/above max), red = below min.
  749. */
  750. function soilCell(?string $raw, ?float $min, ?float $max): string
  751. {
  752. if ($raw === null || $raw === '') return '<span class="text-muted">—</span>';
  753. $v = (float) $raw;
  754. $class = '';
  755. if ($min !== null && $v < $min) {
  756. $class = $v < ($min * 0.5) ? 'text-danger fw-bold' : 'text-warning fw-bold';
  757. } elseif ($max !== null && $v > $max) {
  758. $class = 'text-warning fw-bold';
  759. } else {
  760. $class = 'text-success';
  761. }
  762. return '<span class="' . $class . '">' . htmlspecialchars($raw, ENT_QUOTES, 'UTF-8') . '</span>';
  763. }
  764. /**
  765. * Render a plain numeric cell — dash if empty.
  766. */
  767. function numCell(?string $raw): string
  768. {
  769. if ($raw === null || $raw === '') return '<span class="text-muted">—</span>';
  770. return htmlspecialchars($raw, ENT_QUOTES, 'UTF-8');
  771. }
  772. ?>