studentClassModel = new StudentClassModel(); $this->prepLogModel = new ClassPreparationLogModel(); $this->classSectionModel = new ClassSectionModel(); $this->configModel = new ConfigurationModel(); $this->adjustmentModel = new ClassPrepAdjustmentModel(); $this->userModel = new UserModel(); $this->db = \Config\Database::connect(); $this->schoolYear = $this->configModel->getConfig('school_year'); $this->semester = $this->configModel->getConfig('semester'); } public function index() { $schoolYear = (string)($this->request->getGet('school_year') ?? $this->schoolYear); $semParam = $this->request->getGet('semester'); $semester = (is_string($semParam) && $semParam !== '') ? (string)$semParam : (string)$this->semester; $allowed = $this->allowedPrepCategories; $limitToSemester = $this->hasRosterForSemester($schoolYear, $semester); // 1) Get student count per class-section (distinct students, correct semester) $scQ = $this->db->table('student_class sc') ->select('sc.class_section_id, COUNT(DISTINCT sc.student_id) AS student_count', false) ->join('students s', 's.id = sc.student_id', 'inner') ->where('s.is_active', 1) ->where('sc.school_year', $schoolYear); if ($limitToSemester && $semester !== '') { $scQ->where('sc.semester', $semester); } $classSections = $scQ->groupBy('sc.class_section_id')->get()->getResultArray(); // 2) Inventory availability — prefer good_qty when present, else condition='good' quantity. $inventoryMap = $this->buildInventoryAvailability($schoolYear, $semester, $limitToSemester, $allowed); // Seed totals with allowed categories for stable ordering $requiredTotals = array_fill_keys($allowed, 0); $prepResults = []; // 3) Build prep per section (once!) foreach ($classSections as $section) { $classSectionId = $section['class_section_id']; $studentCount = (int)$section['student_count']; $classLevel = $this->getClassLevelBySection($classSectionId); $className = $this->classSectionModel->getClassSectionNameBySectionId($classSectionId); // Calculate required items (whitelist inside) $baseItems = $this->calculatePrepItems($studentCount, $classLevel, $classSectionId); // --- Apply adjustments (only Small/Large Table), clamp >= 0 --- $rawAdjustments = $this->adjustmentModel ->where('class_section_id', $classSectionId) ->where('school_year', $schoolYear) ->where('adjustable', 1) ->findAll(); $adjMap = ['Large Table' => 0, 'Small Table' => 0]; foreach ($rawAdjustments as $a) { $item = $a['item_name']; $delta = (int)$a['adjustment']; if (array_key_exists($item, $adjMap)) { $adjMap[$item] = $delta; } if (isset($baseItems[$item])) { $baseItems[$item] = max(0, (int)$baseItems[$item] + $delta); } elseif (in_array($item, $allowed, true)) { $baseItems[$item] = max(0, $delta); } } // 4) Accumulate global totals (allowed only) foreach ($allowed as $cat) { $requiredTotals[$cat] += (int)($baseItems[$cat] ?? 0); } // 5) Compare with last snapshot; do not save here — only on print or explicit API $oldSnap = $this->prepLogModel ->where('class_section_id', $classSectionId) ->where('school_year', $schoolYear) ->orderBy('created_at', 'DESC') ->first(); $oldPrep = $oldSnap ? json_decode($oldSnap['prep_data'], true) : []; $hasChanged = $this->hasPrepChanged($baseItems, $oldPrep); // 6) Push exactly ONCE $prepResults[] = [ 'class_section' => $className, 'class_section_id' => $classSectionId, 'student_count' => $studentCount, 'class_level' => $classLevel, 'prep_items' => $baseItems, 'needs_print' => $hasChanged, 'last_printed_at' => $oldSnap['created_at'] ?? null, 'adjustments' => $adjMap, ]; } // 7) Shortages (compare totals vs inventory) $shortages = []; foreach ($requiredTotals as $item => $reqQty) { $have = (int)($inventoryMap[$item] ?? 0); if ($have < (int)$reqQty) { $shortages[$item] = (int)$reqQty - $have; } } return view('class_prep/list', [ 'prepResults' => $prepResults, 'schoolYear' => $schoolYear, 'semester' => $semester, 'shortages' => $shortages, 'totalNeeded' => $requiredTotals, 'available' => $inventoryMap, // For temporary debugging, you can pass these too: // 'joinGood' => $joinGood, 'nameGood' => $nameGood, 'joinCond' => $joinCond, 'nameCond' => $nameCond, ]); } private function getTeacherCountForSection(string $classSectionId, ?string $schoolYear = null): int { $schoolYear = $schoolYear ?? $this->schoolYear; // positions: 'main' and 'ta' (per your new schema) $row = $this->db->table('teacher_class') ->select('COUNT(*) AS cnt') ->where('class_section_id', $classSectionId) ->whereIn('position', ['main', 'ta']) ->where('school_year', $schoolYear) ->get()->getRowArray(); return (int)($row['cnt'] ?? 0); } private function calculatePrepItems(int $students, int $classLevel, string $classSectionId): array { // 1) Stable keys: your whitelist, all zero to start $allowed = $this->allowedPrepCategories; $items = array_fill_keys($allowed, 0); // 2) Fetch only the classroom categories you care about (optionally with grade bounds) $categories = $this->db->table('inventory_categories') ->select('name, grade_min, grade_max') ->where('type', 'classroom') ->whereIn('name', $allowed) ->orderBy('name') ->get() ->getResultArray(); // 3) Rules $isLowerGrades = in_array($classLevel, [1, 2], true); $teacherCount = $this->getTeacherCountForSection($classSectionId, $this->schoolYear); foreach ($categories as $cat) { $name = $cat['name']; // e.g., 'Small Table' $gradeMin = $cat['grade_min']; // may be null $gradeMax = $cat['grade_max']; // may be null // Respect optional bounds if ($gradeMin !== null && $classLevel < (int)$gradeMin) { $items[$name] = 0; continue; } if ($gradeMax !== null && $classLevel > (int)$gradeMax) { $items[$name] = 0; continue; } $qty = 0; switch (strtolower($name)) { case 'small table': $qty = $isLowerGrades ? (int)ceil($students / 3) : 0; break; case 'large table': $qty = $isLowerGrades ? 0 : (int)ceil($students / 4); break; case 'small chair': $qty = $isLowerGrades ? $students : 0; break; case 'regular chair': $qty = $isLowerGrades ? 0 : $students; break; case 'teacher chair': $qty = (int)$teacherCount; break; case 'trash bin': case 'white board': case 'grade box': $qty = 1; break; default: $qty = 0; } // Only set for allowed keys (guards against unexpected DB rows) if (array_key_exists($name, $items)) { $items[$name] = $qty; } } return $items; } public function saveAdjustment() { $data = $this->request->getPost(); $sectionId = $data['class_section_id']; $schoolYear = $data['school_year']; $adjustments = $data['adjustments'] ?? []; foreach ($adjustments as $itemName => $adjustment) { $row = $this->adjustmentModel ->where('class_section_id', $sectionId) ->where('school_year', $schoolYear) ->where('item_name', $itemName) ->first(); if ($row) { // Update $this->adjustmentModel->update($row['id'], ['adjustment' => (int)$adjustment, 'adjustable' => 1]); } else { // Insert $this->adjustmentModel->insert([ 'class_section_id' => $sectionId, 'item_name' => $itemName, 'adjustment' => (int)$adjustment, 'school_year' => $schoolYear, 'adjustable' => 1, ]); } } return redirect()->to(site_url('class-prep?school_year=' . urlencode($schoolYear))) ->with('success', 'Adjustments saved successfully.'); } /* ---------- helpers ---------- */ private function hasPrepChanged(array $new, array $old): bool { ksort($new); ksort($old); return json_encode($new) !== json_encode($old); } public function print($classSectionId, $schoolYear) { $semParam = $this->request->getGet('semester'); $semester = (is_string($semParam) && $semParam !== '') ? (string) $semParam : (string) $this->semester; $limitToSemester = $this->hasRosterForSemester((string) $schoolYear, $semester); // Friendly label (if you have this helper) $className = $this->classSectionModel->getClassSectionNameBySectionId($classSectionId) ?? $classSectionId; // Distinct student count for this term $studentQ = $this->db->table('student_class sc') ->select('COUNT(DISTINCT sc.student_id) AS cnt', false) ->join('students s', 's.id = sc.student_id', 'inner') ->where('s.is_active', 1) ->where('sc.class_section_id', $classSectionId) ->where('sc.school_year', $schoolYear); if ($limitToSemester && $semester !== '') { $studentQ->where('sc.semester', $semester); } $studentRow = $studentQ->get()->getRowArray(); $studentCount = (int)($studentRow['cnt'] ?? 0); // Live calc + adjustments $classLevel = $this->getClassLevelBySection((string)$classSectionId); $items = $this->calculatePrepItems($studentCount, $classLevel, (string)$classSectionId); $rawAdjustments = $this->adjustmentModel ->where('class_section_id', $classSectionId) ->where('school_year', $schoolYear) ->where('adjustable', 1) ->findAll(); foreach ($rawAdjustments as $a) { $item = $a['item_name']; $delta = (int)$a['adjustment']; if (isset($items[$item])) $items[$item] = max(0, (int)$items[$item] + $delta); } // Record a snapshot at print time as the new baseline $this->prepLogModel->insert([ 'class_section_id' => (string)$classSectionId, 'class_section' => $className, 'school_year' => $schoolYear, 'prep_data' => json_encode($items), 'created_at' => utc_now(), ]); // Return PRINT view (HTML) that auto-opens the print dialog return view('class_prep/print', [ 'classSectionId' => $classSectionId, 'classSection' => $className, 'schoolYear' => $schoolYear, 'prepItems' => $items, ]); } /** * API: Return class prep data and change flags for all sections in a year. * GET: school_year * Response: { ok, schoolYear, results:[{ class_section_id, class_section, student_count, class_level, prep_items, needs_print, last_printed_at }], totals, shortages } */ public function apiList() { $schoolYear = (string)($this->request->getGet('school_year') ?? $this->schoolYear); $semParam = $this->request->getGet('semester'); $semester = (is_string($semParam) && $semParam !== '') ? (string)$semParam : (string)$this->semester; $allowed = $this->allowedPrepCategories; $limitToSemester = $this->hasRosterForSemester($schoolYear, $semester); // Student counts $scQ = $this->db->table('student_class sc') ->select('sc.class_section_id, COUNT(DISTINCT sc.student_id) AS student_count', false) ->join('students s', 's.id = sc.student_id', 'inner') ->where('s.is_active', 1) ->where('sc.school_year', $schoolYear); if ($limitToSemester && $semester !== '') { $scQ->where('sc.semester', $semester); } $classSections = $scQ->groupBy('sc.class_section_id')->get()->getResultArray(); // Build inventory availability maps $inventoryMap = $this->buildInventoryAvailability($schoolYear, $semester, $limitToSemester, $allowed); $requiredTotals = array_fill_keys($allowed, 0); $results = []; foreach ($classSections as $row) { $classSectionId = (string)$row['class_section_id']; $studentCount = (int)$row['student_count']; $classLevel = $this->getClassLevelBySection($classSectionId); $className = $this->classSectionModel->getClassSectionNameBySectionId($classSectionId); $baseItems = $this->calculatePrepItems($studentCount, $classLevel, $classSectionId); $rawAdjustments = $this->adjustmentModel ->where('class_section_id', $classSectionId) ->where('school_year', $schoolYear) ->where('adjustable', 1) ->findAll(); $adjMap = ['Large Table' => 0, 'Small Table' => 0]; foreach ($rawAdjustments as $a) { $item = $a['item_name']; $delta = (int)$a['adjustment']; if (array_key_exists($item, $adjMap)) $adjMap[$item] = $delta; if (isset($baseItems[$item])) $baseItems[$item] = max(0, (int)$baseItems[$item] + $delta); } foreach ($allowed as $cat) { $requiredTotals[$cat] += (int)($baseItems[$cat] ?? 0); } $oldSnap = $this->prepLogModel ->where('class_section_id', $classSectionId) ->where('school_year', $schoolYear) ->orderBy('created_at', 'DESC') ->first(); $oldPrep = $oldSnap ? json_decode($oldSnap['prep_data'], true) : []; $hasChanged = $this->hasPrepChanged($baseItems, $oldPrep); $results[] = [ 'class_section' => $className, 'class_section_id' => $classSectionId, 'student_count' => $studentCount, 'class_level' => $classLevel, 'prep_items' => $baseItems, 'needs_print' => $hasChanged, 'last_printed_at' => $oldSnap['created_at'] ?? null, 'adjustments' => $adjMap, ]; } // Shortages $shortages = []; foreach ($requiredTotals as $item => $reqQty) { $have = (int)($inventoryMap[$item] ?? 0); if ($have < (int)$reqQty) $shortages[$item] = (int)$reqQty - $have; } return $this->response->setJSON([ 'ok' => true, 'schoolYear' => $schoolYear, 'results' => $results, 'totals' => $requiredTotals, 'shortages' => $shortages, 'csrf_token' => csrf_token(), 'csrf_hash' => csrf_hash(), ]); } /** * API: Mark selected classes as printed (save baseline snapshot). * POST: school_year, class_section_ids[] */ public function apiMarkPrinted() { $schoolYear = (string)($this->request->getPost('school_year') ?? $this->schoolYear); $semParam = $this->request->getPost('semester'); $semester = (is_string($semParam) && $semParam !== '') ? (string)$semParam : (string)$this->semester; $limitToSemester = $this->hasRosterForSemester($schoolYear, $semester); $ids = $this->request->getPost('class_section_ids') ?? []; if (!is_array($ids)) $ids = $ids ? [$ids] : []; $ids = array_values(array_unique(array_filter(array_map('strval', $ids)))); $now = utc_now(); $count = 0; foreach ($ids as $classSectionId) { $studentQ = $this->db->table('student_class sc') ->select('COUNT(DISTINCT sc.student_id) AS cnt', false) ->join('students s', 's.id = sc.student_id', 'inner') ->where('s.is_active', 1) ->where('sc.class_section_id', $classSectionId) ->where('sc.school_year', $schoolYear); if ($limitToSemester && $semester !== '') { $studentQ->where('sc.semester', $semester); } $studentRow = $studentQ->get()->getRowArray(); $studentCount = (int)($studentRow['cnt'] ?? 0); $classLevel = $this->getClassLevelBySection((string)$classSectionId); $className = $this->classSectionModel->getClassSectionNameBySectionId($classSectionId) ?? $classSectionId; $items = $this->calculatePrepItems($studentCount, $classLevel, (string)$classSectionId); $rawAdjustments = $this->adjustmentModel ->where('class_section_id', $classSectionId) ->where('school_year', $schoolYear) ->where('adjustable', 1) ->findAll(); foreach ($rawAdjustments as $a) { $item = $a['item_name']; $delta = (int)$a['adjustment']; if (isset($items[$item])) $items[$item] = max(0, (int)$items[$item] + $delta); } try { $this->prepLogModel->insert([ 'class_section_id' => (string)$classSectionId, 'class_section' => $className, 'school_year' => $schoolYear, 'prep_data' => json_encode($items), 'created_at' => $now, ]); $count++; } catch (\Throwable $e) { // ignore and continue } } return $this->response->setJSON([ 'ok' => true, 'updated' => $count, 'csrf_token' => csrf_token(), 'csrf_hash' => csrf_hash(), ]); } private function getClassLevelBySection(string $classSectionId): int { // Prefer the human-readable section name for grade parsing. $sectionName = $this->classSectionModel->getClassSectionNameBySectionId($classSectionId); $label = $sectionName !== null && $sectionName !== '' ? (string) $sectionName : $classSectionId; // If it's clearly Kindergarten (KG/K) if (preg_match('/^(kg|k)(\b|[^a-z0-9])/i', $label)) { return 1; // treat Kindergarten as lower grade } // Remove non-digits, then infer by leading digit $numValue = (int) preg_replace('/\D/', '', $label); if ($numValue > 0 && $numValue < 30) { $firstDigit = (int) substr((string) $numValue, 0, 1); return $firstDigit === 1 ? 1 : ($firstDigit === 2 ? 2 : 3); } // Default to upper grades (3+) return 3; } /** * Returns true if student_class has any rows for the given school year. * The semester argument is ignored because assignments are tracked per year. */ private function hasRosterForTerm(string $schoolYear, string $semester): bool { $year = trim((string)$schoolYear); if ($year === '') { return false; } if (array_key_exists($year, $this->rosterPresenceCache)) { return $this->rosterPresenceCache[$year]; } $cnt = $this->db->table('student_class') ->where('school_year', $year) ->countAllResults(); $this->rosterPresenceCache[$year] = $cnt > 0; return $this->rosterPresenceCache[$year]; } private function hasRosterForSemester(string $schoolYear, string $semester): bool { $year = trim((string)$schoolYear); $sem = trim((string)$semester); if ($year === '' || $sem === '') { return false; } $key = sprintf('sem:%s:%s', $year, $sem); if (array_key_exists($key, $this->rosterPresenceCache)) { return $this->rosterPresenceCache[$key]; } $cnt = $this->db->table('student_class') ->where('school_year', $year) ->where('semester', $sem) ->countAllResults(); $this->rosterPresenceCache[$key] = $cnt > 0; return $this->rosterPresenceCache[$key]; } private function buildInventoryAvailability(string $schoolYear, string $semester, bool $limitToSemester, array $allowed): array { $inventoryMap = array_fill_keys($allowed, 0); $joinRows = $this->db->table('inventory_items ii') ->select('ic.name AS item_name, COALESCE(SUM(CASE WHEN ii.good_qty IS NOT NULL THEN ii.good_qty WHEN ii.`condition`="good" THEN ii.quantity ELSE 0 END),0) AS available') ->join('inventory_categories ic', 'ic.id = ii.category_id', 'left') ->where('ii.type', 'classroom') ->where('ii.school_year', $schoolYear) ->whereIn('ic.name', $allowed); if ($limitToSemester && $semester !== '') { $joinRows->where('ii.semester', $semester); } $joinRows = $joinRows->groupBy('ic.name')->get()->getResultArray(); foreach ($joinRows as $r) { $name = (string)($r['item_name'] ?? ''); if ($name !== '' && isset($inventoryMap[$name])) { $inventoryMap[$name] = max($inventoryMap[$name], (int)($r['available'] ?? 0)); } } $nameRows = $this->db->table('inventory_items') ->select('name AS item_name, COALESCE(SUM(CASE WHEN good_qty IS NOT NULL THEN good_qty WHEN `condition`="good" THEN quantity ELSE 0 END),0) AS available') ->where('type', 'classroom') ->where('school_year', $schoolYear) ->whereIn('name', $allowed); if ($limitToSemester && $semester !== '') { $nameRows->where('semester', $semester); } $nameRows = $nameRows->groupBy('name')->get()->getResultArray(); foreach ($nameRows as $r) { $name = (string)($r['item_name'] ?? ''); if ($name !== '' && isset($inventoryMap[$name])) { $inventoryMap[$name] = max($inventoryMap[$name], (int)($r['available'] ?? 0)); } } return $inventoryMap; } }