soil-import.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323
  1. /**
  2. * client-assets/js/soil-import.js
  3. *
  4. * Lab file import flow for the soil test entry page.
  5. *
  6. * Flow:
  7. * 1. User selects lab + file → "Analyse" button enabled
  8. * 2. File sent to soilImportController.php (action=parse)
  9. * 3. Samples returned → shown in confirmation table
  10. * - User can edit sample_id (paddock) inline
  11. * - User can uncheck individual rows to skip them
  12. * 4a. "Import All" / "Import Selected" → action=import_bulk → records saved to DB
  13. * 4b. For single sample: "Fill Form" → action=import_one → pre-fills the form below
  14. */
  15. (function () {
  16. 'use strict';
  17. // ── DOM refs ──────────────────────────────────────────────────────────────
  18. const labSelect = document.getElementById('lab-select');
  19. const fileInput = document.getElementById('lab-file-input');
  20. const analyseBtn = document.getElementById('lab-analyse-btn');
  21. const statusEl = document.getElementById('import-status');
  22. const progressEl = document.getElementById('import-progress');
  23. const bulkPanel = document.getElementById('bulk-import-panel');
  24. const bulkTbody = document.getElementById('bulk-import-tbody');
  25. const bulkImportBtn= document.getElementById('bulk-import-btn');
  26. const bulkAllChk = document.getElementById('bulk-select-all');
  27. if (!fileInput || !analyseBtn) return;
  28. let parsedSamples = []; // raw from server
  29. let detectedLab = '';
  30. // ── Helpers ───────────────────────────────────────────────────────────────
  31. function showStatus(msg, type = 'info') {
  32. if (!statusEl) return;
  33. statusEl.className = `alert alert-${type} mt-2`;
  34. statusEl.innerHTML = msg;
  35. statusEl.hidden = false;
  36. }
  37. function hideStatus() { if (statusEl) statusEl.hidden = true; }
  38. function setProgress(on, text = 'Processing…') {
  39. if (!progressEl) return;
  40. progressEl.hidden = !on;
  41. const lbl = progressEl.querySelector('.progress-label');
  42. if (lbl) lbl.textContent = text;
  43. }
  44. function buildFormData(extra = {}) {
  45. const fd = new FormData();
  46. fd.append('file', fileInput.files[0]);
  47. fd.append('lab', labSelect ? labSelect.value : 'auto');
  48. for (const [k, v] of Object.entries(extra)) fd.append(k, v);
  49. return fd;
  50. }
  51. async function post(fd) {
  52. const r = await fetch('/controllers/soilImportController.php', { method: 'POST', body: fd });
  53. if (!r.ok) throw new Error(`Server error ${r.status}`);
  54. return r.json();
  55. }
  56. // ── Enable Analyse button when file chosen ────────────────────────────────
  57. fileInput.addEventListener('change', () => {
  58. analyseBtn.disabled = !fileInput.files.length;
  59. hideStatus();
  60. if (bulkPanel) bulkPanel.hidden = true;
  61. });
  62. // ── Step 1: Analyse ───────────────────────────────────────────────────────
  63. analyseBtn.addEventListener('click', async () => {
  64. if (!fileInput.files.length) return;
  65. analyseBtn.disabled = true;
  66. setProgress(true, 'Reading file…');
  67. hideStatus();
  68. if (bulkPanel) bulkPanel.hidden = true;
  69. try {
  70. const data = await post(buildFormData({ action: 'parse' }));
  71. if (!data.success) {
  72. showStatus('Parse error: ' + escHtml(data.error), 'danger');
  73. return;
  74. }
  75. parsedSamples = data.samples;
  76. detectedLab = data.lab;
  77. if (labSelect && labSelect.value === 'auto' && detectedLab) {
  78. // Update the dropdown to reflect auto-detected lab
  79. for (const opt of labSelect.options) {
  80. if (opt.value === detectedLab) { opt.selected = true; break; }
  81. }
  82. }
  83. if (data.count === 1) {
  84. // Single sample — offer both "fill form" and "bulk import"
  85. showStatus(
  86. `1 sample found (<strong>${escHtml(parsedSamples[0]?.lab_no || 'unknown')}</strong>). ` +
  87. `Review below then import.`,
  88. 'info'
  89. );
  90. } else {
  91. showStatus(
  92. `<strong>${data.count}</strong> samples found in this file. ` +
  93. `Review paddock IDs below, then import all or select individual rows.`,
  94. 'info'
  95. );
  96. }
  97. renderBulkTable(data.samples);
  98. } catch (err) {
  99. showStatus('Error: ' + escHtml(err.message), 'danger');
  100. } finally {
  101. setProgress(false);
  102. analyseBtn.disabled = false;
  103. }
  104. });
  105. // ── Step 2: Render confirmation table ─────────────────────────────────────
  106. function renderBulkTable(samples) {
  107. if (!bulkPanel || !bulkTbody) return;
  108. bulkTbody.innerHTML = '';
  109. samples.forEach((s, idx) => {
  110. const tr = document.createElement('tr');
  111. tr.dataset.idx = idx;
  112. tr.innerHTML = `
  113. <td class="text-center">
  114. <input type="checkbox" class="form-check-input row-check" checked>
  115. </td>
  116. <td class="text-muted small">${escHtml(s.lab_no)}</td>
  117. <td>
  118. <input type="text"
  119. class="form-control form-control-sm paddock-input"
  120. value="${escHtml(s.sample_id)}"
  121. placeholder="Paddock / sample ID">
  122. </td>
  123. <td class="small">${escHtml(s.client)}</td>
  124. <td class="small">${escHtml(s.crop)}</td>
  125. <td class="text-center">
  126. <button class="btn btn-xs btn-sm btn-outline-secondary fill-form-btn"
  127. data-idx="${idx}" type="button" title="Pre-fill the form below with this sample">
  128. <i class="fas fa-pen fa-xs"></i>
  129. </button>
  130. </td>`;
  131. bulkTbody.appendChild(tr);
  132. });
  133. // Wire "fill form" buttons
  134. bulkTbody.querySelectorAll('.fill-form-btn').forEach(btn => {
  135. btn.addEventListener('click', () => fillForm(parseInt(btn.dataset.idx)));
  136. });
  137. bulkPanel.hidden = false;
  138. bulkPanel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
  139. }
  140. // ── Select-all checkbox ───────────────────────────────────────────────────
  141. if (bulkAllChk) {
  142. bulkAllChk.addEventListener('change', () => {
  143. document.querySelectorAll('.row-check').forEach(cb => {
  144. cb.checked = bulkAllChk.checked;
  145. });
  146. });
  147. }
  148. // ── Step 3a: Bulk import ──────────────────────────────────────────────────
  149. if (bulkImportBtn) {
  150. bulkImportBtn.addEventListener('click', async () => {
  151. const clientId = document.querySelector('[name="client_id"]')?.value || '';
  152. if (!clientId || clientId === 'new') {
  153. showStatus('Please select a client before importing.', 'warning');
  154. document.querySelector('[name="client_id"]')?.scrollIntoView({ behavior: 'smooth' });
  155. return;
  156. }
  157. // Collect checked rows with updated paddock IDs
  158. const toImport = [];
  159. bulkTbody.querySelectorAll('tr').forEach(tr => {
  160. const chk = tr.querySelector('.row-check');
  161. if (!chk?.checked) return;
  162. const idx = parseInt(tr.dataset.idx);
  163. const paddock = tr.querySelector('.paddock-input')?.value.trim() || '';
  164. const sample = { ...parsedSamples[idx] };
  165. sample.sample_id = paddock || sample.sample_id;
  166. // Convert parsed display fields to db field names
  167. toImport.push(rawSampleToDbFields(idx, sample));
  168. });
  169. if (!toImport.length) {
  170. showStatus('No rows selected.', 'warning');
  171. return;
  172. }
  173. setProgress(true, `Importing ${toImport.length} sample${toImport.length !== 1 ? 's' : ''}…`);
  174. bulkImportBtn.disabled = true;
  175. try {
  176. const fd = buildFormData({
  177. action: 'import_bulk',
  178. client_id: clientId,
  179. samples: JSON.stringify(toImport),
  180. });
  181. const data = await post(fd);
  182. if (!data.success) {
  183. showStatus('Import failed: ' + escHtml(data.error), 'danger');
  184. return;
  185. }
  186. bulkPanel.hidden = true;
  187. const skipHtml = data.skipped?.length
  188. ? `<br><small class="text-warning">Skipped: ${data.skipped.map(escHtml).join(', ')}</small>`
  189. : '';
  190. showStatus(`<i class="fas fa-check-circle me-1"></i>${escHtml(data.message)}${skipHtml}`, 'success');
  191. } catch (err) {
  192. showStatus('Error: ' + escHtml(err.message), 'danger');
  193. } finally {
  194. setProgress(false);
  195. bulkImportBtn.disabled = false;
  196. }
  197. });
  198. }
  199. // ── Step 3b: Fill form with one sample ────────────────────────────────────
  200. async function fillForm(idx) {
  201. setProgress(true, 'Mapping fields…');
  202. try {
  203. const fd = buildFormData({ action: 'import_one', sample_idx: idx });
  204. const data = await post(fd);
  205. if (!data.success) {
  206. showStatus('Could not map fields: ' + escHtml(data.error), 'danger');
  207. return;
  208. }
  209. const { filled } = populateForm(data.fields);
  210. highlightFilled(data.fields);
  211. const method = data.method === 'csbp' ? 'CSBP direct map' : 'AI-assisted';
  212. showStatus(
  213. `<i class="fas fa-check-circle me-1"></i>${filled} fields filled via ${method}. ` +
  214. `Review highlighted fields before submitting.`,
  215. 'success'
  216. );
  217. document.getElementById('SoilcsvForm')?.scrollIntoView({ behavior: 'smooth' });
  218. } catch (err) {
  219. showStatus('Error: ' + escHtml(err.message), 'danger');
  220. } finally {
  221. setProgress(false);
  222. }
  223. }
  224. // ── Form population helpers ───────────────────────────────────────────────
  225. function populateForm(fields) {
  226. let filled = 0;
  227. for (const [name, value] of Object.entries(fields)) {
  228. if (value === null || value === undefined || value === '') continue;
  229. const el = document.getElementById(name) || document.querySelector(`[name="${name}"]`);
  230. if (!el) continue;
  231. if (el.tagName === 'SELECT') {
  232. const val = String(value).toLowerCase();
  233. for (const opt of el.options) {
  234. if (opt.value.toLowerCase() === val || opt.text.toLowerCase() === val) {
  235. el.value = opt.value; filled++; break;
  236. }
  237. }
  238. } else {
  239. el.value = value;
  240. el.dispatchEvent(new Event('input', { bubbles: true }));
  241. filled++;
  242. }
  243. }
  244. return { filled };
  245. }
  246. function highlightFilled(fields) {
  247. // Clear old highlights first
  248. document.querySelectorAll('.import-filled').forEach(el => {
  249. el.classList.remove('import-filled', 'border-success', 'bg-success-subtle');
  250. });
  251. for (const name of Object.keys(fields)) {
  252. if (!fields[name]) continue;
  253. const el = document.getElementById(name) || document.querySelector(`[name="${name}"]`);
  254. if (el) el.classList.add('import-filled', 'border-success', 'bg-success-subtle');
  255. }
  256. }
  257. // ── Convert display sample → db field names for bulk save ─────────────────
  258. // The parsed CSBP sample already uses db field names, so this is mostly pass-through.
  259. // For generic lab samples (raw headers), the server handles mapping via Ollama.
  260. function rawSampleToDbFields(idx, sample) {
  261. // sample already has db keys from csbpParse / genericParse
  262. // just ensure sample_id is updated from the editable paddock field
  263. return sample;
  264. }
  265. // ── Utility ───────────────────────────────────────────────────────────────
  266. function escHtml(str) {
  267. const d = document.createElement('div');
  268. d.textContent = str || '';
  269. return d.innerHTML;
  270. }
  271. })();