Files
2026-02-10 22:11:06 -05:00

692 lines
28 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use CodeIgniter\HTTP\ResponseInterface;
use App\Models\UserModel;
use App\Models\LateSlipLogModel;
use App\Models\ConfigurationModel;
use Mike42\Escpos\PrintConnectors\WindowsPrintConnector;
// ESC/POS
use Mike42\Escpos\Printer;
use Mike42\Escpos\EscposImage;
use Mike42\Escpos\PrintConnectors\NetworkPrintConnector;
use Mike42\Escpos\PrintConnectors\UsbPrintConnector;
use Dompdf\Dompdf;
use Dompdf\Options;
class SlipPrinterController extends BaseController
{
protected $configModel;
protected $semester;
protected $schoolYear;
public function __construct()
{
helper(['form', 'url']);
$this->configModel = new ConfigurationModel();
$this->semester = $this->configModel->getConfig('semester');
$this->schoolYear = $this->configModel->getConfig('school_year');
}
/**
* POST /slips/print
* Expected fields (POST): school_year, student_name, date, time_in, grade, reason, admin_name
* You can wire this to your own form/data source; all fields are strings.
*/
public function print(): ResponseInterface
{
$req = $this->request;
$data = [
'school_year' => trim((string) $req->getVar('school_year') ?: ''),
'student_name' => trim((string) $req->getVar('student_name') ?: ''),
'date' => trim((string) $req->getVar('date') ?: date('m/d/Y')),
'time_in' => trim((string) $req->getVar('time_in') ?: date('h:i A')),
'grade' => trim((string) $req->getVar('grade') ?: ''),
'reason' => trim((string) $req->getVar('reason') ?: ''),
'admin_name' => trim((string) $req->getVar('admin_name') ?: ''),
];
// Always prefer current logged-in user's full name from DB
$adminFromDb = $this->currentAdminName();
if ($adminFromDb !== '') {
$data['admin_name'] = $adminFromDb;
}
// Validate minimal fields
if ($data['student_name'] === '') {
return $this->response->setStatusCode(422)->setJSON(['error' => 'Student name is required.']);
}
try {
// Persist a log (best-effort) before generating PDF
try {
$printedBy = (int) (session()->get('user_id') ?? 0) ?: null;
$cfg = new ConfigurationModel();
$semester = (string)($cfg->getConfig('semester') ?? '');
$logData = [
'school_year' => $data['school_year'],
'semester' => $semester,
'student_name' => $data['student_name'],
'slip_date' => $this->toDbDate($data['date']),
'time_in' => $this->toDbTime($data['time_in']),
'grade' => $data['grade'],
'reason' => $data['reason'],
'admin_name' => $data['admin_name'],
];
(new LateSlipLogModel())->logSlip($logData, $printedBy);
} catch (\Throwable $e) {
log_message('error', 'Late slip log insert failed: ' . $e->getMessage());
}
// Generate and stream a PDF instead of direct printer output
return $this->renderSlipPdfResponse($data);
} catch (\Throwable $e) {
log_message('error', 'Late slip PDF generation failed: ' . $e->getMessage());
return $this->response->setStatusCode(500)->setJSON([
'error' => 'Printing failed.',
'details' => $e->getMessage(),
]);
}
}
/**
* Show or print preview of late slips.
*/
public function preview(): ResponseInterface
{
$req = $this->request;
try {
// ---- Step 1: Resolve current school year ----
$currentYear = trim((string) ($this->schoolYear ?? ''));
if ($currentYear === '') {
$latest = (new LateSlipLogModel())
->select('school_year')
->orderBy('id', 'DESC')
->first();
$currentYear = $latest['school_year'] ?? (date('Y') . '-' . (date('Y') + 1));
}
$selectedYear = trim((string) $req->getVar('school_year') ?: $currentYear);
$selectedSemester = strtolower(trim((string) $req->getVar('semester') ?: ''));
$data = [
'school_year' => $selectedYear,
'student_name' => trim((string) $req->getVar('student_name') ?: ''),
'date' => trim((string) $req->getVar('date') ?: date('m/d/Y')),
'time_in' => trim((string) $req->getVar('time_in') ?: date('h:i A')),
'grade' => trim((string) $req->getVar('grade') ?: ''),
'reason' => trim((string) $req->getVar('reason') ?: ''),
'admin_name' => trim((string) $req->getVar('admin_name') ?: ''),
];
// Prefer logged-in admin name
$adminFromDb = $this->currentAdminName();
if ($adminFromDb !== '') {
$data['admin_name'] = $adminFromDb;
}
// ---- Handle GET (HTML preview) ----
if (strtolower($req->getMethod()) === 'get') {
$rows = [];
$logModel = new LateSlipLogModel();
$builder = $logModel;
// Filter by school year if not empty
if ($selectedYear !== '') {
$builder->where('school_year', $selectedYear);
}
// Normalize and apply semester filter
$hasFilter = false;
if ($selectedSemester !== '') {
$hasFilter = true;
if ($selectedSemester === 'fall') {
$builder->groupStart()
->where('semester', 'fall')
->orWhere('semester', '1')
->orWhere('semester', 1)
->groupEnd();
} elseif ($selectedSemester === 'spring') {
$builder->groupStart()
->where('semester', 'spring')
->orWhere('semester', '2')
->orWhere('semester', 2)
->groupEnd();
}
}
// ---- Execute query ----
$logs = $builder->orderBy('id', 'DESC')->findAll(50);
// 🟢 Only use fallback when *no filters are selected*
if (empty($logs) && !$hasFilter && $selectedYear === '') {
$logs = (new LateSlipLogModel())->orderBy('id', 'DESC')->findAll(50);
}
// ---- Format rows ----
foreach ($logs as $r) {
$dispDate = '';
$slipDateRaw = trim((string)($r['slip_date'] ?? ''));
if ($slipDateRaw !== '' && $slipDateRaw !== '0000-00-00') {
$ts = strtotime($slipDateRaw);
$dispDate = $ts ? date('m/d/Y', $ts) : '';
}
if ($dispDate === '') {
$printedAt = trim((string)($r['printed_at'] ?? ''));
if ($printedAt !== '') {
try {
$dispDate = local_date($printedAt, 'm/d/Y');
} catch (\Throwable $e) {
$ts = strtotime($printedAt);
$dispDate = $ts ? date('m/d/Y', $ts) : '';
}
}
}
$dispTime = '';
if (!empty($r['time_in'])) {
$t = strtotime($r['time_in']) ?: strtotime('today ' . $r['time_in']);
$dispTime = $t ? date('h:i A', $t) : '';
}
$rows[] = [
'school_year' => (string)($r['school_year'] ?? ''),
'semester' => (string)($r['semester'] ?? ''),
'student_name' => (string)($r['student_name'] ?? ''),
'date' => $dispDate,
'time_in' => $dispTime,
'grade' => (string)($r['grade'] ?? ''),
'reason' => (string)($r['reason'] ?? ''),
'admin_name' => (string)($r['admin_name'] ?? ''),
];
}
// ---- Render the view ----
$html = view('slips/preview_list', [
'rows' => $rows,
'school_year' => $selectedYear,
'semester' => $selectedSemester,
]);
return $this->response
->setStatusCode(200)
->setHeader('Content-Type', 'text/html; charset=UTF-8')
->setBody($html);
}
// ---- POST mode (JSON preview for printer) ----
$cfg = $this->printerConfig();
$lineWidth = (int) ($cfg['chars_per_line'] ?? 48);
$feedLines = (int) ($cfg['feed_lines'] ?? 3);
$header = "Al Rahma School at ISGL\nSchool Year: {$data['school_year']}\n\n";
$body = implode("\n", $this->buildSlipLines($data, $lineWidth));
$footer = str_repeat("\n", $feedLines);
$full = $header . $body . $footer;
return $this->response->setJSON([
'ok' => true,
'text' => $full,
'width' => $lineWidth,
]);
} catch (\Throwable $e) {
log_message('error', 'Late slip preview failed: ' . $e->getMessage());
return $this->response->setStatusCode(500)->setJSON([
'ok' => false,
'error' => 'Preview failed.',
'details' => $e->getMessage(),
]);
}
}
/**
* POST /administrator/late_slips/reprint/{id}
* Reprint a late slip from a saved log row, then redirect back with status.
*/
public function reprint($id = null): ResponseInterface
{
$id = (int) ($id ?? 0);
if ($id <= 0) {
return redirect()->back()->with('error', 'Invalid slip id.');
}
$row = null;
try {
$row = (new LateSlipLogModel())->find($id);
} catch (\Throwable $e) {
$row = null;
}
if (!$row) {
return redirect()->back()->with('error', 'Slip not found.');
}
// Map DB row to print payload
$dateDisplay = '';
if (!empty($row['slip_date'])) {
$ts = strtotime($row['slip_date']);
$dateDisplay = $ts ? date('m/d/Y', $ts) : '';
}
if ($dateDisplay === '') $dateDisplay = date('m/d/Y');
$timeDisplay = '';
if (!empty($row['time_in'])) {
$ts = strtotime($row['time_in']);
if ($ts === false) {
// Try combining with today
$ts = strtotime('today ' . $row['time_in']);
}
$timeDisplay = $ts ? date('h:i A', $ts) : '';
}
if ($timeDisplay === '') $timeDisplay = date('h:i A');
$data = [
'school_year' => (string) ($row['school_year'] ?? ''),
'student_name' => (string) ($row['student_name'] ?? ''),
'date' => $dateDisplay,
'time_in' => $timeDisplay,
'grade' => (string) ($row['grade'] ?? ''),
'reason' => (string) ($row['reason'] ?? ''),
'admin_name' => (string) ($row['admin_name'] ?? ''),
];
// Always prefer current logged-in user's full name from DB
$adminFromDb = $this->currentAdminName();
if ($adminFromDb !== '') {
$data['admin_name'] = $adminFromDb;
}
try {
// Format lines and print
$lines = $this->buildSlipLines($data);
$printer = $this->makePrinter();
$printer->setJustification(Printer::JUSTIFY_CENTER);
$printer->setEmphasis(true);
$printer->text("Al Rahma School at ISGL\n");
$printer->setEmphasis(false);
$printer->text("School Year: {$data['school_year']}\n");
$printer->feed();
$printer->setJustification(Printer::JUSTIFY_LEFT);
foreach ($lines as $ln) {
$printer->text($ln . "\n");
}
$printer->feed((int) ($this->printerConfig()['feed_lines'] ?? 3));
$printer->cut();
$printer->close();
// Log again (best-effort) for audit trail
try {
$printedBy = (int) (session()->get('user_id') ?? 0) ?: null;
$cfg = new ConfigurationModel();
$semester = (string)($cfg->getConfig('semester') ?? '');
$logData = [
'school_year' => $data['school_year'],
'semester' => $semester,
'student_name' => $data['student_name'],
'slip_date' => $this->toDbDate($data['date']),
'time_in' => $this->toDbTime($data['time_in']),
'grade' => $data['grade'],
'reason' => $data['reason'],
'admin_name' => $data['admin_name'],
];
(new LateSlipLogModel())->logSlip($logData, $printedBy);
} catch (\Throwable $e) {
log_message('error', 'Late slip reprint log insert failed: ' . $e->getMessage());
}
return redirect()->back()->with('success', 'Late slip sent to printer.');
} catch (\Throwable $e) {
return redirect()->back()->with('error', 'Reprint failed: ' . $e->getMessage());
}
}
/**
* Build monospaced slip lines for an 80mm (typically 48 chars) receipt.
*
* Layout required:
* Al Rahma School at ISGL "SchoolYear"
* Student Name ___________________________
* Date ______________ Time In ______________
* Grade _________________________________
* Reason _________________________________
* _______________________________________
* Admin Name ____________________________
*/
private function buildSlipLines(array $data, ?int $lineWidth = null): array
{
// Choose a sane default for 58mm rolls (~2432 chars). Fall back to env config.
$cfgWidth = (int) ($this->printerConfig()['chars_per_line'] ?? 0);
$w = $lineWidth ?? ($cfgWidth > 0 ? $cfgWidth : 32);
$lines = [];
$lines[] = $this->fitLine('Student Name: ' . (string)($data['student_name'] ?? ''), $w);
$lines[] = $this->fitLine('Date: ' . (string)($data['date'] ?? '') . ' ' . 'Time In: ' . (string)($data['time_in'] ?? ''), $w);
$lines[] = $this->fitLine('Grade: ' . (string)($data['grade'] ?? ''), $w);
$lines[] = $this->fitLine('Reason: ' . (string)($data['reason'] ?? ''), $w);
$lines[] = $this->fitLine('Admin: ' . (string)($data['admin_name'] ?? ''), $w);
return $lines;
}
/**
* Create and return a configured Mike42\Escpos\Printer instance.
*/
private function makePrinter(): Printer
{
$cfg = $this->printerConfig();
$mode = strtolower((string) ($cfg['mode'] ?? 'network'));
if ($mode === 'windows') {
if (stripos(PHP_OS_FAMILY, 'Windows') !== false) {
$name = (string) ($cfg['windows_name'] ?? 'POS-80');
$connector = new \Mike42\Escpos\PrintConnectors\WindowsPrintConnector($name);
return new Printer($connector);
}
log_message('warning', 'PRINTER_MODE=windows on non-Windows OS; falling back to network.');
$mode = 'network';
}
if ($mode === 'usb') {
if (stripos(PHP_OS_FAMILY, 'Windows') !== false) {
$name = (string) ($cfg['windows_name'] ?? 'POS-80');
$connector = new \Mike42\Escpos\PrintConnectors\WindowsPrintConnector($name);
return new Printer($connector);
}
$vidStr = (string) ($cfg['usb_vid'] ?? '');
$pidStr = (string) ($cfg['usb_pid'] ?? '');
if ($vidStr === '' || $pidStr === '') {
throw new \RuntimeException('USB mode requires PRINTER_USB_VID and PRINTER_USB_PID');
}
$vid = $this->hexToInt($vidStr);
$pid = $this->hexToInt($pidStr);
$connector = new \Mike42\Escpos\PrintConnectors\UsbPrintConnector($vid, $pid);
return new Printer($connector);
}
// Default: network
$host = (string) ($cfg['host'] ?? '192.168.1.100');
$port = (int) ($cfg['port'] ?? 9100);
$connector = new \Mike42\Escpos\PrintConnectors\NetworkPrintConnector($host, $port);
return new Printer($connector);
}
/**
* Build a "Label: ________VALUE" style line, using underscores to fill remaining space.
* If $value is empty, just render a long blank line after the label.
*/
private function labelWithLine(string $label, string $value, int $width, ?int $fillCount = null): string
{
$label = rtrim($label, ':') . ': ';
$maxFill = $fillCount ?? max(0, $width - strlen($label));
$filled = $value !== ''
? $this->fixedField($value, $maxFill, '_')
: str_repeat('_', $maxFill);
$line = $label . $filled;
// Ensure total width, pad or trim
return $this->fitLine($line, $width);
}
/**
* Create a single field like "Date: ________" (or with value).
*/
private function fieldWithBlanks(string $label, string $value, int $fieldWidth): string
{
$label = rtrim($label, ':') . ': ';
$fill = max(0, $fieldWidth - strlen($label));
$content = $value !== '' ? $this->fixedField($value, $fill, '_') : str_repeat('_', $fill);
return $this->fitLine($label . $content, $fieldWidth);
}
/**
* Join two fixed-width fields into one line (e.g., Date field + Time In field).
*/
private function joinTwoFields(string $left, string $right, int $totalWidth): string
{
$gap = ' ';
$line = $left . $gap . $right;
return $this->fitLine($line, $totalWidth);
}
/**
* Pad/truncate a value to an exact width using a fill character (for underlines).
*/
private function fixedField(string $val, int $width, string $fillChar = '_'): string
{
$val = (string) $val;
if (strlen($val) > $width) {
return substr($val, 0, $width);
}
return $val . str_repeat($fillChar, max(0, $width - strlen($val)));
}
/**
* Ensure any line is exactly $width characters (trim or pad spaces).
*/
private function fitLine(string $line, int $width): string
{
$line = (string) $line;
if (strlen($line) > $width) {
return substr($line, 0, $width);
}
return $line . str_repeat(' ', $width - strlen($line));
}
/**
* "0x1a86" -> 0x1a86 (int). Accepts plain decimal too.
*/
private function hexToInt(string $hexOrDec): int
{
$hexOrDec = trim($hexOrDec);
if ($hexOrDec === '') return 0;
if (stripos($hexOrDec, '0x') === 0) {
return intval($hexOrDec, 16);
}
return (int) $hexOrDec;
}
/**
* Consolidated printer configuration. Supports both dotted keys (printer.*)
* and uppercase PRINTER_* keys from .env.
*/
private function printerConfig(): array
{
$get = function ($keys, $default = null) {
$keys = is_array($keys) ? $keys : [$keys];
foreach ($keys as $k) {
$v = env($k);
if ($v !== null && $v !== '') return $v;
}
return $default;
};
return [
'mode' => strtolower((string) $get(['printer.mode', 'PRINTER_MODE'], 'network')),
'host' => (string) $get(['printer.host', 'PRINTER_HOST'], '192.168.1.100'),
'port' => (int) $get(['printer.port', 'PRINTER_PORT'], 9100),
'usb_vid' => (string) $get(['printer.usb.vid', 'PRINTER_USB_VID'], ''),
'usb_pid' => (string) $get(['printer.usb.pid', 'PRINTER_USB_PID'], ''),
'windows_name' => (string) $get(['printer.windows.name', 'PRINTER_WINDOWS_NAME'], 'POS-80'),
'chars_per_line' => (int) $get(['printer.chars_per_line', 'PRINTER_CHARS_PER_LINE'], 48),
'feed_lines' => (int) $get(['printer.feed_lines', 'PRINTER_FEED_LINES'], 3),
];
}
/**
* Convert user-provided date/time strings into DB formats.
*/
private function toDbDate(?string $s): ?string
{
$s = trim((string)$s);
if ($s === '') return null;
// Explicitly parse common US formats to avoid day/month swaps.
if (preg_match('/^\\d{4}-\\d{2}-\\d{2}$/', $s)) {
return $s;
}
if (preg_match('/^\\d{1,2}\\/\\d{1,2}\\/\\d{4}$/', $s)) {
$dt = \DateTime::createFromFormat('!m/d/Y', $s);
return $dt ? $dt->format('Y-m-d') : null;
}
if (preg_match('/^\\d{1,2}-\\d{1,2}-\\d{4}$/', $s)) {
$dt = \DateTime::createFromFormat('!m-d-Y', $s);
return $dt ? $dt->format('Y-m-d') : null;
}
$ts = strtotime($s);
return $ts ? date('Y-m-d', $ts) : null;
}
private function toDbTime(?string $s): ?string
{
$s = trim((string)$s);
if ($s === '') return null;
$ts = strtotime($s);
return $ts ? date('H:i:s', $ts) : null;
}
/**
* Compute current admin full name using session user_id and users table.
* Falls back to session firstname/lastname, then user_name, else ''.
*/
private function currentAdminName(): string
{
try {
$uid = (int) (session()->get('user_id') ?? 0);
if ($uid > 0) {
$user = (new UserModel())
->select('firstname, lastname')
->find($uid);
if ($user) {
$full = trim((string)($user['firstname'] ?? '') . ' ' . (string)($user['lastname'] ?? ''));
if ($full !== '') return $full;
}
}
} catch (\Throwable $e) {
// ignore and fall back
}
$first = trim((string)(session()->get('firstname') ?? ''));
$last = trim((string)(session()->get('lastname') ?? ''));
$full = trim($first . ' ' . $last);
if ($full !== '') return $full;
return (string)(session()->get('user_name') ?? '');
}
/**
* Render the slip as a PDF and stream it inline to the browser.
*/
private function renderSlipPdfResponse(array $data): ResponseInterface
{
// Build HTML from a dedicated view
// Determine paper size (mm)
$paper = strtolower((string) ($this->request->getVar('paper') ?: env('SLIP_PAPER', 'card')));
// Estimate dynamic height based on the PDF view font and line-height
// Title + seven content lines = 8 rows total
// Defaults match the current view: 13pt font, 2.0 line-height
$fontPt = (float) ($this->request->getVar('font_pt') ?? env('SLIP_FONT_PT', 13.0));
$lineH = (float) ($this->request->getVar('line_h') ?? env('SLIP_LINE_H', 2.0));
$linesCount = (int) ($this->request->getVar('rows') ?? 8);
if ($linesCount < 1) {
$linesCount = 8;
}
$ptPerLine = $fontPt * $lineH;
$mmPerPt = 25.4 / 72.0; // 1pt in mm
$contentMm = $ptPerLine * $linesCount * $mmPerPt; // content only
$pageMarginsMm = 3.0 * 2; // top+bottom from CSS
// Allow caller to increase safety buffer if their viewer/driver adds spacing
// Slightly bigger safety buffer to avoid second-page spillovers from drivers
$fudgeMm = (float) ($this->request->getVar('fudge_mm') ?? env('SLIP_FUDGE_MM', 12.0));
$dynHeightMm = $contentMm + $pageMarginsMm + $fudgeMm;
switch ($paper) {
case 'cardp':
case 'card-portrait':
$wMm = 53.98;
$hMm = max($dynHeightMm, 40.0); // portrait width, dynamic height
$wChars = 30;
break;
case 'receipt80':
$wMm = 72.0;
$hMm = 90.0; // 80mm roll (printable ~72mm) — taller to fit larger font
$wChars = 30;
break;
case 'receipt58':
$wMm = 58.0;
$hMm = 80.0; // 58mm roll — taller to fit larger font
$wChars = 24;
break;
case 'card':
default:
$wMm = 85.60;
$hMm = max($dynHeightMm, 40.0); // credit card width, dynamic height
$wChars = 40;
}
// Optional manual override via query: height_mm (or h)
$hOverride = $this->request->getVar('height_mm') ?? $this->request->getVar('h');
if (is_numeric($hOverride)) {
$hMm = max(30.0, min(200.0, (float) $hOverride));
}
// Build exactly six lines in monospace formatting
$w = $wChars; // characters per line for layout
$lines = [];
$lines[] = $this->fitLine('Al Rahma School at ISGL ' . ($data['school_year'] ?? ''), $w);
// Helper to compose "Label: value_____" without truncating value first
$compose = function (string $label, string $value, int $padUnderscore = 0) use ($w) {
$prefix = rtrim($label, ':') . ': ';
$line = $prefix . $value;
$len = strlen($line);
if ($padUnderscore > 0 && $len < $w) {
$line .= str_repeat('_', min($padUnderscore, $w - $len));
}
return $this->fitLine($line, $w);
};
// Student Name (no trailing underscores)
$lines[] = $compose('Student Name', (string)($data['student_name'] ?? ''), 0);
// Date and Time In on separate lines
$lines[] = $this->fitLine('Date: ' . (string)($data['date'] ?? ''), $w);
$lines[] = $this->fitLine('Time In: ' . (string)($data['time_in'] ?? ''), $w);
// Grade and Reason (no trailing underscores)
$lines[] = $compose('Grade', (string)($data['grade'] ?? ''), 0);
$lines[] = $compose('Reason', (string)($data['reason'] ?? ''), 0);
// Admin: do not underline, just value
$lines[] = $this->fitLine('Admin: ' . (string)($data['admin_name'] ?? ''), $w);
$html = view('slips/slip_pdf', ['entry' => $data, 'lines' => $lines, 'page' => ['w_mm' => $wMm, 'h_mm' => $hMm]]);
$options = new Options();
$options->set('isRemoteEnabled', true);
$options->set('defaultFont', 'Courier');
$dompdf = new Dompdf($options);
$dompdf->loadHtml($html, 'UTF-8');
// Ensure minimum height is enough for current content calculation
if ($hMm < $dynHeightMm) {
$hMm = $dynHeightMm;
}
// Set paper size from selected mm values -> points
$mmToPt = 72 / 25.4;
$wPt = $wMm * $mmToPt;
$hPt = $hMm * $mmToPt;
$dompdf->setPaper([0, 0, $wPt, $hPt]);
$dompdf->render();
$filename = 'LateSlip_' . preg_replace('/[^A-Za-z0-9]+/', '_', (string)($data['student_name'] ?? 'slip')) . '_' . date('Ymd_His') . '.pdf';
return $this->response
->setHeader('Content-Type', 'application/pdf')
->setHeader('Content-Disposition', 'inline; filename="' . $filename . '"')
->setBody($dompdf->output());
}
}