Benjamin Harris 2 месяцев назад
Родитель
Сommit
9ea15d1630

+ 265 - 0
controllers/ollamaGenerate.php

@@ -0,0 +1,265 @@
+<?php
+/**
+ * controllers/ollamaGenerate.php
+ *
+ * AJAX POST handler: sends soil test data to a local Ollama instance and
+ * returns an AI-generated agronomic interpretation for the requested section.
+ *
+ * Expected POST params:
+ *   csrf_token  string  CSRF token
+ *   rid         int     soil_records.id
+ *   rand        string  soil_records.rand (ownership token)
+ *   section     string  overview | ai_interpretation | foliar | microbial
+ *
+ * Requires Ollama running on http://localhost:11434
+ * Default model: llama3.2 — change OLLAMA_MODEL below to suit your setup.
+ */
+
+if (session_status() === PHP_SESSION_NONE) {
+    session_start();
+}
+
+require_once __DIR__ . '/../config/database.php';
+require_once __DIR__ . '/../lib/auth.php';
+require_once __DIR__ . '/../lib/csrf.php';
+
+header('Content-Type: application/json');
+
+// ── Auth + CSRF ─────────────────────────────────────────────────────────────
+if (!isLoggedIn()) {
+    http_response_code(401);
+    echo json_encode(['success' => false, 'error' => 'Not authenticated']);
+    exit;
+}
+
+if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
+    http_response_code(405);
+    echo json_encode(['success' => false, 'error' => 'Method not allowed']);
+    exit;
+}
+
+if (!verifyCsrfToken($_POST['csrf_token'] ?? '')) {
+    http_response_code(403);
+    echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
+    exit;
+}
+
+// ── Input validation ────────────────────────────────────────────────────────
+$recordId = (int)trim($_POST['rid']  ?? '');
+$randId   = trim($_POST['rand']      ?? '');
+$section  = trim($_POST['section']   ?? '');
+
+$validSections = ['overview', 'ai_interpretation', 'foliar', 'microbial'];
+if (!$recordId || $randId === '' || !in_array($section, $validSections, true)) {
+    http_response_code(400);
+    echo json_encode(['success' => false, 'error' => 'Invalid parameters']);
+    exit;
+}
+
+// ── Load data ───────────────────────────────────────────────────────────────
+try {
+    $pdo    = getDBConnection();
+    $userId = getCurrentUserId();
+
+    $stmt = $pdo->prepare('SELECT * FROM soil_records WHERE id = ? AND rand = ?');
+    $stmt->execute([$recordId, $randId]);
+    $row = $stmt->fetch(PDO::FETCH_ASSOC);
+
+    if (!$row) {
+        http_response_code(404);
+        echo json_encode(['success' => false, 'error' => 'Record not found']);
+        exit;
+    }
+
+    $spec = [];
+    if (!empty($row['soil_type'])) {
+        $stmtSpec = $pdo->prepare('SELECT * FROM soil_specifications WHERE soil_type = ? LIMIT 1');
+        $stmtSpec->execute([$row['soil_type']]);
+        $spec = $stmtSpec->fetch(PDO::FETCH_ASSOC) ?: [];
+    }
+
+} catch (PDOException $e) {
+    error_log('DB error in ollamaGenerate.php: ' . $e->getMessage());
+    http_response_code(500);
+    echo json_encode(['success' => false, 'error' => 'Database error']);
+    exit;
+}
+
+// ── Build soil data summary for the prompt ──────────────────────────────────
+
+/** Helper: format a numeric value safely, '' → 'N/A' */
+function fv(mixed $v, int $dp = 2): string
+{
+    if ($v === null || $v === '') return 'N/A';
+    return is_numeric($v) ? number_format((float)$v, $dp) : (string)$v;
+}
+
+/** Compare a value to a min/max range, return status string */
+function rangeStatus(mixed $value, mixed $min, mixed $max): string
+{
+    if (!is_numeric($value)) return '';
+    $v  = (float)$value;
+    $lo = is_numeric($min) ? (float)$min : null;
+    $hi = is_numeric($max) ? (float)$max : null;
+    if ($lo !== null && $v < $lo) return 'DEFICIENT';
+    if ($hi !== null && $v > $hi) return 'EXCESS';
+    return 'IDEAL';
+}
+
+$r = $row;
+$s = $spec;
+
+$soilSummary = <<<TEXT
+SOIL TEST RESULTS
+=================
+Client:       {$r['client_name']}
+Location:     {$r['site_address']}, {$r['state_postcode']}
+Crop:         {$r['sample_id']}
+Soil Type:    {$r['soil_type']}
+Lab Number:   {$r['lab_no']}
+Date Sampled: {$r['date_sampled']}
+Batch:        {$r['batch_no']}
+
+PHYSICAL / GENERAL
+------------------
+pH (H2O):           {fv($r['ph_h2o'],   1)}  [target: 6.0–7.0]   {rangeStatus($r['ph_h2o'], 6.0, 7.0)}
+pH (CaCl2):         {fv($r['ph_cacl2'], 1)}
+EC (mS/cm):         {fv($r['ec'],       2)}
+Organic Carbon (%): {fv($r['ocarbon'],  1)}
+Organic Matter (%): {fv($r['omatter'],  1)}
+CEC:                {fv($r['cec'],      2)}
+TEC:                {fv($r['tec'],      2)}
+
+MAJOR ELEMENTS (ppm)
+---------------------
+Calcium (Ca):       {fv($r['BS_ca_ppm'], 0)}  [min: {fv($s['ca_ppm_min'] ?? $r['ca_ppm_min'], 0)}, max: {fv($s['ca_ppm_max'] ?? $r['ca_ppm_max'], 0)}]  {rangeStatus($r['BS_ca_ppm'], $s['ca_ppm_min'] ?? $r['ca_ppm_min'] ?? null, $s['ca_ppm_max'] ?? $r['ca_ppm_max'] ?? null)}
+Magnesium (Mg):     {fv($r['BS_mg_ppm'], 0)}  [min: {fv($s['mg_ppm_min'] ?? $r['mg_ppm_min'], 0)}, max: {fv($s['mg_ppm_max'] ?? $r['mg_ppm_max'], 0)}]  {rangeStatus($r['BS_mg_ppm'], $s['mg_ppm_min'] ?? $r['mg_ppm_min'] ?? null, $s['mg_ppm_max'] ?? $r['mg_ppm_max'] ?? null)}
+Potassium (K):      {fv($r['BS_k_ppm'],  0)}  [min: {fv($s['k_ppm_min']  ?? $r['k_ppm_min'],  0)}, max: {fv($s['k_ppm_max']  ?? $r['k_ppm_max'],  0)}]  {rangeStatus($r['BS_k_ppm'],  $s['k_ppm_min']  ?? $r['k_ppm_min']  ?? null, $s['k_ppm_max']  ?? $r['k_ppm_max']  ?? null)}
+Sodium (Na):        {fv($r['BS_na_ppm'], 0)}  [min: {fv($s['na_ppm_min'] ?? $r['na_ppm_min'], 0)}, max: {fv($s['na_ppm_max'] ?? $r['na_ppm_max'], 0)}]  {rangeStatus($r['BS_na_ppm'], $s['na_ppm_min'] ?? $r['na_ppm_min'] ?? null, $s['na_ppm_max'] ?? $r['na_ppm_max'] ?? null)}
+Nitrate-N:          {fv($r['NO3_N'],    0)} ppm
+Ammonium-N:         {fv($r['NH3_N'],    0)} ppm
+Phosphate (P):      {fv($r['p_colwell'],0)} ppm  (Colwell)
+
+TRACE ELEMENTS (ppm)
+--------------------
+Sulfur (S):         {fv($r['s_morgan'],  2)}
+Boron (B):          {fv($r['b_cacl2'],   2)}
+Manganese (Mn):     {fv($r['mn_dtpa'],   2)}
+Copper (Cu):        {fv($r['cu_dtpa'],   2)}
+Zinc (Zn):          {fv($r['zn_dtpa'],   2)}
+Iron (Fe):          {fv($r['fe_dtpa'],   2)}
+Aluminium (Al):     {fv($r['al'],        2)}
+Silicon (Si):       {fv($r['sl_cacl2'],  2)}
+
+BASE SATURATIONS (%)
+--------------------
+Calcium (Ca):       {fv($r['BS_ca2'],  2)}%  [min: {fv($s['cabs_min'] ?? null)}, max: {fv($s['cabs_max'] ?? null)}]  {rangeStatus($r['BS_ca2'], $s['cabs_min'] ?? null, $s['cabs_max'] ?? null)}
+Magnesium (Mg):     {fv($r['BS_mg2'],  2)}%  [min: {fv($s['mgbs_min'] ?? null)}, max: {fv($s['mgbs_max'] ?? null)}]  {rangeStatus($r['BS_mg2'], $s['mgbs_min'] ?? null, $s['mgbs_max'] ?? null)}
+Potassium (K):      {fv($r['BS_k'],    2)}%  [min: {fv($s['kbs_min']  ?? null)}, max: {fv($s['kbs_max']  ?? null)}]  {rangeStatus($r['BS_k'],   $s['kbs_min']  ?? null, $s['kbs_max']  ?? null)}
+Sodium (Na):        {fv($r['BS_na'],   2)}%
+Other Bases:        {fv($r['BS_ob'],   2)}%
+Hydrogen:           {fv($r['BS_h'],    2)}%
+
+RATIOS
+------
+Ca:Mg ratio:        {fv(is_numeric($r['ca_mehlick3']) && is_numeric($r['mg_mehlick3']) && (float)$r['mg_mehlick3'] != 0 ? (float)$r['ca_mehlick3'] / (float)$r['mg_mehlick3'] : null, 1)}  [recommended: {fv($s['ca_mg_ratio'] ?? null, 1)}]
+C:N ratio:          {fv($r['c_n_ratio'], 1)}
+TEXT;
+
+// ── Section-specific prompts ────────────────────────────────────────────────
+$prompts = [
+
+    'overview' =>
+        "You are an experienced Australian agronomist writing a professional soil analysis report.\n\n"
+        . $soilSummary
+        . "\n\nWrite a concise executive overview (3–4 paragraphs) suitable for a farmer. "
+        . "Summarise the overall soil health, the most important deficiencies or imbalances, "
+        . "and their likely impact on crop performance. Use plain language — avoid jargon. "
+        . "Do not include specific product recommendations in this section.",
+
+    'ai_interpretation' =>
+        "You are an experienced Australian agronomist.\n\n"
+        . $soilSummary
+        . "\n\nProvide a detailed technical interpretation of these soil test results. "
+        . "For each element that is DEFICIENT or in EXCESS, explain: "
+        . "(1) the agronomic significance, "
+        . "(2) the likely cause in Australian soils, "
+        . "(3) the interactions with other nutrients shown in this test. "
+        . "Also comment on the base saturation balance, pH implications, and organic matter. "
+        . "Write in a professional tone suitable for an agronomist's report.",
+
+    'foliar' =>
+        "You are an experienced Australian agronomist.\n\n"
+        . $soilSummary
+        . "\n\nDesign a foliar spray program to correct the nutrient deficiencies shown in these soil test results. "
+        . "List recommended applications by growth stage, including product types, rates (L/ha or kg/ha), "
+        . "and timing. Focus on elements that are DEFICIENT. "
+        . "Note any antagonisms to avoid (e.g. Ca and Mg competing). "
+        . "Write as a practical program a farmer can follow.",
+
+    'microbial' =>
+        "You are an experienced Australian agronomist with expertise in soil biology.\n\n"
+        . $soilSummary
+        . "\n\nDesign a microbial and biological soil program suited to these test results. "
+        . "Consider: the organic matter level, pH, and nutrient imbalances shown. "
+        . "Recommend specific microbial inoculants, compost teas, or biological amendments "
+        . "that would improve soil biology and nutrient cycling for the crop type listed. "
+        . "Include timing and application rates where possible.",
+];
+
+// ── Call Ollama ─────────────────────────────────────────────────────────────
+define('OLLAMA_URL',   'http://localhost:11434/api/generate');
+define('OLLAMA_MODEL', 'llama3.2');   // change to match your installed model
+define('OLLAMA_TIMEOUT', 120);        // seconds — LLM can be slow
+
+$prompt  = $prompts[$section];
+$payload = json_encode([
+    'model'  => OLLAMA_MODEL,
+    'prompt' => $prompt,
+    'stream' => false,
+]);
+
+$ch = curl_init(OLLAMA_URL);
+curl_setopt_array($ch, [
+    CURLOPT_POST           => true,
+    CURLOPT_POSTFIELDS     => $payload,
+    CURLOPT_HTTPHEADER     => ['Content-Type: application/json'],
+    CURLOPT_RETURNTRANSFER => true,
+    CURLOPT_TIMEOUT        => OLLAMA_TIMEOUT,
+    CURLOPT_CONNECTTIMEOUT => 5,
+]);
+
+$response = curl_exec($ch);
+$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+$curlErr  = curl_error($ch);
+curl_close($ch);
+
+if ($curlErr || $response === false) {
+    http_response_code(502);
+    echo json_encode([
+        'success' => false,
+        'error'   => 'Could not connect to Ollama: ' . ($curlErr ?: 'no response'),
+    ]);
+    exit;
+}
+
+if ($httpCode !== 200) {
+    http_response_code(502);
+    echo json_encode([
+        'success' => false,
+        'error'   => 'Ollama returned HTTP ' . $httpCode,
+    ]);
+    exit;
+}
+
+$ollamaData = json_decode($response, true);
+$text = trim($ollamaData['response'] ?? '');
+
+if ($text === '') {
+    http_response_code(502);
+    echo json_encode(['success' => false, 'error' => 'Ollama returned an empty response']);
+    exit;
+}
+
+echo json_encode(['success' => true, 'text' => $text]);
+exit;

+ 400 - 190
dashboard/crop-analysis/soil-test-data/soil-report.php

@@ -1,27 +1,34 @@
 <?php
-require_once __DIR__.'/../../../config/database.php';
-require_once __DIR__.'/../../../lib/auth.php';
-require_once __DIR__.'/../../../lib/validation.php';
-require_once __DIR__.'/../../../lib/soil_calculations.php';
+/**
+ * dashboard/crop-analysis/soil-test-data/soil-report.php
+ *
+ * Soil Analysis Report — editable sections with auto-save and Ollama AI interpretation.
+ */
 
-if (session_status() === PHP_SESSION_NONE) {
-    session_start();
-}
+require_once __DIR__ . '/../../../config/database.php';
+require_once __DIR__ . '/../../../lib/auth.php';
+require_once __DIR__ . '/../../../lib/csrf.php';
+require_once __DIR__ . '/../../../lib/soil_calculations.php';
 
 requireLogin();
 
-$client_id = (int)($_GET['cid'] ?? 0);
-$record_id = (int)($_GET['rid'] ?? 0);
-$rand_id = (float)($_GET['rand'] ?? 0);
+$client_id = (int)  ($_GET['cid']  ?? 0);
+$record_id = (int)  ($_GET['rid']  ?? 0);
+$rand_id   = trim(  $_GET['rand']  ?? '');
 
-if (!$record_id || !$rand_id) {
+if (!$record_id || $rand_id === '') {
     http_response_code(400);
     die('Invalid request parameters');
 }
 
+$acHa = 2.4710559990832394739; // kg/Ac → kg/ha conversion
+
 try {
-    $pdo = getDBConnection();
-    $stmt = $pdo->prepare("SELECT * FROM soil_records WHERE id = ? AND rand = ?");
+    $pdo    = getDBConnection();
+    $userId = getCurrentUserId();
+
+    // Load soil record — verify ownership via rand token
+    $stmt = $pdo->prepare('SELECT * FROM soil_records WHERE id = ? AND rand = ?');
     $stmt->execute([$record_id, $rand_id]);
     $row = $stmt->fetch(PDO::FETCH_ASSOC);
 
@@ -30,213 +37,416 @@ try {
         die('Soil record not found');
     }
 
-    $client = htmlspecialchars($row['client_name'] ?? '', ENT_QUOTES, 'UTF-8');
-    $address = htmlspecialchars($row['site_address'] ?? '', ENT_QUOTES, 'UTF-8');
-    $state = htmlspecialchars($row['state_postcode'] ?? '', ENT_QUOTES, 'UTF-8');
-    $email = htmlspecialchars($row['email'] ?? '', ENT_QUOTES, 'UTF-8');
-    $labNo = htmlspecialchars($row['lab_no'] ?? '', ENT_QUOTES, 'UTF-8');
-    $sampleDate = htmlspecialchars($row['date_sampled'] ?? '', ENT_QUOTES, 'UTF-8');
-    $sample = htmlspecialchars($row['site_id'] ?? '', ENT_QUOTES, 'UTF-8');
-    $crop = htmlspecialchars($row['sample_id'] ?? '', ENT_QUOTES, 'UTF-8');
+    // Load specification ranges for this soil type
+    $spec = [];
+    if (!empty($row['soil_type'])) {
+        $stmtSpec = $pdo->prepare('SELECT * FROM soil_specifications WHERE soil_type = ? LIMIT 1');
+        $stmtSpec->execute([$row['soil_type']]);
+        $spec = $stmtSpec->fetch(PDO::FETCH_ASSOC) ?: [];
+    }
+
+    // Load saved report comments (JSON blob in reports table)
+    $savedComments = [];
+    $stmtRpt = $pdo->prepare(
+        'SELECT comment FROM reports WHERE record_id = ? AND modx_user_id = ? ORDER BY id DESC LIMIT 1'
+    );
+    $stmtRpt->execute([$record_id, $userId]);
+    $savedRow = $stmtRpt->fetchColumn();
+    if ($savedRow) {
+        $decoded = json_decode($savedRow, true);
+        if (is_array($decoded)) {
+            $savedComments = $decoded;
+        }
+    }
 
 } catch (PDOException $e) {
-    error_log("Database error in soil-report.php: " . $e->getMessage());
+    error_log('DB error in soil-report.php: ' . $e->getMessage());
     die('Database error occurred');
 }
 
-$today = date('jS F Y');
-$pageTitle = 'Soil Report - ' . ($client ?: 'Crop Monitoring');
-$siteName = 'Crop Management Platform';
-$activeItem = 'Soil Report';
+// ── Escaped display vars ────────────────────────────────────────────────────
+$client     = htmlspecialchars($row['client_name']   ?? '', ENT_QUOTES, 'UTF-8');
+$address    = htmlspecialchars($row['site_address']   ?? '', ENT_QUOTES, 'UTF-8');
+$state      = htmlspecialchars($row['state_postcode'] ?? '', ENT_QUOTES, 'UTF-8');
+$email      = htmlspecialchars($row['email']          ?? '', ENT_QUOTES, 'UTF-8');
+$labNo      = htmlspecialchars($row['lab_no']         ?? '', ENT_QUOTES, 'UTF-8');
+$sampleDate = htmlspecialchars($row['date_sampled']   ?? '', ENT_QUOTES, 'UTF-8');
+$sample     = htmlspecialchars($row['site_id']        ?? '', ENT_QUOTES, 'UTF-8');
+$cropName   = htmlspecialchars($row['sample_id']      ?? '', ENT_QUOTES, 'UTF-8');
+$soilType   = htmlspecialchars($row['soil_type']      ?? '', ENT_QUOTES, 'UTF-8');
+$batchNo    = htmlspecialchars($row['batch_no']       ?? '', ENT_QUOTES, 'UTF-8');
+
+$today     = date('jS F Y');
+$pageTitle = 'Soil Report' . ($client !== '' ? ' — ' . $client : '');
+$siteName  = 'Crop Monitor';
+
+// ── Five-year plan element definitions ─────────────────────────────────────
+// [label, soil_records column, min column (record), max column (record), unit]
+// When min/max are empty the spec column of the same name provides the target.
+$planElements = [
+    ['Calcium',   'BS_ca_ppm', 'ca_ppm_min', 'ca_ppm_max', 'kg/ha'],
+    ['Magnesium', 'BS_mg_ppm', 'mg_ppm_min', 'mg_ppm_max', 'kg/ha'],
+    ['Potassium', 'BS_k_ppm',  'k_ppm_min',  'k_ppm_max',  'kg/ha'],
+    ['Sodium',    'BS_na_ppm', 'na_ppm_min',  'na_ppm_max', 'kg/ha'],
+    ['Phosphate', 'p_colwell', '',           '',            'kg/ha'],
+    ['Sulfur',    's_morgan',  '',           '',            'kg/ha'],
+    ['Boron',     'b_cacl2',   '',           '',            'kg/ha'],
+    ['Manganese', 'mn_dtpa',   '',           '',            'kg/ha'],
+    ['Zinc',      'zn_dtpa',   '',           '',            'kg/ha'],
+    ['Copper',    'cu_dtpa',   '',           '',            'kg/ha'],
+];
 
-include __DIR__.'/../../../layouts/header.php';
+/**
+ * Calculate total kg/ha deficit for one element, given soil row + spec row.
+ * Returns 0 if already at or above target.
+ */
+function calcDeficit(array $row, array $spec, string $col, string $minCol, string $maxCol): float
+{
+    global $acHa;
+    $value  = (float)($row[$col] ?? 0);
+    if ($maxCol !== '') {
+        $maxVal = (float)($row[$maxCol] ?? 0);
+    } else {
+        $maxVal = (float)($spec[$col]   ?? 0);
+    }
+    $deficit = ($maxVal - $value) * $acHa;
+    return $deficit > 0 ? round($deficit, 2) : 0.0;
+}
 
+include __DIR__ . '/../../../layouts/header.php';
 ?>
 
-<div class="container">
-    <div class="row">
-        <div class="col-md-3">
-            <img class="img-fluid" src="/client-assets/images/crop-monitor.png" alt="Crop Monitor">
-        </div>
-        <div class="col-md-9">
-            <h4 class="mt-2">Soil Analysis for <?php echo $client; ?></h4>
-            <p><strong>Sample:</strong> <?php echo $sample; ?> | <strong>Date:</strong> <?php echo $sampleDate; ?></p>
+<style>
+    .element-required-module .card-group .col {
+    padding: 0 10px;
+  }
+</style>
+
+<div class="container-fluid px-4" id="content">
+
+    <!-- ── Page heading ──────────────────────────────────────────────────── -->
+    <div class="d-flex align-items-center justify-content-between mt-4 mb-3">
+        <h1 class="h3 mb-0">Soil Analysis Report</h1>
+        <div class="d-flex gap-2 d-print-none">
+            <a href="/dashboard/crop-analysis/soil-test-data/soil-analysis.php?rid=<?= $record_id ?>&rand=<?= urlencode($rand_id) ?>&cid=<?= $client_id ?>"
+               class="btn btn-outline-secondary btn-sm">
+                &larr; Analysis
+            </a>
+            <a href="/dashboard/crop-analysis/soil-test-data/soil-report-pdf.php?rid=<?= $record_id ?>&rand=<?= urlencode($rand_id) ?>"
+               class="btn btn-success btn-sm" target="_blank">
+                <i class="fas fa-file-pdf me-1"></i>PDF Report
+            </a>
+            <button type="button" class="btn btn-primary btn-sm" id="btn-generate-all">
+                <i class="fas fa-robot me-1"></i>Interpret All with AI
+            </button>
         </div>
     </div>
 
-    <!-- display client metadata -->
-    <div class="row">
-        <div class="col-md-12">
-            <table class="table table-sm table-borderless">
-                <tr><th>Address</th><td><?php echo $address; ?></td><th>State/Postcode</th><td><?php echo $state; ?></td></tr>
-                <tr><th>Email</th><td><?php echo $email; ?></td><th>Lab Number</th><td><?php echo $labNo; ?></td></tr>
-                <tr><th>Crop</th><td><?php echo $crop; ?></td><th>Test Date</th><td><?php echo $sampleDate; ?></td></tr>
-            </table>
+    <!-- ── Client / Sample info card ─────────────────────────────────────── -->
+    <div class="card mb-4">
+        <div class="card-body py-2">
+            <div class="row g-2">
+                <div class="col-md-2">
+                    <img class="img-fluid" src="/client-assets/images/crop-monitor.png" alt="Crop Monitor"
+                         style="max-height:60px;">
+                </div>
+                <div class="col-md-10">
+                    <div class="row row-cols-2 row-cols-md-3 g-1 small">
+                        <div><strong>Client:</strong> <?= $client ?></div>
+                        <div><strong>Sample ID:</strong> <?= $sample ?></div>
+                        <div><strong>Date Sampled:</strong> <?= $sampleDate ?></div>
+                        <div><strong>Address:</strong> <?= $address ?>, <?= $state ?></div>
+                        <div><strong>Crop:</strong> <?= $cropName ?></div>
+                        <div><strong>Lab No:</strong> <?= $labNo ?></div>
+                        <div><strong>Soil Type:</strong> <?= $soilType ?></div>
+                        <div><strong>Batch:</strong> <?= $batchNo ?></div>
+                        <div><strong>Report Date:</strong> <?= htmlspecialchars($today, ENT_QUOTES, 'UTF-8') ?></div>
+                    </div>
+                </div>
+            </div>
         </div>
     </div>
 
-    <!-- Graph Button -->
-    <div class="d-print-none">
-        <div class="row p-2">
-            <div class="col">
-                <a href="/dashboard/crop-analysis/soil-test-data/soil-report-pdf.php?rid=<?= (int)$record_id ?>&rand=<?= (float)$rand_id ?>"
-                   class="btn btn-success btn-sm" target="_blank">
-                    <i class="fas fa-file-pdf me-1"></i>View PDF Report
-                </a>
+    <div id="save-status" class="text-muted small mb-2" style="min-height:1.2rem;"></div>
+
+    <form class="report-form" method="post">
+        <input type="hidden" name="csrf_token"
+               value="<?= htmlspecialchars(generateCsrfToken(), ENT_QUOTES, 'UTF-8') ?>">
+        <input type="hidden" name="rid"  value="<?= $record_id ?>">
+        <input type="hidden" name="rand" value="<?= htmlspecialchars($rand_id, ENT_QUOTES, 'UTF-8') ?>">
+
+        <!-- ── 1. Element requirements ────────────────────────────────────── -->
+        <div class="card mb-4">
+            <div class="card-header fw-bold">
+                Total kilograms per hectare of each element needed to balance soil
             </div>
-            <div class="col">
-                <div class="form-status-holder"></div>
+            <div class="card-body">
+                <div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-6 g-2">
+                    <?php
+                    echo soilAnalysisReportCalcs('Ca',  'BS_ca_ppm', 'ca_ppm_min', 'ca_ppm_max', 'Calcium',      'kg', 'col', $record_id, $rand_id);
+                    echo soilAnalysisReportCalcs('Mg',  'BS_mg_ppm', 'mg_ppm_min', 'mg_ppm_max', 'Magnesium',    'kg', 'col', $record_id, $rand_id);
+                    echo soilAnalysisReportCalcs('K',   'BS_k_ppm',  'k_ppm_min',  'k_ppm_max',  'Potassium',    'kg', 'col', $record_id, $rand_id);
+                    echo soilAnalysisReportCalcs('Na',  'BS_na_ppm', 'na_ppm_min', 'na_ppm_max', 'Sodium',       'kg', 'col', $record_id, $rand_id);
+                    echo soilAnalysisReportCalcs('P',   'p_colwell', '',           '',           'Phosphate',     'kg', 'col', $record_id, $rand_id);
+                    echo soilAnalysisReportCalcs('S',   's_morgan',  '',           '',           'Sulfur',        'kg', 'col', $record_id, $rand_id);
+                    echo soilAnalysisReportCalcs('Mn',  'mn_dtpa',   '',           '',           'Manganese',     'kg', 'col', $record_id, $rand_id);
+                    echo soilAnalysisReportCalcs('Fe',  'fe_dtpa',   '',           '',           'Iron',          'kg', 'col', $record_id, $rand_id);
+                    echo soilAnalysisReportCalcs('Zn',  'zn_dtpa',   '',           '',           'Zinc',          'kg', 'col', $record_id, $rand_id);
+                    echo soilAnalysisReportCalcs('Cu',  'cu_dtpa',   '',           '',           'Copper',        'kg', 'col', $record_id, $rand_id);
+                    echo soilAnalysisReportCalcs('AmN', 'NH3_N',     '',           '',           'Amm. Nitrogen', 'kg', 'col', $record_id, $rand_id);
+                    echo soilAnalysisReportCalcs('B',   'b_cacl2',   '',           '',           'Boron',         'kg', 'col', $record_id, $rand_id);
+                    echo soilAnalysisReportCalcs('NN',  'NO3_N',     '',           '',           'Nit. Nitrogen', 'kg', 'col', $record_id, $rand_id);
+                    ?>
+                </div>
             </div>
         </div>
-    </div>
-    <!-- GRAPH BANNER -->
-    <div class="row">
-        <div class="col-md-12 text-center fw-bold h4">Soil Analysis Summary</div>
-    </div>
 
-    
-    <!-- Element Required Module  -->
-    <div class="element-required-module">
-        <div class="alert alert-secondary" role="alert">
-            <div class="text-center h5">Total kilograms per hectare of each element needed to balance soil in this test</div>
+        <!-- ── 2. Five-year balancing plan ───────────────────────────────── -->
+        <div class="card mb-4">
+            <div class="card-header fw-bold">
+                Ideal Soil Balancing Program — 5-Year Plan (kg/ha per year)
+            </div>
+            <div class="card-body p-0">
+                <div class="table-responsive">
+                    <table class="table table-sm table-bordered table-hover mb-0">
+                        <thead class="table-light">
+                            <tr>
+                                <th>Element</th>
+                                <th class="text-center">Total Deficit</th>
+                                <?php for ($y = 1; $y <= 5; $y++): ?>
+                                <th class="text-center">Year <?= $y ?></th>
+                                <?php endfor; ?>
+                            </tr>
+                        </thead>
+                        <tbody>
+                            <?php foreach ($planElements as [$label, $col, $minCol, $maxCol, $unit]):
+                                $total   = calcDeficit($row, $spec, $col, $minCol, $maxCol);
+                                $perYear = $total > 0 ? round($total / 5, 2) : 0;
+                            ?>
+                            <tr>
+                                <td><?= htmlspecialchars($label, ENT_QUOTES, 'UTF-8') ?></td>
+                                <td class="text-center <?= $total > 0 ? 'text-danger fw-semibold' : 'text-success' ?>">
+                                    <?= $total > 0 ? $total . ' ' . $unit : '&#10003; Adequate' ?>
+                                </td>
+                                <?php for ($y = 1; $y <= 5; $y++): ?>
+                                <td class="text-center">
+                                    <?= $perYear > 0 ? $perYear . ' ' . $unit : '—' ?>
+                                </td>
+                                <?php endfor; ?>
+                            </tr>
+                            <?php endforeach; ?>
+                        </tbody>
+                    </table>
+                </div>
+            </div>
         </div>
-        <div class="card-group">
-            <?php
-            echo soilAnalysisReportCalcs('Ca', 'BS_ca_ppm', 'ca_ppm_min', 'ca_ppm_max', 'Calcium', 'kg', 'col', $record_id, $rand_id);
-            echo soilAnalysisReportCalcs('Mg', 'BS_mg_ppm', 'mg_ppm_min', 'mg_ppm_max', 'Magnesium', 'kg', 'col', $record_id, $rand_id);
-            echo soilAnalysisReportCalcs('K', 'BS_k_ppm', 'k_ppm_min', 'k_ppm_max', 'Potasium', 'kg', 'col', $record_id, $rand_id);
-            echo soilAnalysisReportCalcs('Na', 'BS_na_ppm', 'na_ppm_min', 'na_ppm_max', 'Sodium', 'kg', 'col', $record_id, $rand_id);
-            echo soilAnalysisReportCalcs('P', 'p_colwell', '', '', 'Phosphate', 'kg', 'col', $record_id, $rand_id);
-            echo soilAnalysisReportCalcs('S', 's_morgan', '', '', 'Sulfur', 'kg', 'col', $record_id, $rand_id);
-            echo soilAnalysisReportCalcs('Mn', 'mn_dtpa', '', '', 'Manganese', 'kg', 'col', $record_id, $rand_id);
-            echo soilAnalysisReportCalcs('Fe', 'fe_dtpa', '', '', 'Iron', 'kg', 'col', $record_id, $rand_id);
-            echo soilAnalysisReportCalcs('Zn', 'zn_dtpa', '', '', 'Zinc', 'kg', 'col', $record_id, $rand_id);
-            echo soilAnalysisReportCalcs('Cu', 'cu_dtpa', '', '', 'Copper', 'kg', 'col', $record_id, $rand_id);
-            echo soilAnalysisReportCalcs('AmN', 'NH3_N', '', '', 'AmNitrogen', 'kg', 'col', $record_id, $rand_id);
-            echo soilAnalysisReportCalcs('B', 'b_cacl2', '', '', 'Boron', 'kg', 'col', $record_id, $rand_id);
-            echo soilAnalysisReportCalcs('NN', 'NO3_N', '', '', 'NNitrogen', 'kg', 'col', $record_id, $rand_id);
-            ?>
-    <form class="report-form" method="post">
-        <input class="" hidden type="text" name="id" id="id" value="<?= (int)getCurrentUserId() ?>">
-    
-        <!-- Overview Module  -->
-        <div class="overview-module py-2">
-            <div class="alert alert-secondary" role="alert">
-                <div class="text-center h5">Overview</div>
+
+        <!-- ── 3. Overview / Executive Summary ───────────────────────────── -->
+        <div class="card mb-4">
+            <div class="card-header d-flex justify-content-between align-items-center fw-bold">
+                <span>Overview</span>
+                <button type="button" class="btn btn-outline-primary btn-sm ai-generate-btn"
+                        data-section="overview" data-target="#overview">
+                    <i class="fas fa-robot me-1"></i>Generate with AI
+                </button>
+            </div>
+            <div class="card-body">
+                <textarea id="overview" name="overview" class="form-control report-textarea" rows="6"
+                          placeholder="Enter an overview of the soil analysis results..."
+                ><?= htmlspecialchars($savedComments['overview'] ?? '', ENT_QUOTES, 'UTF-8') ?></textarea>
             </div>
-            <textarea id="overview" name="overview" >This is some text within a card body.</textarea>
         </div>
-        
-        <!-- Overview Module  -->
-        <div class="overview-module py-2">
-            <div class="alert alert-secondary" role="alert">
-                <div class="text-center h5">Ideal Soil Balancing Program for One Season of a FIVE YEAR Plan</div>
+
+        <!-- ── 4. AI Interpretation ───────────────────────────────────────── -->
+        <div class="card mb-4">
+            <div class="card-header d-flex justify-content-between align-items-center fw-bold">
+                <span>AI Soil Interpretation</span>
+                <button type="button" class="btn btn-outline-primary btn-sm ai-generate-btn"
+                        data-section="ai_interpretation" data-target="#ai_interpretation">
+                    <i class="fas fa-robot me-1"></i>Interpret with AI
+                </button>
             </div>
-            <div class="card">
-                <div class="card-body">
-                    <?php
-                    for ($year = 1; $year <= 5; $year++) {
-                        echo soilProgramCalcs('Ca', 'BS_ca_ppm', 'ca_ppm_min', 'ca_ppm_max', 'Calcium', 'kg', $record_id, $rand_id);
-                    }
-                    ?>
-                </div>
+            <div class="card-body">
+                <p class="text-muted small mb-2">
+                    AI-generated agronomic interpretation. Review and edit before including in the final report.
+                </p>
+                <textarea id="ai_interpretation" name="ai_interpretation"
+                          class="form-control report-textarea" rows="10"
+                          placeholder="Click 'Interpret with AI' to generate an agronomic interpretation, or type manually..."
+                ><?= htmlspecialchars($savedComments['ai_interpretation'] ?? '', ENT_QUOTES, 'UTF-8') ?></textarea>
+            </div>
+        </div>
+
+        <!-- ── 5. Foliar program ──────────────────────────────────────────── -->
+        <div class="card mb-4">
+            <div class="card-header d-flex justify-content-between align-items-center fw-bold">
+                <span>
+                    <input type="text" name="header1" class="form-control form-control-sm d-inline-block w-auto fw-bold"
+                           value="<?= htmlspecialchars($savedComments['header1'] ?? 'Foliar Program', ENT_QUOTES, 'UTF-8') ?>">
+                </span>
+                <button type="button" class="btn btn-outline-primary btn-sm ai-generate-btn"
+                        data-section="foliar" data-target="#foliar_Details">
+                    <i class="fas fa-robot me-1"></i>Generate with AI
+                </button>
+            </div>
+            <div class="card-body">
+                <textarea id="foliar_Details" name="foliar_Details"
+                          class="form-control report-textarea" rows="6"
+                          placeholder="Enter the foliar spray program details..."
+                ><?= htmlspecialchars($savedComments['foliar_details'] ?? '', ENT_QUOTES, 'UTF-8') ?></textarea>
             </div>
         </div>
 
-         
-        <hr>
+        <!-- ── 6. Microbial program ───────────────────────────────────────── -->
+        <div class="card mb-4">
+            <div class="card-header d-flex justify-content-between align-items-center fw-bold">
+                <span>Microbial Program</span>
+                <button type="button" class="btn btn-outline-primary btn-sm ai-generate-btn"
+                        data-section="microbial" data-target="#microbe_Program">
+                    <i class="fas fa-robot me-1"></i>Generate with AI
+                </button>
+            </div>
+            <div class="card-body">
+                <textarea id="microbe_Program" name="microbe_Program"
+                          class="form-control report-textarea" rows="6"
+                          placeholder="Enter the microbial / biological program details..."
+                ><?= htmlspecialchars($savedComments['microbe_program'] ?? '', ENT_QUOTES, 'UTF-8') ?></textarea>
+            </div>
+        </div>
 
-        <div class="row pt-4">
-            <p style="font-style: italic; font-size: 9px;">Any recommendations provided by Cropmonitor are advice only, We are not paid consultants and we are not covered to accept responsibiliy for any of our suggestions. As no control can be exercised over storage, handling, mixing application or use, or weather, plant or soil conditions before, during or after application (all of which may affect the preformance of our program), no responsibility for, or liability for any failure in performance, losses, damage or injuries consequential or otherwise, arisiing form such storage mixng application or use will be accepted under any circumstances whatsoever. The buyer assumes all responsibility for the use of any of our products.</p>
+        <!-- ── 7. Disclaimer ──────────────────────────────────────────────── -->
+        <div class="card mb-4 border-0 bg-light">
+            <div class="card-body">
+                <p class="text-muted mb-0" style="font-size:0.75rem;">
+                    Any recommendations provided by Crop Monitor are advice only. We are not paid consultants
+                    and accept no responsibility for any of our suggestions. No control can be exercised over
+                    storage, handling, mixing, application, or use, or over weather, plant or soil conditions
+                    before, during or after application — all of which may affect the performance of our
+                    program. No responsibility for, or liability for, any failure in performance, losses,
+                    damage or injuries consequential or otherwise arising from such storage, mixing,
+                    application or use will be accepted under any circumstances whatsoever. The buyer assumes
+                    all responsibility for the use of any of our products.
+                </p>
+            </div>
         </div>
-    
+
     </form>
-</div>    
-
-
-
-    <script type="text/javascript">
-        $(document).ready(function(){
-            var timeoutId;
-                $('form textarea, form input').on('input propertychange change', function() {
-                    console.log('Textarea Change');
-                    
-                    clearTimeout(timeoutId);
-                    timeoutId = setTimeout(function() {
-                        // Runs 1 second (1000 ms) after the last change    
-                        saveToDB();
-                    }, 1000);
-                });
-                
-                function saveToDB() {
-                    console.log('Saving to the db');
-                    form = $('.report-form');
-                	$.ajax({
-                		url: "/dashboard/crop-analysis/updatecomment.php?rid=<?= (int)$record_id ?>&rand=<?= (float)$rand_id ?>",
-                		type: "POST",
-                		data: form.serialize(), // serializes the form's elements.
-                		beforeSend: function(xhr) {
-                            // Let them know we are saving
-                			$('.form-status-holder').html('Saving...');
-                		},
-                		success: function(data) {
-                			var jqObj = jQuery(data); // You can get data returned from your ajax call here. ex. jqObj.find('.returned-data').html()
-                            // Now show them we saved and when we did
-                            var d = new Date();
-                            $('.form-status-holder').html('Saved! Last: ' + d.toLocaleTimeString());
-                		},
-                	});
+
+</div><!-- /.container-fluid -->
+
+
+<script>
+(function () {
+    'use strict';
+
+    var saveTimer  = null;
+    var statusEl   = document.getElementById('save-status');
+    var SAVE_URL   = '/dashboard/crop-analysis/updatecomment.php'
+                   + '?rid=<?= $record_id ?>&rand=<?= urlencode($rand_id) ?>';
+    var AI_URL     = '/controllers/ollamaGenerate.php';
+    var CSRF_TOKEN = <?= json_encode(generateCsrfToken()) ?>;
+
+    function setStatus(msg, cls) {
+        statusEl.textContent = msg;
+        statusEl.className   = 'small mb-2 text-' + (cls || 'secondary');
+    }
+
+    // ── Auto-save ────────────────────────────────────────────────────────── //
+    document.querySelectorAll('.report-form .report-textarea, .report-form input[name="header1"]')
+        .forEach(function (el) {
+            el.addEventListener('input', function () {
+                clearTimeout(saveTimer);
+                saveTimer = setTimeout(saveReport, 1200);
+            });
+        });
+
+    function saveReport() {
+        var form = document.querySelector('.report-form');
+        var data = new URLSearchParams(new FormData(form));
+        setStatus('Saving…', 'secondary');
+
+        fetch(SAVE_URL, { method: 'POST', body: data })
+            .then(function (r) { return r.json(); })
+            .then(function (d) {
+                if (d.success) {
+                    var t = new Date();
+                    setStatus('Saved — ' + t.toLocaleTimeString(), 'success');
+                } else {
+                    setStatus('Save failed: ' + (d.message || 'unknown error'), 'danger');
                 }
-                
-                // This is just so we don't go anywhere  
-                // and still save if you submit the form
-                $('.report-form').submit(function(e) {
-                	saveToDB();
-                	e.preventDefault();
-                });
+            })
+            .catch(function () {
+                setStatus('Network error — not saved', 'danger');
+            });
+    }
+
+    document.querySelector('.report-form').addEventListener('submit', function (e) {
+        e.preventDefault();
+        saveReport();
+    });
+
+    // ── AI generation ────────────────────────────────────────────────────── //
+    function generateSection(btn, section, targetSelector) {
+        var textarea = document.querySelector(targetSelector);
+        if (!textarea) return;
+
+        var origHTML = btn.innerHTML;
+        btn.disabled = true;
+        btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Generating…';
+        setStatus('Requesting AI interpretation…', 'secondary');
+
+        fetch(AI_URL, {
+            method: 'POST',
+            headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+            body: new URLSearchParams({
+                csrf_token: CSRF_TOKEN,
+                rid:        <?= $record_id ?>,
+                rand:       <?= json_encode($rand_id) ?>,
+                section:    section,
+            }),
+        })
+        .then(function (r) { return r.json(); })
+        .then(function (d) {
+            if (d.success && d.text) {
+                textarea.value = d.text;
+                textarea.dispatchEvent(new Event('input')); // trigger auto-save
+                setStatus('AI text generated — review before publishing', 'success');
+            } else {
+                setStatus('AI error: ' + (d.error || 'no response returned'), 'danger');
+            }
+        })
+        .catch(function () {
+            setStatus('Could not reach AI service. Is Ollama running on port 11434?', 'danger');
+        })
+        .finally(function () {
+            btn.disabled = false;
+            btn.innerHTML = origHTML;
         });
-    </script>
-
-</div>
-
-    <!-- 
-    <script src="https://cloud.tinymce.com/stable/tinymce.min.js?apiKey=xcotawi18mg1imp8im144buq68h9g3ndd3c9c8215w8qu3ld"></script>
-    <script>
-        tinymce.init({
-            selector: 'textarea',
-            menubar: false,
-            toolbar: 'bold italic  | alignleft aligncenter alignright alignjustify | bullist numlist | removeformat',
-            plugins: 'autosave',
-            autosave_interval: '20s'
+    }
+
+    document.querySelectorAll('.ai-generate-btn').forEach(function (btn) {
+        btn.addEventListener('click', function () {
+            generateSection(btn, btn.dataset.section, btn.dataset.target);
         });
-    </script>
-    -->
-    
-    
-    <script>
-        //https://github.com/eKoopmans/html2pdf.js
-        $('.downloadPDF').click(function () {
-        	var element = document.getElementById('content'); //document.createElement("body");
-        	element.classList.remove('screen');
-        	element.classList.add('print');
-        	var opt = {
-        		margin:       3,
-        		filename:     'soil-analysis.pdf',
-        		image:        { type: 'jpeg', quality: 1.0 },
-        		html2canvas:  { scale: 2, letterRendering: true, windowWidth: 1024 },  //, windowWidth: 1024
-        		jsPDF:        { orientation: 'portrait', unit: 'mm', format: 'a4', putOnlyUsedFonts: true, floatPrecision: 'smart', }
-        	};
-        	html2pdf()
-        	    .from(element)
-        	    .toPdf()
-        	    .set(opt)
-        	    .save()
-        	    .then(function(){
-        		    element.classList.remove('print');
-        		    element.classList.add('screen');
-        	});
-        	
+    });
+
+    // "Interpret All" — stagger requests so Ollama isn't flooded
+    document.getElementById('btn-generate-all').addEventListener('click', function () {
+        var sections = [
+            { section: 'overview',          target: '#overview' },
+            { section: 'ai_interpretation', target: '#ai_interpretation' },
+            { section: 'foliar',            target: '#foliar_Details' },
+            { section: 'microbial',         target: '#microbe_Program' },
+        ];
+        sections.forEach(function (s, i) {
+            setTimeout(function () {
+                var sectionBtn = document.querySelector('.ai-generate-btn[data-section="' + s.section + '"]');
+                generateSection(sectionBtn || document.getElementById('btn-generate-all'), s.section, s.target);
+            }, i * 4000);
         });
-    </script>
-</body>
-</html>
+    });
+
+})();
+</script>
+
+<?php include __DIR__ . '/../../../layouts/footer.php'; ?>

+ 5 - 4
dashboard/crop-analysis/updatecomment.php

@@ -49,10 +49,11 @@ if (!$check->fetch()) {
 
 // Collect and sanitise comment fields
 $data = [
-    'overview'        => trim($_POST['overview']        ?? ''),
-    'foliar_details'  => trim($_POST['foliar_Details']  ?? ''),
-    'microbe_program' => trim($_POST['microbe_Program'] ?? ''),
-    'header1'         => trim($_POST['header1']         ?? 'Foliar Program'),
+    'overview'           => trim($_POST['overview']           ?? ''),
+    'ai_interpretation'  => trim($_POST['ai_interpretation']  ?? ''),
+    'foliar_details'     => trim($_POST['foliar_Details']      ?? ''),
+    'microbe_program'    => trim($_POST['microbe_Program']     ?? ''),
+    'header1'            => trim($_POST['header1']             ?? 'Foliar Program'),
 ];
 
 $comment = json_encode($data, JSON_UNESCAPED_UNICODE);