recreate project

This commit is contained in:
root
2026-02-10 22:11:06 -05:00
commit 663c0cdbda
10149 changed files with 1379710 additions and 0 deletions
+353
View File
@@ -0,0 +1,353 @@
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<?php
// Role formatter for display + posting
$formatRole = function (?string $role): string {
$role = (string)$role;
$role = str_replace(['-', '_'], ' ', $role);
$role = preg_replace('/\s+/', ' ', trim($role));
if ($role === '') return '';
$out = [];
foreach (explode(' ', $role) as $w) {
if ($w === '') continue;
if (preg_match('/^[A-Za-z]{1,3}$/', $w)) {
$out[] = strtoupper($w); // TA, PTA, HR, KG...
} else {
$out[] = ucfirst(strtolower($w)); // Teacher, Assistant, Admin...
}
}
return implode(' ', $out);
};
?>
<div class="container-fluid">
<div class="wrapper">
<div class="content">
<div class="text-center mx-auto mb-5 wow" data-wow-delay="0.1s" style="max-width: 600px;">
<br>
<h2 class="text-dark mb-3" style="font-family: Arial, sans-serif;">Generate Staff Badges</h2>
</div>
<?= $this->include('partials/academic_filter') ?>
<div class="d-flex flex-column flex-md-row justify-content-md-end align-items-md-center gap-2">
<!-- Submit button associates to form below -->
<button type="submit" class="btn btn-success mt-3" form="badgeForm">Generate Badges</button>
<br>
</div>
<!-- Staff selection -->
<form method="post" action="<?= base_url('badge') ?>" target="_blank" id="badgeForm">
<?= csrf_field() ?>
<input type="hidden" name="school_year" value="<?= esc($selectedYear ?? '') ?>">
<!-- hidden container to preserve selections and their data (guaranteed inside form) -->
<div id="selectedHiddenInputs" style="display:none"></div>
<table id="staffTable" class="table table-bordered table-striped align-middle w-100">
<thead class="table-dark">
<tr>
<th style="width: 70px;">#</th>
<th scope="col">Firstname</th>
<th scope="col">Lastname</th>
<th scope="col">Role</th>
<th scope="col">Teacher Class Section</th>
<th scope="col" style="width: 140px;">Badge Prints</th>
<th scope="col" style="width: 150px;">
<div class="form-check m-0">
<input class="form-check-input" type="checkbox" id="select-all">
<label class="form-check-label" for="select-all">Select All (page)</label>
</div>
</th>
</tr>
</thead>
<tbody>
<?php if (!empty($users)): ?>
<?php $order = 1; ?>
<?php foreach ($users as $user): ?>
<?php
// Pick a representative role (first of CSV or role_name/active_role)
$roleRaw = $user['active_role'] ?? $user['role_name'] ?? ($user['roles'] ?? '');
if (strpos((string)$roleRaw, ',') !== false) {
$parts = array_filter(array_map('trim', explode(',', (string)$roleRaw)));
$roleRaw = $parts[0] ?? '';
}
//$roleLabel = $roleRaw !== '' ? $formatRole($roleRaw) : '-';
$roleLabel = $user['role_name'] ?? ($user['roles'] ?? '-'); // both are pre-formatted by $formatRole
// Normalize user id key for checkbox
$uid = $user['user_id'] ?? $user['id'] ?? ($user['users.id'] ?? null);
// Class section name (optional)
$className = !empty($user['class_section_name']) ? (string)$user['class_section_name'] : '';
?>
<tr
data-user-id="<?= esc($uid ?? '') ?>"
data-role-label="<?= esc($roleLabel) ?>"
data-class-name="<?= esc($className) ?>">
<td style="text-align: center;"><?= esc($order++) ?></td>
<td><?= esc($user['firstname'] ?? '') ?></td>
<td><?= esc($user['lastname'] ?? '') ?></td>
<td><?= esc($roleLabel) ?></td>
<td><?= $className !== '' ? esc($className) : '-' ?></td>
<td><span class="badge bg-secondary prints-badge" data-user-id="<?= esc($uid ?? '') ?>">—</span></td>
<td>
<?php if ($uid !== null): ?>
<div class="form-check m-0">
<input type="checkbox" name="user_ids[]" value="<?= esc($uid) ?>" class="form-check-input user-checkbox" id="chk-<?= esc($uid) ?>">
<label class="form-check-label" for="chk-<?= esc($uid) ?>">Select</label>
</div>
<?php else: ?>
<span class="text-muted">N/A</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr>
<td colspan="7" class="text-center text-muted">No staff found for the selected year.</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</form>
<br>
</div>
</div>
</div>
<?= $this->endSection() ?>
<?= $this->section('scripts') ?>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Track selected user IDs across paging/filtering/sorting
const selected = new Set();
const formEl = document.getElementById('badgeForm');
let clearTimerId = null; // pending auto-clear timer, if any
let hiddenContainer = document.getElementById('selectedHiddenInputs');
// Ensure hidden container exists inside the form (some earlier layouts had it outside)
if (!hiddenContainer || !formEl.contains(hiddenContainer)) {
hiddenContainer = document.createElement('div');
hiddenContainer.id = 'selectedHiddenInputs';
hiddenContainer.style.display = 'none';
formEl.appendChild(hiddenContainer);
}
const selectAll = document.getElementById('select-all');
const csrfName = <?= json_encode(csrf_token()) ?>;
const csrfInput = document.querySelector(`input[name='${csrfName}']`);
// Build a meta map (userId -> { role, className }) from the DOM BEFORE DataTables paginates
const rowMeta = {};
document.querySelectorAll('#staffTable tbody tr').forEach(tr => {
const id = tr.getAttribute('data-user-id');
if (!id) return;
rowMeta[id] = {
role: tr.getAttribute('data-role-label') || '',
className: tr.getAttribute('data-class-name') || ''
};
});
const table = $('#staffTable').DataTable({
stateSave: true,
pageLength: 100,
lengthMenu: [10, 25, 50, 100],
order: [
[1, 'asc'],
[2, 'asc']
], // Firstname, Lastname
columnDefs: [{
targets: 0,
searchable: false
}, // row #
{
targets: 6,
orderable: false,
searchable: false
} // checkbox column
]
});
// Helper: keep hidden inputs in sync so selections submit even if not on current page
function syncHiddenInputs() {
hiddenContainer.innerHTML = '';
selected.forEach(function(id) {
const meta = rowMeta[id] || {
role: '',
className: ''
};
// user_ids[]
const i1 = document.createElement('input');
i1.type = 'hidden';
i1.name = 'user_ids[]';
i1.value = id;
hiddenContainer.appendChild(i1);
// roles[ID]
const i2 = document.createElement('input');
i2.type = 'hidden';
i2.name = `roles[${id}]`;
i2.value = meta.role;
hiddenContainer.appendChild(i2);
// classes[ID]
const i3 = document.createElement('input');
i3.type = 'hidden';
i3.name = `classes[${id}]`;
i3.value = meta.className;
hiddenContainer.appendChild(i3);
});
}
// Helper: refresh checkboxes on current page based on Set
function syncPageCheckboxes() {
const pageNodes = table.rows({
page: 'current'
}).nodes().to$();
let allChecked = true;
let anyChecked = false;
pageNodes.each(function() {
const row = this;
const userId = row.getAttribute('data-user-id');
const cb = row.querySelector('.user-checkbox');
if (!cb || !userId) return;
cb.checked = selected.has(userId);
if (!cb.checked) allChecked = false;
if (cb.checked) anyChecked = true;
});
// Update header select-all for current page
selectAll.checked = allChecked && pageNodes.length > 0;
selectAll.indeterminate = !allChecked && anyChecked;
}
// Helper: clear all selections and reset the current page UI
function clearSelectionsNow() {
selected.clear();
// Uncheck visible checkboxes on current page
table.rows({ page: 'current' }).every(function () {
const row = this.node();
const cb = row.querySelector('.user-checkbox');
if (cb) cb.checked = false;
});
// Reset select-all and hidden inputs
selectAll.checked = false;
selectAll.indeterminate = false;
syncHiddenInputs();
syncPageCheckboxes();
}
// On draw (paging/sort/search), just refresh checkbox UI states
table.on('draw', function() {
syncPageCheckboxes();
});
// Delegate checkbox change handling once at the table level (works across redraws)
document.getElementById('staffTable').addEventListener('change', function (e) {
const target = e.target;
if (!target || !target.classList.contains('user-checkbox')) return;
const tr = target.closest('tr[data-user-id]');
const userId = tr ? tr.getAttribute('data-user-id') : null;
if (!userId) return;
if (target.checked) selected.add(userId); else selected.delete(userId);
syncHiddenInputs();
syncPageCheckboxes();
// If user interacts, cancel any pending auto-clear to avoid wiping new selections
if (clearTimerId !== null) { clearTimeout(clearTimerId); clearTimerId = null; }
});
// Initial sync on first load
table.draw(false);
// Header "Select All (page)" toggler
selectAll.addEventListener('change', function() {
const check = this.checked;
table.rows({
page: 'current'
}).every(function() {
const row = this.node();
const userId = row.getAttribute('data-user-id');
const cb = row.querySelector('.user-checkbox');
if (!cb || !userId) return;
cb.checked = check;
if (check) selected.add(userId);
else selected.delete(userId);
});
syncHiddenInputs();
syncPageCheckboxes();
if (clearTimerId !== null) { clearTimeout(clearTimerId); clearTimerId = null; }
});
// Before submit, ensure CSRF is fresh and hidden inputs include all selections
const form = document.getElementById('badgeForm');
async function refreshCsrf() {
try {
const params = new URLSearchParams();
if (selectedYear) params.append('school_year', selectedYear);
const resp = await fetch('<?= base_url('api/printables/badges/status') ?>' + (params.toString() ? ('?' + params.toString()) : ''), {
method: 'GET',
headers: { 'Accept': 'application/json' }
});
if (!resp.ok) return;
const json = await resp.json();
if (json && json.csrf_token && json.csrf_hash && csrfInput && json.csrf_token === csrfName) {
csrfInput.value = json.csrf_hash;
}
} catch (e) {
// No-op: if refresh fails we'll still attempt submit; server may accept existing token
}
}
form.addEventListener('submit', async function(e) {
e.preventDefault();
if (selected.size === 0) {
alert('Please select at least one staff member to generate badges.');
return;
}
syncHiddenInputs();
// CSRF is excluded for this endpoint now, but keeping refresh is safe
try { await refreshCsrf(); } catch (_) {}
// Native submit so browser handles PDF in a new tab
form.submit();
// Auto-clear selections 5 seconds after generating badges
clearTimerId = setTimeout(function() { clearSelectionsNow(); clearTimerId = null; }, 5000);
});
// --- Fetch and render print status ---
const selectedYear = <?= json_encode($selectedYear ?? '') ?>;
function fetchPrintStatus() {
const ids = Object.keys(rowMeta);
if (ids.length === 0) return;
const params = new URLSearchParams();
ids.forEach(id => params.append('user_ids[]', id));
if (selectedYear) params.append('school_year', selectedYear);
fetch('<?= base_url('api/printables/badges/status') ?>?' + params.toString(), {
method: 'GET',
headers: {
'Accept': 'application/json'
}
})
.then(r => r.ok ? r.json() : Promise.reject())
.then(json => {
if (!json || !json.ok || !json.data) return;
const data = json.data;
Object.keys(rowMeta).forEach(id => {
const info = data[id];
const badge = document.querySelector(`.prints-badge[data-user-id="${id}"]`);
const tr = document.querySelector(`#staffTable tbody tr[data-user-id="${id}"]`);
if (!badge || !tr) return;
const count = info ? (info.count || 0) : 0;
badge.textContent = count > 0 ? `Printed ${count}` : '—';
badge.className = 'badge prints-badge ' + (count > 0 ? 'bg-success' : 'bg-secondary');
tr.classList.toggle('table-success', count > 0);
});
// Refresh CSRF for subsequent submissions (so no page reload is needed)
if (json.csrf_token && json.csrf_hash && csrfInput && json.csrf_token === csrfName) {
csrfInput.value = json.csrf_hash;
}
})
.catch(() => {});
}
fetchPrintStatus();
window.addEventListener('focus', fetchPrintStatus);
});
</script>
<?= $this->endSection() ?>
@@ -0,0 +1,302 @@
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<div class="container-fluid">
<div class="wrapper">
<div class="content"></div>
<div class="text-center mx-auto mb-4" style="max-width: 700px;">
<h2 class="text-dark" style="font-family: Arial, sans-serif;">Report Card Management</h2>
</div>
<div class="row g-2 align-items-center justify-content-center mb-3">
<div class="col-auto"><label for="schoolYearSelect" class="col-form-label">School year</label></div>
<div class="col-auto">
<select id="schoolYearSelect" class="form-select form-select-sm" style="min-width: 200px;"></select>
</div>
<div class="col-auto"><label for="semesterSelect" class="col-form-label">Semester</label></div>
<div class="col-auto">
<select id="semesterSelect" class="form-select form-select-sm" style="min-width: 160px;"></select>
</div>
<div class="col-auto"><label for="reportDate" class="col-form-label">Report Date</label></div>
<div class="col-auto">
<input type="date" id="reportDate" class="form-control form-control-sm" style="min-width: 170px;" />
</div>
</div>
<form id="classForm" class="mb-3" onsubmit="return false;">
<div class="row align-items-end justify-content-center g-2">
<div class="col-md-4 mb-2">
<label for="class_section_id">Select Class Section</label>
<select class="form-control" id="class_section_id"></select>
</div>
<div class="col-md-4 d-flex gap-2 mb-2 justify-content-center">
<button type="button" class="btn btn-primary" id="viewClassBtn">View All Reports</button>
<a href="#" class="btn btn-secondary external-link" id="printClassBtn" target="_blank">Print</a>
<a id="downloadClassBtn" href="#" class="btn btn-success external-link" target="_blank">Download</a>
</div>
</div>
</form>
<form id="studentForm" class="mb-3" onsubmit="return false;">
<div class="row align-items-end justify-content-center g-2">
<div class="col-md-4 mb-2">
<label for="student_id">Select Student</label>
<select class="form-control" id="student_id"></select>
</div>
<div class="col-md-4 d-flex gap-2 mb-2 justify-content-center">
<button type="button" class="btn btn-primary" id="viewStudentBtn">View Report</button>
<a href="#" class="btn btn-secondary external-link" id="printStudentBtn" target="_blank">Print</a>
<a id="downloadStudentBtn" href="#" class="btn btn-success external-link" target="_blank">Download</a>
</div>
</div>
</form>
<div class="card shadow-sm mb-3">
<div class="card-body">
<div class="d-flex flex-wrap gap-2 align-items-center justify-content-between">
<div>
<h5 class="mb-1">Report Card Completeness</h5>
<div class="text-muted small">Check grades, comments, and final scores for the selected class.</div>
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-primary btn-sm" id="checkCompletenessBtn">Check Completeness</button>
</div>
</div>
<div id="completenessStatus" class="text-muted small mt-2"></div>
<div id="completenessSummary" class="mt-2"></div>
<div id="completenessTableWrap" class="table-responsive mt-3" hidden>
<table class="table table-sm table-bordered align-middle mb-0">
<thead class="table-light">
<tr>
<th style="width: 35%;">Student</th>
<th style="width: 15%;">Status</th>
<th>Missing</th>
<th>Warnings</th>
</tr>
</thead>
<tbody id="completenessBody"></tbody>
</table>
</div>
</div>
</div>
<iframe id="studentReportFrame" style="width:100%; height:600px; border:1px solid #ccc;" hidden></iframe>
<iframe id="classReportFrame" style="width:100%; height:600px; border:1px solid #ccc;" hidden></iframe>
</div>
</div>
</div>
<?= $this->endSection() ?>
<?= $this->section('scripts') ?>
<script>
document.addEventListener('DOMContentLoaded', function(){
const API = <?= json_encode(site_url('api/printables/report-card/meta')) ?>;
const COMPLETENESS_API = <?= json_encode(site_url('api/printables/report-card/completeness')) ?>;
// add cache-busting cb param
const withCB = (u) => u + (u.includes('?') ? '&' : '?') + 'cb=' + Date.now();
const urlStudent = (id, y, s, d=false, rd=null) =>
withCB(`<?= site_url('report-card/student') ?>/${encodeURIComponent(id)}?school_year=${encodeURIComponent(y)}${s?('&semester='+encodeURIComponent(s)) : ''}${d?'&download=1':''}${rd?('&report_date='+encodeURIComponent(rd)) : ''}`);
const urlClass = (cid, y, s, d=false, rd=null) =>
withCB(`<?= site_url('report-card/class') ?>/${encodeURIComponent(cid)}?school_year=${encodeURIComponent(y)}${s?('&semester='+encodeURIComponent(s)) : ''}${d?'&download=1':''}${rd?('&report_date='+encodeURIComponent(rd)) : ''}`);
const $year = document.getElementById('schoolYearSelect');
const $student= document.getElementById('student_id');
const $class = document.getElementById('class_section_id');
const $sem = document.getElementById('semesterSelect');
const $rdate = document.getElementById('reportDate');
const $viewS = document.getElementById('viewStudentBtn');
const $viewC = document.getElementById('viewClassBtn');
const $printS = document.getElementById('printStudentBtn');
const $dlS = document.getElementById('downloadStudentBtn');
const $printC = document.getElementById('printClassBtn');
const $dlC = document.getElementById('downloadClassBtn');
const $iframeS= document.getElementById('studentReportFrame');
const $iframeC= document.getElementById('classReportFrame');
const $checkCompleteness = document.getElementById('checkCompletenessBtn');
const $completenessStatus = document.getElementById('completenessStatus');
const $completenessSummary = document.getElementById('completenessSummary');
const $completenessWrap = document.getElementById('completenessTableWrap');
const $completenessBody = document.getElementById('completenessBody');
function opt(el, val, label) {
const o = document.createElement('option'); o.value = String(val); o.textContent = label; el.appendChild(o);
}
async function loadMeta(year=null, sem=null){
let url = year ? (API + '?school_year=' + encodeURIComponent(year)) : API;
if (sem) url += (url.indexOf('?')>-1?'&':'?') + 'semester=' + encodeURIComponent(sem);
const res = await fetch(url, { credentials: 'same-origin' });
const data = await res.json();
if (!res.ok || data?.ok === false) throw new Error('Failed to load data');
// Years
$year.innerHTML = '';
(data.schoolYears || []).forEach(y => opt($year, y, y));
if (data.selectedYear) $year.value = data.selectedYear;
// Semesters
$sem.innerHTML = '';
(data.semesters || []).forEach(s => opt($sem, s, s));
if (data.selectedSemester) $sem.value = data.selectedSemester;
// Students
$student.innerHTML = '';
const students = (data.students || []);
if (students.length === 0) {
opt($student, '', 'No students found');
} else {
students.forEach(s => {
const classLabel = (s.class_section_name || '').trim();
const suffix = classLabel ? ` — ${classLabel}` : '';
opt($student, s.id, `${s.firstname} ${s.lastname}${suffix}`);
});
}
// Classes
$class.innerHTML = '';
(data.classSections || []).forEach(c => opt($class, c.class_section_id || c.id, c.class_section_name));
}
$year.addEventListener('change', function(){ loadMeta(this.value, $sem.value).catch(console.error); });
$sem.addEventListener('change', function(){ loadMeta($year.value, this.value).catch(console.error); });
$viewS.addEventListener('click', function(){
const sid = $student.value; const y = $year.value; const s=$sem.value;
const rd = $rdate.value;
if (!sid || !y) return;
$iframeS.removeAttribute('hidden');
$iframeS.src = urlStudent(sid, y, s, false, rd);
});
$viewC.addEventListener('click', function(){
const cid = $class.value; const y = $year.value; const s=$sem.value;
const rd = $rdate.value;
if (!cid || !y) return;
$iframeC.removeAttribute('hidden');
$iframeC.src = urlClass(cid, y, s, false, rd);
});
const openExternal = (el, href) => {
if (!href) return;
el.href = href;
el.setAttribute('target', '_blank');
window.open(href, '_blank', 'noopener');
};
$printS.addEventListener('click', function(e){
e.preventDefault();
const sid=$student.value, y=$year.value, s=$sem.value;
const rd = $rdate.value;
if(!sid||!y) return;
openExternal(this, urlStudent(sid,y,s,false,rd));
});
$dlS.addEventListener('click', function(e){
e.preventDefault();
const sid=$student.value, y=$year.value, s=$sem.value;
const rd = $rdate.value;
if(!sid||!y) return;
openExternal(this, urlStudent(sid,y,s,true,rd));
});
$printC.addEventListener('click', function(e){
e.preventDefault();
const cid=$class.value, y=$year.value, s=$sem.value;
const rd = $rdate.value;
if(!cid||!y) return;
openExternal(this, urlClass(cid,y,s,false,rd));
});
$dlC.addEventListener('click', function(e){
e.preventDefault();
const cid=$class.value, y=$year.value, s=$sem.value;
const rd = $rdate.value;
if(!cid||!y) return;
openExternal(this, urlClass(cid,y,s,true,rd));
});
const setCompletenessStatus = (msg, isError=false) => {
$completenessStatus.textContent = msg || '';
$completenessStatus.classList.toggle('text-danger', !!isError);
};
const renderCompleteness = (data) => {
const students = Array.isArray(data?.students) ? data.students : [];
$completenessBody.innerHTML = '';
if (students.length === 0) {
$completenessWrap.hidden = true;
$completenessSummary.textContent = 'No students found for the selected class.';
return;
}
const summary = data?.summary || {};
const total = summary.total ?? students.length;
const complete = summary.complete ?? 0;
const incomplete = summary.incomplete ?? (total - complete);
const warnings = summary.warnings ?? 0;
const note = data?.used_fallback ? ' (roster uses year-only assignments)' : '';
$completenessSummary.textContent = `Complete: ${complete}/${total} | Incomplete: ${incomplete} | With warnings: ${warnings}${note}`;
students.forEach((row) => {
const missing = Array.isArray(row.missing) ? row.missing : [];
const warningsList = Array.isArray(row.warnings) ? row.warnings : [];
const isComplete = !!row.complete;
const status = isComplete ? (warningsList.length ? 'Complete (warnings)' : 'Complete') : 'Missing data';
const tr = document.createElement('tr');
if (!isComplete) tr.classList.add('table-warning');
const tdName = document.createElement('td');
tdName.textContent = row.name || 'N/A';
const tdStatus = document.createElement('td');
tdStatus.textContent = status;
const tdMissing = document.createElement('td');
tdMissing.textContent = missing.length ? missing.join(', ') : 'None';
const tdWarnings = document.createElement('td');
tdWarnings.textContent = warningsList.length ? warningsList.join(', ') : 'None';
tr.appendChild(tdName);
tr.appendChild(tdStatus);
tr.appendChild(tdMissing);
tr.appendChild(tdWarnings);
$completenessBody.appendChild(tr);
});
$completenessWrap.hidden = false;
};
$checkCompleteness.addEventListener('click', async function(){
const cid = $class.value;
const y = $year.value;
const s = $sem.value;
if (!cid || !y) {
setCompletenessStatus('Select a class and school year first.', true);
return;
}
setCompletenessStatus('Checking completeness...');
$completenessSummary.textContent = '';
$completenessWrap.hidden = true;
$completenessBody.innerHTML = '';
try {
const url = withCB(`${COMPLETENESS_API}?class_section_id=${encodeURIComponent(cid)}&school_year=${encodeURIComponent(y)}${s?('&semester='+encodeURIComponent(s)) : ''}`);
const res = await fetch(url, { credentials: 'same-origin' });
const data = await res.json();
if (!res.ok || data?.ok === false) {
throw new Error(data?.message || 'Failed to load completeness report.');
}
setCompletenessStatus('');
renderCompleteness(data);
} catch (err) {
setCompletenessStatus(err?.message || 'Failed to load completeness report.', true);
}
});
// Default report date to today unless already set
if (!$rdate.value) {
const today = new Date();
const pad = (n) => String(n).padStart(2,'0');
$rdate.value = `${today.getFullYear()}-${pad(today.getMonth()+1)}-${pad(today.getDate())}`;
}
loadMeta('<?= esc($schoolYear ?? '') ?>','<?= esc($semester ?? '') ?>').catch(console.error);
});
</script>
<?= $this->endSection() ?>
@@ -0,0 +1,585 @@
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<div class="container-fluid">
<div class="wrapper">
<div class="content">
<h2 class="mb-4">Generate Student Stickers</h2>
<?= $this->include('partials/academic_filter') ?>
<div class="d-flex gap-2 mb-3">
<!-- Auto-count & Print All -->
<button id="btnAutoCountPrintAll" type="button" class="btn btn-primary">
<i class="bi bi-calculator"></i> Auto-count & Print All
</button>
<!-- Auto-count for selected class -->
<button id="btnAutoCountClass" type="button" class="btn btn-outline-primary">
<i class="bi bi-collection"></i> Auto-count Selected Class
</button>
</div>
<!-- Hidden form used by "Auto-count" flow -->
<form id="autoPrintForm" action="<?= base_url('stickers/generate') ?>" method="post" target="_blank" class="d-none">
<?= csrf_field() ?>
<!-- Carry the same layout fields as the visible form -->
<input type="hidden" name="sticker_size" id="ap_size">
<input type="hidden" name="sticker_width" id="ap_w">
<input type="hidden" name="sticker_height" id="ap_h">
<input type="hidden" name="stickers_per_page" id="ap_ppg">
<input type="hidden" name="page_size" id="ap_page_size">
<input type="hidden" name="orientation" id="ap_orientation">
<input type="hidden" name="margin_left" id="ap_ml">
<input type="hidden" name="margin_top" id="ap_mt">
<input type="hidden" name="margin_right" id="ap_mr">
<input type="hidden" name="margin_bottom" id="ap_mb">
<input type="hidden" name="gap_x" id="ap_gx">
<input type="hidden" name="gap_y" id="ap_gy">
<!-- Decide print mode -->
<input type="hidden" name="print_all" id="ap_print_all" value="1">
<input type="hidden" name="class_id" id="ap_class_id" value="">
<!-- copies[ID] are injected dynamically -->
</form>
<!-- Totals preview modal -->
<div class="modal fade" id="previewTotalsModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-sm modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">Sticker Totals</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="previewTotalsBody" class="small"></div>
</div>
<div class="modal-footer">
<button id="btnProceedPrint" type="button" class="btn btn-success">Proceed to Print</button>
</div>
</div>
</div>
</div>
<!-- MAIN FORM (posts to generator) -->
<form method="post" action="<?= base_url('stickers/generate') ?>" target="_blank" id="stickerForm">
<?= csrf_field() ?>
<!-- Sticker settings -->
<div class="row g-3 mb-4">
<div class="col-md-4">
<label class="form-label" for="sticker_size">Sticker Size</label>
<select name="sticker_size" id="sticker_size" class="form-select" required>
<option value="60x30">Xsmall — 100×24.5 mm</option>
<option value="60x30">Small — 60×30 mm</option>
<option value="102x34" selected>Medium — 102×34 mm</option>
<option value="90x45">Large — 90×45 mm</option>
<option value="custom">Custom (enter mm below)</option>
</select>
<div class="row g-2 mt-2" id="customSizeRow" style="display:none;">
<div class="col-6">
<input type="number" min="1" step="0.1" class="form-control" id="sticker_width" name="sticker_width" placeholder="Width (mm)">
</div>
<div class="col-6">
<input type="number" min="1" step="0.1" class="form-control" id="sticker_height" name="sticker_height" placeholder="Height (mm)">
</div>
</div>
<div class="form-text">Choose your labels physical size. Use “Custom” to type exact mm.</div>
</div>
<div class="col-md-4">
<label class="form-label" for="stickers_per_page">Stickers per Page</label>
<input type="number" min="1" max="200" value="20" class="form-control" id="stickers_per_page" name="stickers_per_page" required>
<div class="form-text">Optional cap (e.g., Avery sheets). Grid still derives from size & margins.</div>
</div>
<div class="col-md-4">
<label class="form-label" for="page_size">Paper / Orientation</label>
<div class="input-group">
<select name="page_size" id="page_size" class="form-select">
<option value="A4" selected>A4</option>
<option value="Letter">Letter</option>
</select>
<select name="orientation" id="orientation" class="form-select" style="max-width:140px;">
<option value="P" selected>Portrait</option>
<option value="L">Landscape</option>
</select>
</div>
<div class="form-text">Paper size & print orientation.</div>
</div>
</div>
<!-- Layout: margins & gaps -->
<div class="row g-3 mb-4">
<div class="col-md-6">
<label class="form-label">Margins (mm)</label>
<div class="row g-2">
<div class="col-6 col-lg-3">
<input type="number" min="0" step="0.1" class="form-control" id="margin_left" name="margin_left" value="3" placeholder="Left">
<div class="form-text">Left</div>
</div>
<div class="col-6 col-lg-3">
<input type="number" min="0" step="0.1" class="form-control" id="margin_top" name="margin_top" value="12" placeholder="Top">
<div class="form-text">Top</div>
</div>
<div class="col-6 col-lg-3">
<input type="number" min="0" step="0.1" class="form-control" id="margin_right" name="margin_right" value="3" placeholder="Right">
<div class="form-text">Right</div>
</div>
<div class="col-6 col-lg-3">
<input type="number" min="0" step="0.1" class="form-control" id="margin_bottom" name="margin_bottom" value="12" placeholder="Bottom">
<div class="form-text">Bottom</div>
</div>
</div>
</div>
<div class="col-md-6">
<label class="form-label">Gaps Between Stickers (mm)</label>
<div class="row g-2">
<div class="col-6">
<input type="number" min="0" step="0.1" class="form-control" id="gap_x" name="gap_x" value="3" placeholder="Horizontal gap">
<div class="form-text">Horizontal (X)</div>
</div>
<div class="col-6">
<input type="number" min="0" step="0.1" class="form-control" id="gap_y" name="gap_y" value="3" placeholder="Vertical gap">
<div class="form-text">Vertical (Y)</div>
</div>
</div>
</div>
</div>
<!-- Selectors -->
<div class="row g-3 align-items-end">
<!-- Class picker -->
<div class="col-md-6">
<label class="form-label" for="class_id">Select a Class</label>
<select name="class_id" id="class_id" class="form-select">
<option value="">-- Select Class --</option>
<?php foreach ($classes as $class): ?>
<option value="<?= esc($class['class_section_id']) ?>">
<?= esc($class['class_section_name']) ?>
</option>
<?php endforeach; ?>
</select>
<div class="form-text">Pick a class to load its students below.</div>
</div>
<!-- Single student picker -->
<div class="col-md-3">
<label class="form-label" for="student_id">Or Select a Specific Student (optional)</label>
<select name="student_id" id="student_id" class="form-select">
<option value="">-- None (generate by class) --</option>
<?php foreach ($students as $student): ?>
<option value="<?= esc($student['id']) ?>">
<?= esc($student['firstname'] . ' ' . $student['lastname']) ?>
</option>
<?php endforeach; ?>
</select>
<div class="form-text">Leave blank to generate stickers for the whole class.</div>
</div>
<!-- Copies input -->
<div class="col-md-3">
<label class="form-label" for="single_copies">Copies (if a student is selected)</label>
<input type="number" min="0" max="100" value="1" class="form-control" name="single_copies" id="single_copies">
<div class="form-text">Set how many stickers to print.</div>
</div>
</div>
<div class="mb-4 d-flex gap-2 align-items-center">
<button type="submit" class="btn btn-primary">Generate Stickers</button>
<!-- Optional helper to fill all quantities in the class table -->
<div class="input-group" style="max-width:260px;">
<span class="input-group-text">Set all Qty</span>
<input type="number" min="0" max="100" value="1" id="fillAllQty" class="form-control">
<button type="button" class="btn btn-outline-secondary" id="btnFillAll">Apply</button>
</div>
<small class="text-muted ms-2" id="settingsSummary"></small>
</div>
<!-- Live preview of students in the selected class -->
<div id="student-list-section" class="mt-2" style="display:none;">
<div class="table-responsive">
<table class="table table-bordered table-striped mb-0">
<thead>
<tr>
<th style="width:50%;">Name</th>
<th>Grade</th>
<th>Gender</th>
<th style="width:120px;">Qty</th>
</tr>
</thead>
<tbody id="student-list-body">
<!-- Filled dynamically -->
</tbody>
</table>
</div>
</div>
</form>
<!-- ===== Optional: Rendered Sheet (only if $stickers provided) ===== -->
<?php if (!empty($stickers) && is_array($stickers)): ?>
<style>
/* Page + print */
@page { size: A4; margin: 10mm; } /* Adjust as needed or map to page_size/orientation server-side */
:root{
--gap: 4mm; /* space between stickers */
--h: 34mm; /* default sticker height (fits 102x34 nicely) */
}
/* PDF-safe baseline (2-up) */
.sheet{ font-size:0; } /* collapse inline-block whitespace */
.sticker{
display:inline-block;
vertical-align:top;
width: calc(50% - var(--gap)/2); /* always two columns */
height: var(--h);
box-sizing:border-box;
padding:4mm;
margin:0 var(--gap) var(--gap) 0; /* right & bottom spacing */
outline:0.2mm dashed #ccc; /* cut guide (optional) */
page-break-inside:avoid;
break-inside: avoid-page;
}
.sticker:nth-child(2n){ margin-right:0; }
.sticker h4{ margin:0 0 2mm; font-size:14pt; }
.sticker p{ margin:0; font-size:11pt; }
/* Screen upgrade: grid when available (not used by PDF engines) */
@media screen {
.sheet{
display:grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--gap);
font-size: initial;
}
.sticker{
display:block;
width:auto;
margin:0;
}
}
</style>
<div class="sheet mt-4">
<?php foreach ($stickers as $s):
$title = $s['title'] ?? '';
$line1 = $s['line1'] ?? '';
$line2 = $s['line2'] ?? '';
?>
<div class="sticker">
<?php if ($title !== ''): ?><h4><?= esc($title) ?></h4><?php endif; ?>
<?php if ($line1 !== ''): ?><p><?= esc($line1) ?></p><?php endif; ?>
<?php if ($line2 !== ''): ?><p><?= esc($line2) ?></p><?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<!-- ===== End optional sheet ===== -->
</div>
</div>
</div>
<?= $this->endSection() ?>
<?= $this->section('scripts') ?>
<script>
(function() {
const classSelect = document.getElementById('class_id');
const studentSelect = document.getElementById('student_id');
const listSection = document.getElementById('student-list-section');
const listBody = document.getElementById('student-list-body');
const sizeSelect = document.getElementById('sticker_size');
const customSizeRow = document.getElementById('customSizeRow');
const inW = document.getElementById('sticker_width');
const inH = document.getElementById('sticker_height');
const perPageInput = document.getElementById('stickers_per_page');
const pageSize = document.getElementById('page_size');
const orientation = document.getElementById('orientation');
const mL = document.getElementById('margin_left');
const mT = document.getElementById('margin_top');
const mR = document.getElementById('margin_right');
const mB = document.getElementById('margin_bottom');
const gX = document.getElementById('gap_x');
const gY = document.getElementById('gap_y');
const settingsSummary = document.getElementById('settingsSummary');
// Keep student vs class mutually exclusive
classSelect.addEventListener('change', () => {
if (classSelect.value) {
studentSelect.value = '';
}
});
studentSelect.addEventListener('change', () => {
if (studentSelect.value) {
classSelect.value = '';
listSection.style.display = 'none';
listBody.innerHTML = '';
updateSettingsSummary();
}
});
// Custom size toggling
function toggleCustomSize() {
const isCustom = sizeSelect.value === 'custom';
customSizeRow.style.display = isCustom ? '' : 'none';
if (!isCustom) {
inW.value = '';
inH.value = '';
}
}
sizeSelect.addEventListener('change', () => { toggleCustomSize(); updateSettingsSummary(); });
toggleCustomSize();
function updateSettingsSummary() {
const sizeText = (sizeSelect.value === 'custom' && inW.value && inH.value)
? `Custom ${inW.value}×${inH.value} mm`
: sizeSelect.options[sizeSelect.selectedIndex].textContent;
const per = `${perPageInput.value} per page`;
const paper = `${pageSize.value} ${orientation.value === 'P' ? 'Portrait' : 'Landscape'}`;
const margins = `margins L:${mL.value} T:${mT.value} R:${mR.value} B:${mB.value} mm`;
const gaps = `gaps X:${gX.value} Y:${gY.value} mm`;
settingsSummary.textContent = `Layout: ${sizeText}, ${per}, ${paper}, ${margins}, ${gaps}`;
}
[perPageInput, pageSize, orientation, mL, mT, mR, mB, gX, gY].forEach(el => {
el.addEventListener('input', updateSettingsSummary);
el.addEventListener('change', updateSettingsSummary);
});
updateSettingsSummary();
// Load students for class
classSelect.addEventListener('change', function() {
const classId = this.value;
listBody.innerHTML = '';
listSection.style.display = 'none';
if (!classId) return;
fetch('<?= base_url('api/students/by_class') ?>/' + encodeURIComponent(classId))
.then(res => res.json())
.then(data => {
if (!Array.isArray(data) || data.length === 0) {
listSection.style.display = 'block';
listBody.innerHTML = '<tr><td colspan="4" class="text-center text-muted">No students found for this class.</td></tr>';
updateSettingsSummary();
return;
}
const fragRows = document.createDocumentFragment();
data.forEach(stu => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${escapeHtml(stu.firstname)} ${escapeHtml(stu.lastname)}</td>
<td>${stu.registration_grade ?? ''}</td>
<td>${stu.gender ?? ''}</td>
<td>
<input type="number"
class="form-control form-control-sm"
name="copies[${stu.id}]"
min="0" max="100" value="1"
aria-label="Quantity for ${escapeHtml(stu.firstname)} ${escapeHtml(stu.lastname)}">
</td>
`;
fragRows.appendChild(tr);
});
listBody.appendChild(fragRows);
listSection.style.display = 'block';
updateSettingsSummary();
})
.catch(() => {
listSection.style.display = 'block';
listBody.innerHTML = '<tr><td colspan="4" class="text-center text-danger">Failed to load students. Please try again.</td></tr>';
});
});
// "Set all Qty" helper
document.getElementById('btnFillAll').addEventListener('click', () => {
const v = parseInt(document.getElementById('fillAllQty').value || '1', 10);
listBody.querySelectorAll('input[name^="copies["]').forEach(inp => {
inp.value = Math.max(0, Math.min(100, v));
});
});
function escapeHtml(s) {
return String(s)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
})();
</script>
<!-- Auto-count scripting -->
<script>
(() => {
const btnAll = document.getElementById('btnAutoCountPrintAll');
const btnClass = document.getElementById('btnAutoCountClass');
const form = document.getElementById('autoPrintForm');
// Hidden fields in auto form
const apSize = document.getElementById('ap_size');
const apW = document.getElementById('ap_w');
const apH = document.getElementById('ap_h');
const apPPG = document.getElementById('ap_ppg');
const apAll = document.getElementById('ap_print_all');
const apClass = document.getElementById('ap_class_id');
const apPage = document.getElementById('ap_page_size');
const apOrient = document.getElementById('ap_orientation');
const apML = document.getElementById('ap_ml');
const apMT = document.getElementById('ap_mt');
const apMR = document.getElementById('ap_mr');
const apMB = document.getElementById('ap_mb');
const apGX = document.getElementById('ap_gx');
const apGY = document.getElementById('ap_gy');
// Visible inputs to mirror
const inSize = document.getElementById('sticker_size');
const inW = document.getElementById('sticker_width');
const inH = document.getElementById('sticker_height');
const inPPG = document.getElementById('stickers_per_page');
const selClass = document.getElementById('class_id');
const inPage = document.getElementById('page_size');
const inOrient = document.getElementById('orientation');
const inML = document.getElementById('margin_left');
const inMT = document.getElementById('margin_top');
const inMR = document.getElementById('margin_right');
const inMB = document.getElementById('margin_bottom');
const inGX = document.getElementById('gap_x');
const inGY = document.getElementById('gap_y');
const totalsModal = new bootstrap.Modal(document.getElementById('previewTotalsModal'));
const totalsBody = document.getElementById('previewTotalsBody');
const btnProceed = document.getElementById('btnProceedPrint');
async function fetchCounts(classId = null) {
const url = new URL('<?= base_url('stickers/preview') ?>', window.location.origin);
if (classId) url.searchParams.set('class_id', classId);
const res = await fetch(url.toString(), {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
if (!res.ok) throw new Error('Preview failed: ' + res.status);
const json = await res.json();
if (json.status !== 'ok') throw new Error('Preview failed: bad status');
return json.data; // { students:[{student_id, primary_count}], totals:{stickers, students} }
}
function carryLayoutToHidden() {
// Size / custom size
apSize.value = inSize ? inSize.value : '';
apW.value = (inSize.value === 'custom' && inW.value) ? inW.value : '';
apH.value = (inSize.value === 'custom' && inH.value) ? inH.value : '';
// Cap per page
apPPG.value = inPPG ? (inPPG.value || '') : '';
// Paper & orientation
apPage.value = inPage ? inPage.value : 'A4';
apOrient.value = inOrient ? inOrient.value : 'P';
// Margins & gaps
apML.value = inML ? inML.value : '6';
apMT.value = inMT ? inMT.value : '6';
apMR.value = inMR ? inMR.value : apML.value;
apMB.value = inMB ? inMB.value : '10';
apGX.value = inGX ? inGX.value : '0';
apGY.value = inGY ? inGY.value : '0';
}
function fillHiddenCopies(students) {
// Clean previous copies
[...form.querySelectorAll('input[name^="copies["]')].forEach(n => n.remove());
// Use primary_count only
for (const s of students) {
const copies = Number(s.primary_count) || 0;
if (copies <= 0) continue;
const inp = document.createElement('input');
inp.type = 'hidden';
inp.name = `copies[${s.student_id}]`;
inp.value = String(copies);
form.appendChild(inp);
}
}
function showTotals(data) {
let totalStickers = data?.totals?.stickers;
if (typeof totalStickers !== 'number') {
totalStickers = (data?.students ?? []).reduce(
(sum, s) => sum + (Number(s.primary_count) || 0), 0
);
}
const ns = data?.totals?.students ?? (data?.students?.length ?? 0);
totalsBody.innerHTML =
`<div><strong>Students:</strong> ${ns}</div>` +
`<div><strong>Total stickers:</strong> ${totalStickers}</div>`;
totalsModal.show();
}
// Print All
btnAll?.addEventListener('click', async () => {
btnAll.disabled = true;
try {
const data = await fetchCounts(null); // all students
showTotals(data);
fillHiddenCopies(data.students);
carryLayoutToHidden();
apClass.value = '';
apAll.value = '1';
} catch (e) {
alert(e.message || e);
} finally {
btnAll.disabled = false;
}
});
// Print Selected Class
btnClass?.addEventListener('click', async () => {
const classId = selClass && selClass.value ? Number(selClass.value) : 0;
if (!classId) {
alert('Please select a class first.');
return;
}
btnClass.disabled = true;
try {
const data = await fetchCounts(classId);
showTotals(data);
fillHiddenCopies(data.students);
carryLayoutToHidden();
apClass.value = String(classId);
apAll.value = '0';
} catch (e) {
alert(e.message || e);
} finally {
btnClass.disabled = false;
}
});
// Proceed to print (submits hidden form; opens in new tab)
btnProceed?.addEventListener('click', () => {
totalsModal.hide();
form.submit();
});
})();
</script>
<?= $this->endSection() ?>