Initial commit
293
src/admin/activities.php
Normal file
@@ -0,0 +1,293 @@
|
||||
<?php
|
||||
require __DIR__ . '/../includes/bootstrap.php';
|
||||
require_login();
|
||||
|
||||
$pdo = db();
|
||||
$errors = [];
|
||||
|
||||
$courses = $pdo->query('SELECT id, name FROM courses WHERE status = 1 ORDER BY name')->fetchAll();
|
||||
$departments = $pdo->query('SELECT id, name FROM departments WHERE status = 1 ORDER BY name')->fetchAll();
|
||||
|
||||
if (is_post()) {
|
||||
if (!validate_csrf_token($_POST['csrf_token'] ?? null)) {
|
||||
$errors[] = 'Invalid session token.';
|
||||
} else {
|
||||
$action = $_POST['action'] ?? 'create';
|
||||
|
||||
if ($action === 'delete') {
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
$pdo->prepare('DELETE FROM activities WHERE id = :id')->execute(['id' => $id]);
|
||||
add_flash('success', 'Activity removed.');
|
||||
redirect('admin/activities.php');
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'name' => trim($_POST['name'] ?? ''),
|
||||
'date' => $_POST['date'] ?? null,
|
||||
'time_in' => $_POST['time_in'] ?? null,
|
||||
'time_out' => $_POST['time_out'] ?? null,
|
||||
'location' => trim($_POST['location'] ?? ''),
|
||||
'required_students' => $_POST['required_students'] ?? 'all',
|
||||
'course_id' => $_POST['required_students'] === 'specific_course' ? (int) ($_POST['course_id'] ?? 0) : null,
|
||||
'department_id' => $_POST['required_students'] === 'specific_department' ? (int) ($_POST['department_id'] ?? 0) : null,
|
||||
'description' => trim($_POST['description'] ?? ''),
|
||||
'status' => (int) ($_POST['status'] ?? 1),
|
||||
'created_by' => current_user()['id'],
|
||||
];
|
||||
|
||||
if ($payload['name'] === '' || !$payload['date']) {
|
||||
$errors[] = 'Activity name and date are required.';
|
||||
}
|
||||
|
||||
if (!$errors) {
|
||||
if ($action === 'update') {
|
||||
$updatePayload = $payload;
|
||||
$updatePayload['id'] = (int) ($_POST['id'] ?? 0);
|
||||
unset($updatePayload['created_by']);
|
||||
$stmt = $pdo->prepare(
|
||||
'UPDATE activities SET name = :name, date = :date, time_in = :time_in, time_out = :time_out,
|
||||
location = :location, required_students = :required_students, course_id = :course_id,
|
||||
department_id = :department_id, description = :description, status = :status
|
||||
WHERE id = :id'
|
||||
);
|
||||
$stmt->execute($updatePayload);
|
||||
add_flash('success', 'Activity updated.');
|
||||
} else {
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO activities (name, date, time_in, time_out, location, required_students,
|
||||
course_id, department_id, description, status, created_by)
|
||||
VALUES (:name, :date, :time_in, :time_out, :location, :required_students,
|
||||
:course_id, :department_id, :description, :status, :created_by)'
|
||||
);
|
||||
$stmt->execute($payload);
|
||||
add_flash('success', 'Activity scheduled.');
|
||||
}
|
||||
|
||||
redirect('admin/activities.php');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$activities = $pdo->query(
|
||||
'SELECT a.*, c.name AS course_name, d.name AS department_name, u.full_name AS creator
|
||||
FROM activities a
|
||||
LEFT JOIN courses c ON a.course_id = c.id
|
||||
LEFT JOIN departments d ON a.department_id = d.id
|
||||
LEFT JOIN users u ON a.created_by = u.id
|
||||
ORDER BY a.date DESC, a.time_in DESC'
|
||||
)->fetchAll();
|
||||
|
||||
render_header('Activities', ['active' => 'activities']);
|
||||
?>
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-white d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h5 class="mb-0">Activities</h5>
|
||||
<small class="text-muted"><?= count($activities) ?> scheduled</small>
|
||||
</div>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<?php if ($errors): ?>
|
||||
<span class="text-danger small"><?= sanitize(implode(' ', $errors)) ?></span>
|
||||
<?php endif; ?>
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#activityModal" data-mode="create">
|
||||
Schedule Activity
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<?php if (!$activities): ?>
|
||||
<p class="text-muted mb-0 text-center">No activities scheduled.</p>
|
||||
<?php else: ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Date</th>
|
||||
<th>Audience</th>
|
||||
<th>Location</th>
|
||||
<th>Status</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($activities as $activity): ?>
|
||||
<?php
|
||||
$recordPayload = encode_record([
|
||||
'id' => (int) $activity['id'],
|
||||
'name' => $activity['name'],
|
||||
'date' => $activity['date'],
|
||||
'time_in' => $activity['time_in'],
|
||||
'time_out' => $activity['time_out'],
|
||||
'location' => $activity['location'],
|
||||
'required_students' => $activity['required_students'],
|
||||
'course_id' => $activity['course_id'],
|
||||
'department_id' => $activity['department_id'],
|
||||
'description' => $activity['description'],
|
||||
'status' => (int) $activity['status'],
|
||||
]);
|
||||
$timeIn = $activity['time_in'] !== null ? substr($activity['time_in'], 0, 5) : '—';
|
||||
$timeOut = $activity['time_out'] !== null ? substr($activity['time_out'], 0, 5) : '—';
|
||||
?>
|
||||
<tr>
|
||||
<td>
|
||||
<strong><?= sanitize($activity['name']) ?></strong><br>
|
||||
<span class="text-muted-sm">Created by <?= sanitize($activity['creator'] ?? 'N/A') ?></span>
|
||||
</td>
|
||||
<td>
|
||||
<?= format_date($activity['date']) ?><br>
|
||||
<span class="text-muted-sm"><?= sanitize($timeIn) ?> — <?= sanitize($timeOut) ?></span>
|
||||
</td>
|
||||
<td>
|
||||
<?php
|
||||
$audience = match ($activity['required_students']) {
|
||||
'specific_course' => $activity['course_name'] ?? 'Course',
|
||||
'specific_department' => $activity['department_name'] ?? 'Department',
|
||||
default => 'All Students',
|
||||
};
|
||||
?>
|
||||
<?= sanitize($audience) ?>
|
||||
</td>
|
||||
<td><?= sanitize($activity['location']) ?></td>
|
||||
<td>
|
||||
<span class="badge <?= $activity['status'] ? 'bg-success-subtle text-success' : 'bg-secondary' ?>">
|
||||
<?= $activity['status'] ? 'Active' : 'Inactive' ?>
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<button class="btn btn-sm btn-outline-primary"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#activityModal"
|
||||
data-mode="edit"
|
||||
data-record="<?= sanitize($recordPayload) ?>">
|
||||
Edit
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#deleteActivityModal"
|
||||
data-record-name="<?= sanitize($activity['name']) ?>"
|
||||
data-record-ref="<?= sanitize(format_date($activity['date'])) ?>"
|
||||
data-id="<?= (int) $activity['id'] ?>">
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Activity modal -->
|
||||
<div class="modal fade" id="activityModal" tabindex="-1" aria-hidden="true"
|
||||
data-create-title="Schedule Activity" data-edit-title="Edit Activity"
|
||||
data-create-submit="Schedule Activity" data-edit-submit="Save Changes">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<form method="post" class="needs-validation" novalidate>
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" data-modal-title>Schedule Activity</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="csrf_token" value="<?= sanitize(ensure_csrf_token()) ?>">
|
||||
<input type="hidden" name="action" value="create">
|
||||
<input type="hidden" name="id" value="">
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label">Name</label>
|
||||
<input type="text" name="name" class="form-control" required>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Date</label>
|
||||
<input type="date" name="date" class="form-control" required>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Time In</label>
|
||||
<input type="time" name="time_in" class="form-control">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Time Out</label>
|
||||
<input type="time" name="time_out" class="form-control">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Location</label>
|
||||
<input type="text" name="location" class="form-control">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Required Participants</label>
|
||||
<select name="required_students" class="form-select">
|
||||
<option value="all" selected>All Students</option>
|
||||
<option value="specific_course">Specific Course</option>
|
||||
<option value="specific_department">Specific Department</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Course Filter</label>
|
||||
<select name="course_id" class="form-select">
|
||||
<option value="" selected>—</option>
|
||||
<?php foreach ($courses as $course): ?>
|
||||
<option value="<?= $course['id'] ?>"><?= sanitize($course['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Department Filter</label>
|
||||
<select name="department_id" class="form-select">
|
||||
<option value="" selected>—</option>
|
||||
<?php foreach ($departments as $department): ?>
|
||||
<option value="<?= $department['id'] ?>"><?= sanitize($department['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea name="description" class="form-control" rows="2"></textarea>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Status</label>
|
||||
<select name="status" class="form-select">
|
||||
<option value="1" selected>Active</option>
|
||||
<option value="0">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Schedule Activity</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete confirmation modal -->
|
||||
<div class="modal fade" id="deleteActivityModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form method="post">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Delete Activity</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="csrf_token" value="<?= sanitize(ensure_csrf_token()) ?>">
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="id" value="">
|
||||
<p class="mb-1">Are you sure you want to delete this activity?</p>
|
||||
<p class="fw-semibold mb-0" data-delete-name>Activity Name</p>
|
||||
<small class="text-muted" data-delete-id data-label="Date">Date: —</small>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-danger">Delete</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
render_footer();
|
||||
131
src/admin/attendance.php
Normal file
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
require __DIR__ . '/../includes/bootstrap.php';
|
||||
require_login();
|
||||
require_admin();
|
||||
|
||||
$pdo = db();
|
||||
|
||||
$activities = $pdo->query(
|
||||
'SELECT id, name, date, time_in, time_out
|
||||
FROM activities
|
||||
WHERE status = 1 AND date >= CURDATE() - INTERVAL 1 DAY
|
||||
ORDER BY date DESC, time_in DESC'
|
||||
)->fetchAll();
|
||||
$hasActivities = !empty($activities);
|
||||
|
||||
$todayAttendance = $pdo->query(
|
||||
'SELECT att.*, s.full_name, s.student_id, act.name AS activity_name
|
||||
FROM attendance att
|
||||
INNER JOIN students s ON att.student_id = s.id
|
||||
INNER JOIN activities act ON att.activity_id = act.id
|
||||
WHERE DATE(att.time_in) = CURDATE()
|
||||
ORDER BY att.time_in DESC'
|
||||
)->fetchAll();
|
||||
|
||||
render_header('Scan Center', ['active' => 'attendance']);
|
||||
?>
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-5">
|
||||
<div class="scan-panel h-100">
|
||||
<h2>Admin Scan Center</h2>
|
||||
<p class="mb-4 text-white-50">Only signed-in administrators can scan QR codes. Use the camera or type the token below.</p>
|
||||
<?php if (!$hasActivities): ?>
|
||||
<div class="alert alert-warning bg-warning-subtle text-dark">
|
||||
No active activities available. Create one first to start scanning.
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<form data-scan-form data-endpoint="<?= url_for('api/scan.php') ?>" class="v-stack gap-3">
|
||||
<div>
|
||||
<label class="form-label text-white">Activity</label>
|
||||
<select name="activity_id" class="form-select form-select-lg" <?= $hasActivities ? '' : 'disabled' ?> required>
|
||||
<?php if ($hasActivities): ?>
|
||||
<?php foreach ($activities as $activity): ?>
|
||||
<option value="<?= $activity['id'] ?>">
|
||||
<?= sanitize($activity['name']) ?> — <?= format_date($activity['date'], 'M d') ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
<?php else: ?>
|
||||
<option value="">No activities</option>
|
||||
<?php endif; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label text-white">QR Token / Student ID</label>
|
||||
<input type="text" name="qr_token" class="form-control form-control-lg"
|
||||
placeholder="Scan or type token" autocomplete="off" <?= $hasActivities ? '' : 'disabled' ?> required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label text-white">Notes (optional)</label>
|
||||
<textarea name="notes" class="form-control" rows="2" placeholder="Remarks for this scan"></textarea>
|
||||
</div>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<button type="submit" class="btn btn-light btn-lg flex-grow-1" <?= $hasActivities ? '' : 'disabled' ?>>Record Attendance</button>
|
||||
<button type="button" class="btn btn-outline-light btn-lg flex-grow-1" data-scan-start <?= $hasActivities ? '' : 'disabled' ?>>
|
||||
Start Camera Scan
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-white-50 small mb-0">
|
||||
The scanner auto-selects the best camera mode and falls back to a built-in detector if needed.
|
||||
</p>
|
||||
</form>
|
||||
<div class="mt-4">
|
||||
<p id="scan-status" class="fw-semibold mb-3"></p>
|
||||
<div id="camera-viewer" class="camera-viewer bg-white rounded shadow-sm">
|
||||
<div class="text-center text-muted small">Camera idle</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-7">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header bg-white d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">Today’s Attendance</h5>
|
||||
<span class="badge bg-primary-subtle text-primary"><?= count($todayAttendance) ?> captured</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="scan-result" class="mb-4"></div>
|
||||
<?php if (!$todayAttendance): ?>
|
||||
<p class="text-muted text-center mb-0">No scans recorded today.</p>
|
||||
<?php else: ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Student</th>
|
||||
<th>Activity</th>
|
||||
<th>Time In</th>
|
||||
<th>Time Out</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($todayAttendance as $row): ?>
|
||||
<tr>
|
||||
<td>
|
||||
<strong><?= sanitize($row['full_name']) ?></strong><br>
|
||||
<span class="text-muted-sm"><?= sanitize($row['student_id']) ?></span>
|
||||
</td>
|
||||
<td><?= sanitize($row['activity_name']) ?></td>
|
||||
<td><?= format_datetime($row['time_in'], 'h:i A') ?></td>
|
||||
<td><?= format_datetime($row['time_out'], 'h:i A') ?></td>
|
||||
<td>
|
||||
<span class="badge bg-success-subtle text-success badge-status">
|
||||
<?= strtoupper(sanitize($row['status'])) ?>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
render_footer([
|
||||
'extra_js' => [
|
||||
'https://unpkg.com/html5-qrcode@2.3.10/html5-qrcode.min.js',
|
||||
],
|
||||
]);
|
||||
223
src/admin/courses.php
Normal file
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
require __DIR__ . '/../includes/bootstrap.php';
|
||||
require_login();
|
||||
|
||||
$pdo = db();
|
||||
$errors = [];
|
||||
$departments = $pdo->query('SELECT id, name FROM departments WHERE status = 1 ORDER BY name')->fetchAll();
|
||||
|
||||
if (is_post()) {
|
||||
if (!validate_csrf_token($_POST['csrf_token'] ?? null)) {
|
||||
$errors[] = 'Invalid session token.';
|
||||
} else {
|
||||
$action = $_POST['action'] ?? 'create';
|
||||
|
||||
if ($action === 'delete') {
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
$pdo->prepare('DELETE FROM courses WHERE id = :id')->execute(['id' => $id]);
|
||||
add_flash('success', 'Course removed.');
|
||||
redirect('admin/courses.php');
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'code' => strtoupper(trim($_POST['code'] ?? '')),
|
||||
'name' => trim($_POST['name'] ?? ''),
|
||||
'department_id' => (int) ($_POST['department_id'] ?? 0),
|
||||
'description' => trim($_POST['description'] ?? ''),
|
||||
'status' => (int) ($_POST['status'] ?? 1),
|
||||
];
|
||||
|
||||
if ($payload['code'] === '' || $payload['name'] === '') {
|
||||
$errors[] = 'Course code and name are required.';
|
||||
}
|
||||
|
||||
if (!$errors) {
|
||||
if ($action === 'update') {
|
||||
$payload['id'] = (int) ($_POST['id'] ?? 0);
|
||||
$stmt = $pdo->prepare(
|
||||
'UPDATE courses SET code = :code, name = :name, department_id = :department_id,
|
||||
description = :description, status = :status WHERE id = :id'
|
||||
);
|
||||
$stmt->execute($payload);
|
||||
add_flash('success', 'Course updated.');
|
||||
} else {
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO courses (code, name, department_id, description, status)
|
||||
VALUES (:code, :name, :department_id, :description, :status)'
|
||||
);
|
||||
$stmt->execute($payload);
|
||||
add_flash('success', 'Course added.');
|
||||
}
|
||||
|
||||
redirect('admin/courses.php');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$courses = $pdo->query(
|
||||
'SELECT c.*, d.name AS department_name
|
||||
FROM courses c
|
||||
LEFT JOIN departments d ON c.department_id = d.id
|
||||
ORDER BY c.name ASC'
|
||||
)->fetchAll();
|
||||
|
||||
render_header('Courses', ['active' => 'courses']);
|
||||
?>
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-white d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h5 class="mb-0">Courses</h5>
|
||||
<small class="text-muted"><?= count($courses) ?> total</small>
|
||||
</div>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<?php if ($errors): ?>
|
||||
<span class="text-danger small"><?= sanitize(implode(' ', $errors)) ?></span>
|
||||
<?php endif; ?>
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#courseModal" data-mode="create">
|
||||
Add Course
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<?php if (!$courses): ?>
|
||||
<p class="text-muted mb-0 text-center">No courses yet.</p>
|
||||
<?php else: ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Code</th>
|
||||
<th>Name</th>
|
||||
<th>Department</th>
|
||||
<th>Status</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($courses as $course): ?>
|
||||
<?php
|
||||
$recordPayload = encode_record([
|
||||
'id' => (int) $course['id'],
|
||||
'code' => $course['code'],
|
||||
'name' => $course['name'],
|
||||
'department_id' => (int) ($course['department_id'] ?? 0),
|
||||
'description' => $course['description'],
|
||||
'status' => (int) $course['status'],
|
||||
]);
|
||||
?>
|
||||
<tr>
|
||||
<td><code><?= sanitize($course['code']) ?></code></td>
|
||||
<td><?= sanitize($course['name']) ?></td>
|
||||
<td><?= sanitize($course['department_name'] ?? '—') ?></td>
|
||||
<td>
|
||||
<span class="badge <?= $course['status'] ? 'bg-success-subtle text-success' : 'bg-secondary' ?>">
|
||||
<?= $course['status'] ? 'Active' : 'Inactive' ?>
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<button class="btn btn-sm btn-outline-primary"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#courseModal"
|
||||
data-mode="edit"
|
||||
data-record="<?= sanitize($recordPayload) ?>">
|
||||
Edit
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#deleteCourseModal"
|
||||
data-record-name="<?= sanitize($course['name']) ?>"
|
||||
data-record-ref="<?= sanitize($course['code']) ?>"
|
||||
data-id="<?= (int) $course['id'] ?>">
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Course modal -->
|
||||
<div class="modal fade" id="courseModal" tabindex="-1" aria-hidden="true"
|
||||
data-create-title="Add Course" data-edit-title="Edit Course"
|
||||
data-create-submit="Add Course" data-edit-submit="Save Changes">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<form method="post" class="needs-validation" novalidate>
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" data-modal-title>Add Course</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="csrf_token" value="<?= sanitize(ensure_csrf_token()) ?>">
|
||||
<input type="hidden" name="action" value="create">
|
||||
<input type="hidden" name="id" value="">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Code</label>
|
||||
<input type="text" name="code" class="form-control text-uppercase" required>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Name</label>
|
||||
<input type="text" name="name" class="form-control" required>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Department</label>
|
||||
<select name="department_id" class="form-select">
|
||||
<?php foreach ($departments as $department): ?>
|
||||
<option value="<?= $department['id'] ?>"><?= sanitize($department['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea name="description" class="form-control" rows="2"></textarea>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Status</label>
|
||||
<select name="status" class="form-select">
|
||||
<option value="1" selected>Active</option>
|
||||
<option value="0">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Add Course</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete modal -->
|
||||
<div class="modal fade" id="deleteCourseModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form method="post">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Delete Course</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="csrf_token" value="<?= sanitize(ensure_csrf_token()) ?>">
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="id" value="">
|
||||
<p class="mb-1" data-delete-message>Delete this course?</p>
|
||||
<p class="fw-semibold mb-0" data-delete-name>Course Name</p>
|
||||
<small class="text-muted" data-delete-id data-label="Code">Code: —</small>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-danger">Delete</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
render_footer();
|
||||
148
src/admin/dashboard.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
require __DIR__ . '/../includes/bootstrap.php';
|
||||
require_login();
|
||||
|
||||
$pdo = db();
|
||||
|
||||
$stats = [
|
||||
'students' => (int) $pdo->query('SELECT COUNT(*) FROM students')->fetchColumn(),
|
||||
'activities' => (int) $pdo->query('SELECT COUNT(*) FROM activities WHERE status = 1')->fetchColumn(),
|
||||
'attendance_today' => (int) $pdo->query('SELECT COUNT(*) FROM attendance WHERE DATE(time_in) = CURDATE()')->fetchColumn(),
|
||||
'users' => (int) $pdo->query('SELECT COUNT(*) FROM users WHERE status = 1')->fetchColumn(),
|
||||
];
|
||||
|
||||
$upcomingActivities = $pdo->query(
|
||||
'SELECT a.*, d.name AS department_name, c.name AS course_name
|
||||
FROM activities a
|
||||
LEFT JOIN departments d ON a.department_id = d.id
|
||||
LEFT JOIN courses c ON a.course_id = c.id
|
||||
WHERE a.date >= CURDATE()
|
||||
ORDER BY a.date ASC, a.time_in ASC
|
||||
LIMIT 5'
|
||||
)->fetchAll();
|
||||
|
||||
$recentAttendance = $pdo->query(
|
||||
'SELECT att.*, s.full_name, act.name AS activity_name
|
||||
FROM attendance att
|
||||
INNER JOIN students s ON att.student_id = s.id
|
||||
INNER JOIN activities act ON att.activity_id = act.id
|
||||
ORDER BY att.updated_at DESC
|
||||
LIMIT 10'
|
||||
)->fetchAll();
|
||||
|
||||
render_header('Dashboard', ['active' => 'dashboard']);
|
||||
?>
|
||||
<div class="row g-4">
|
||||
<div class="col-md-3">
|
||||
<div class="stat-card">
|
||||
<h6>Registered Students</h6>
|
||||
<p class="stat-value"><?= $stats['students'] ?></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="stat-card">
|
||||
<h6>Active Activities</h6>
|
||||
<p class="stat-value"><?= $stats['activities'] ?></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="stat-card">
|
||||
<h6>Attendance (Today)</h6>
|
||||
<p class="stat-value"><?= $stats['attendance_today'] ?></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="stat-card">
|
||||
<h6>Active Users</h6>
|
||||
<p class="stat-value"><?= $stats['users'] ?></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-4 g-4">
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">Upcoming Activities</h5>
|
||||
<a href="<?= url_for('admin/activities.php') ?>" class="btn btn-sm btn-outline-primary">Manage</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<?php if (!$upcomingActivities): ?>
|
||||
<p class="text-muted text-center mb-0">No scheduled activities yet.</p>
|
||||
<?php else: ?>
|
||||
<div class="list-group list-group-flush">
|
||||
<?php foreach ($upcomingActivities as $activity): ?>
|
||||
<div class="list-group-item">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<h6 class="mb-1"><?= sanitize($activity['name']) ?></h6>
|
||||
<div class="text-muted-sm">
|
||||
<?= format_date($activity['date']) ?> •
|
||||
<?= sanitize(substr($activity['time_in'], 0, 5)) ?>
|
||||
—
|
||||
<?= sanitize(substr($activity['time_out'], 0, 5)) ?>
|
||||
</div>
|
||||
<?php if ($activity['required_students'] !== 'all'): ?>
|
||||
<span class="chip mt-2">
|
||||
<?= $activity['required_students'] === 'specific_course'
|
||||
? sanitize($activity['course_name'] ?? 'Course Restricted')
|
||||
: sanitize($activity['department_name'] ?? 'Department Restricted') ?>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<span class="badge rounded-pill bg-primary-subtle text-primary align-self-start">
|
||||
<?= sanitize($activity['location']) ?>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h5 class="mb-0">Latest Attendance Logs</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<?php if (!$recentAttendance): ?>
|
||||
<p class="text-muted text-center mb-0">No attendance captured yet.</p>
|
||||
<?php else: ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Student</th>
|
||||
<th>Activity</th>
|
||||
<th>Status</th>
|
||||
<th>Recorded</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($recentAttendance as $row): ?>
|
||||
<tr>
|
||||
<td><?= sanitize($row['full_name']) ?></td>
|
||||
<td><?= sanitize($row['activity_name']) ?></td>
|
||||
<td>
|
||||
<span class="badge bg-success-subtle text-success badge-status">
|
||||
<?= strtoupper(sanitize($row['status'])) ?>
|
||||
</span>
|
||||
</td>
|
||||
<td><?= format_datetime($row['updated_at']) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
render_footer();
|
||||
223
src/admin/departments.php
Normal file
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
require __DIR__ . '/../includes/bootstrap.php';
|
||||
require_login();
|
||||
|
||||
$pdo = db();
|
||||
$errors = [];
|
||||
$schools = $pdo->query('SELECT id, name FROM schools WHERE status = 1 ORDER BY name')->fetchAll();
|
||||
|
||||
if (is_post()) {
|
||||
if (!validate_csrf_token($_POST['csrf_token'] ?? null)) {
|
||||
$errors[] = 'Invalid session token.';
|
||||
} else {
|
||||
$action = $_POST['action'] ?? 'create';
|
||||
|
||||
if ($action === 'delete') {
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
$pdo->prepare('DELETE FROM departments WHERE id = :id')->execute(['id' => $id]);
|
||||
add_flash('success', 'Department removed.');
|
||||
redirect('admin/departments.php');
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'code' => strtoupper(trim($_POST['code'] ?? '')),
|
||||
'name' => trim($_POST['name'] ?? ''),
|
||||
'school_id' => (int) ($_POST['school_id'] ?? 0),
|
||||
'description' => trim($_POST['description'] ?? ''),
|
||||
'status' => (int) ($_POST['status'] ?? 1),
|
||||
];
|
||||
|
||||
if ($payload['code'] === '' || $payload['name'] === '') {
|
||||
$errors[] = 'Department code and name are required.';
|
||||
}
|
||||
|
||||
if (!$errors) {
|
||||
if ($action === 'update') {
|
||||
$payload['id'] = (int) ($_POST['id'] ?? 0);
|
||||
$stmt = $pdo->prepare(
|
||||
'UPDATE departments SET code = :code, name = :name, school_id = :school_id,
|
||||
description = :description, status = :status WHERE id = :id'
|
||||
);
|
||||
$stmt->execute($payload);
|
||||
add_flash('success', 'Department updated.');
|
||||
} else {
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO departments (code, name, school_id, description, status)
|
||||
VALUES (:code, :name, :school_id, :description, :status)'
|
||||
);
|
||||
$stmt->execute($payload);
|
||||
add_flash('success', 'Department added.');
|
||||
}
|
||||
|
||||
redirect('admin/departments.php');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$departments = $pdo->query(
|
||||
'SELECT d.*, s.name AS school_name
|
||||
FROM departments d
|
||||
LEFT JOIN schools s ON d.school_id = s.id
|
||||
ORDER BY d.name ASC'
|
||||
)->fetchAll();
|
||||
|
||||
render_header('Departments', ['active' => 'departments']);
|
||||
?>
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-white d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h5 class="mb-0">Departments</h5>
|
||||
<small class="text-muted"><?= count($departments) ?> total</small>
|
||||
</div>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<?php if ($errors): ?>
|
||||
<span class="text-danger small"><?= sanitize(implode(' ', $errors)) ?></span>
|
||||
<?php endif; ?>
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#departmentModal" data-mode="create">
|
||||
Add Department
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<?php if (!$departments): ?>
|
||||
<p class="text-muted mb-0 text-center">No departments yet.</p>
|
||||
<?php else: ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Code</th>
|
||||
<th>Name</th>
|
||||
<th>School</th>
|
||||
<th>Status</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($departments as $department): ?>
|
||||
<?php
|
||||
$recordPayload = encode_record([
|
||||
'id' => (int) $department['id'],
|
||||
'code' => $department['code'],
|
||||
'name' => $department['name'],
|
||||
'school_id' => (int) ($department['school_id'] ?? 0),
|
||||
'description' => $department['description'],
|
||||
'status' => (int) $department['status'],
|
||||
]);
|
||||
?>
|
||||
<tr>
|
||||
<td><code><?= sanitize($department['code']) ?></code></td>
|
||||
<td><?= sanitize($department['name']) ?></td>
|
||||
<td><?= sanitize($department['school_name'] ?? '—') ?></td>
|
||||
<td>
|
||||
<span class="badge <?= $department['status'] ? 'bg-success-subtle text-success' : 'bg-secondary' ?>">
|
||||
<?= $department['status'] ? 'Active' : 'Inactive' ?>
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<button class="btn btn-sm btn-outline-primary"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#departmentModal"
|
||||
data-mode="edit"
|
||||
data-record="<?= sanitize($recordPayload) ?>">
|
||||
Edit
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#deleteDepartmentModal"
|
||||
data-record-name="<?= sanitize($department['name']) ?>"
|
||||
data-record-ref="<?= sanitize($department['code']) ?>"
|
||||
data-id="<?= (int) $department['id'] ?>">
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Department modal -->
|
||||
<div class="modal fade" id="departmentModal" tabindex="-1" aria-hidden="true"
|
||||
data-create-title="Add Department" data-edit-title="Edit Department"
|
||||
data-create-submit="Add Department" data-edit-submit="Save Changes">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<form method="post" class="needs-validation" novalidate>
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" data-modal-title>Add Department</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="csrf_token" value="<?= sanitize(ensure_csrf_token()) ?>">
|
||||
<input type="hidden" name="action" value="create">
|
||||
<input type="hidden" name="id" value="">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Code</label>
|
||||
<input type="text" name="code" class="form-control text-uppercase" required>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Name</label>
|
||||
<input type="text" name="name" class="form-control" required>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">School</label>
|
||||
<select name="school_id" class="form-select">
|
||||
<?php foreach ($schools as $school): ?>
|
||||
<option value="<?= $school['id'] ?>"><?= sanitize($school['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea name="description" class="form-control" rows="2"></textarea>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Status</label>
|
||||
<select name="status" class="form-select">
|
||||
<option value="1" selected>Active</option>
|
||||
<option value="0">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Add Department</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete modal -->
|
||||
<div class="modal fade" id="deleteDepartmentModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form method="post">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Delete Department</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="csrf_token" value="<?= sanitize(ensure_csrf_token()) ?>">
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="id" value="">
|
||||
<p class="mb-1" data-delete-message>Delete this department?</p>
|
||||
<p class="fw-semibold mb-0" data-delete-name>Department Name</p>
|
||||
<small class="text-muted" data-delete-id data-label="Code">Code: —</small>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-danger">Delete</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
render_footer();
|
||||
146
src/admin/reports.php
Normal file
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
require __DIR__ . '/../includes/bootstrap.php';
|
||||
require_login();
|
||||
|
||||
$pdo = db();
|
||||
$activities = $pdo->query('SELECT id, name FROM activities ORDER BY date DESC')->fetchAll();
|
||||
|
||||
$filters = [
|
||||
'start_date' => $_GET['start_date'] ?? date('Y-m-01'),
|
||||
'end_date' => $_GET['end_date'] ?? date('Y-m-d'),
|
||||
'activity_id' => $_GET['activity_id'] ?? '',
|
||||
'status' => $_GET['status'] ?? '',
|
||||
];
|
||||
|
||||
$conditions = [];
|
||||
$params = [];
|
||||
|
||||
if ($filters['start_date']) {
|
||||
$conditions[] = 'DATE(att.time_in) >= :start_date';
|
||||
$params['start_date'] = $filters['start_date'];
|
||||
}
|
||||
|
||||
if ($filters['end_date']) {
|
||||
$conditions[] = 'DATE(att.time_in) <= :end_date';
|
||||
$params['end_date'] = $filters['end_date'];
|
||||
}
|
||||
|
||||
if ($filters['activity_id']) {
|
||||
$conditions[] = 'att.activity_id = :activity_id';
|
||||
$params['activity_id'] = (int) $filters['activity_id'];
|
||||
}
|
||||
|
||||
if ($filters['status']) {
|
||||
$conditions[] = 'att.status = :status';
|
||||
$params['status'] = $filters['status'];
|
||||
}
|
||||
|
||||
$where = $conditions ? 'WHERE ' . implode(' AND ', $conditions) : '';
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT att.*, s.full_name, s.student_id, act.name AS activity_name
|
||||
FROM attendance att
|
||||
INNER JOIN students s ON att.student_id = s.id
|
||||
INNER JOIN activities act ON att.activity_id = act.id
|
||||
$where
|
||||
ORDER BY att.time_in DESC"
|
||||
);
|
||||
$stmt->execute($params);
|
||||
$records = $stmt->fetchAll();
|
||||
|
||||
render_header('Reports', ['active' => 'reports']);
|
||||
?>
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header bg-white">
|
||||
<h5 class="mb-0">Attendance Filters</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="get" class="row gy-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Start Date</label>
|
||||
<input type="date" name="start_date" class="form-control"
|
||||
value="<?= sanitize($filters['start_date']) ?>">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">End Date</label>
|
||||
<input type="date" name="end_date" class="form-control"
|
||||
value="<?= sanitize($filters['end_date']) ?>">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Activity</label>
|
||||
<select name="activity_id" class="form-select">
|
||||
<option value="">All activities</option>
|
||||
<?php foreach ($activities as $activity): ?>
|
||||
<option value="<?= $activity['id'] ?>" <?= selected($filters['activity_id'], $activity['id']) ?>>
|
||||
<?= sanitize($activity['name']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Status</label>
|
||||
<select name="status" class="form-select">
|
||||
<option value="">Any status</option>
|
||||
<option value="present" <?= selected($filters['status'], 'present') ?>>Present</option>
|
||||
<option value="late" <?= selected($filters['status'], 'late') ?>>Late</option>
|
||||
<option value="absent" <?= selected($filters['status'], 'absent') ?>>Absent</option>
|
||||
<option value="excused" <?= selected($filters['status'], 'excused') ?>>Excused</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-1 d-flex align-items-end">
|
||||
<button type="submit" class="btn btn-primary w-100">Apply</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-white d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">Attendance Records</h5>
|
||||
<a href="<?= url_for('api/manual_entry.php?'.http_build_query($filters)) ?>" class="btn btn-sm btn-outline-secondary" target="_blank">
|
||||
Export JSON
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<?php if (!$records): ?>
|
||||
<p class="text-muted text-center mb-0">No records match the filters.</p>
|
||||
<?php else: ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Student</th>
|
||||
<th>Activity</th>
|
||||
<th>Status</th>
|
||||
<th>In</th>
|
||||
<th>Out</th>
|
||||
<th>Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($records as $row): ?>
|
||||
<tr>
|
||||
<td>
|
||||
<strong><?= sanitize($row['full_name']) ?></strong><br>
|
||||
<span class="text-muted-sm"><?= sanitize($row['student_id']) ?></span>
|
||||
</td>
|
||||
<td><?= sanitize($row['activity_name']) ?></td>
|
||||
<td>
|
||||
<span class="badge <?= $row['status'] === 'present'
|
||||
? 'bg-success-subtle text-success'
|
||||
: 'bg-warning-subtle text-warning' ?>">
|
||||
<?= strtoupper(sanitize($row['status'])) ?>
|
||||
</span>
|
||||
</td>
|
||||
<td><?= format_datetime($row['time_in']) ?></td>
|
||||
<td><?= format_datetime($row['time_out']) ?></td>
|
||||
<td><?= sanitize($row['notes'] ?? '') ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
render_footer();
|
||||
163
src/admin/settings.php
Normal file
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
require __DIR__ . '/../includes/bootstrap.php';
|
||||
require_login();
|
||||
require_admin();
|
||||
|
||||
$pdo = db();
|
||||
$errors = [];
|
||||
$userId = current_user()['id'];
|
||||
|
||||
if (is_post()) {
|
||||
if (!validate_csrf_token($_POST['csrf_token'] ?? null)) {
|
||||
$errors[] = 'Invalid session token.';
|
||||
} else {
|
||||
$action = $_POST['action'] ?? 'save';
|
||||
|
||||
if ($action === 'save') {
|
||||
$settings = $_POST['settings'] ?? [];
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO system_settings (setting_key, setting_value, description, updated_by)
|
||||
VALUES (:key, :value, :description, :updated_by)
|
||||
ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), description = VALUES(description),
|
||||
updated_by = VALUES(updated_by), updated_at = CURRENT_TIMESTAMP'
|
||||
);
|
||||
foreach ($settings as $key => $data) {
|
||||
$stmt->execute([
|
||||
'key' => $key,
|
||||
'value' => $data['value'] ?? '',
|
||||
'description' => $data['description'] ?? '',
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
}
|
||||
add_flash('success', 'Settings updated.');
|
||||
redirect('admin/settings.php');
|
||||
}
|
||||
|
||||
if ($action === 'create') {
|
||||
$key = strtolower(trim($_POST['new_key'] ?? ''));
|
||||
$value = trim($_POST['new_value'] ?? '');
|
||||
$description = trim($_POST['new_description'] ?? '');
|
||||
|
||||
if ($key === '') {
|
||||
$errors[] = 'Setting key is required.';
|
||||
} else {
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO system_settings (setting_key, setting_value, description, updated_by)
|
||||
VALUES (:key, :value, :description, :updated_by)'
|
||||
);
|
||||
$stmt->execute([
|
||||
'key' => $key,
|
||||
'value' => $value,
|
||||
'description' => $description,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
add_flash('success', 'Setting created.');
|
||||
redirect('admin/settings.php');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$settings = $pdo->query(
|
||||
'SELECT ss.*, u.full_name AS updated_by_name
|
||||
FROM system_settings ss
|
||||
LEFT JOIN users u ON ss.updated_by = u.id
|
||||
ORDER BY ss.setting_key ASC'
|
||||
)->fetchAll();
|
||||
|
||||
render_header('Settings', ['active' => 'settings']);
|
||||
?>
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-8">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-white d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">System Settings</h5>
|
||||
<form method="post" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="<?= sanitize(ensure_csrf_token()) ?>">
|
||||
<input type="hidden" name="action" value="save">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Save Changes</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<?php if ($errors): ?>
|
||||
<div class="alert alert-danger">
|
||||
<?php foreach ($errors as $error): ?>
|
||||
<div><?= sanitize($error) ?></div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if (!$settings): ?>
|
||||
<p class="text-muted">No settings found. Use the form on the right to add one.</p>
|
||||
<?php else: ?>
|
||||
<form method="post">
|
||||
<input type="hidden" name="csrf_token" value="<?= sanitize(ensure_csrf_token()) ?>">
|
||||
<input type="hidden" name="action" value="save">
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th>Value</th>
|
||||
<th>Description</th>
|
||||
<th>Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($settings as $setting): ?>
|
||||
<tr>
|
||||
<td class="text-uppercase fw-semibold"><?= sanitize($setting['setting_key']) ?></td>
|
||||
<td style="width: 25%;">
|
||||
<input type="text" name="settings[<?= sanitize($setting['setting_key']) ?>][value]"
|
||||
class="form-control" value="<?= sanitize($setting['setting_value']) ?>">
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" name="settings[<?= sanitize($setting['setting_key']) ?>][description]"
|
||||
class="form-control" value="<?= sanitize($setting['description']) ?>">
|
||||
</td>
|
||||
<td class="text-muted-sm">
|
||||
<?= format_datetime($setting['updated_at']) ?><br>
|
||||
<?= sanitize($setting['updated_by_name'] ?? 'System') ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header bg-white">
|
||||
<h5 class="mb-0">Add Setting</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" class="v-stack gap-3">
|
||||
<input type="hidden" name="csrf_token" value="<?= sanitize(ensure_csrf_token()) ?>">
|
||||
<input type="hidden" name="action" value="create">
|
||||
<div>
|
||||
<label class="form-label">Key</label>
|
||||
<input type="text" name="new_key" class="form-control text-lowercase"
|
||||
placeholder="attendance.cutoff" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Value</label>
|
||||
<input type="text" name="new_value" class="form-control" placeholder="Value">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Description</label>
|
||||
<textarea name="new_description" class="form-control" rows="2"></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-outline-primary">Create Setting</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
render_footer();
|
||||
334
src/admin/students.php
Normal file
@@ -0,0 +1,334 @@
|
||||
<?php
|
||||
require __DIR__ . '/../includes/bootstrap.php';
|
||||
require_login();
|
||||
|
||||
$pdo = db();
|
||||
$errors = [];
|
||||
|
||||
$courses = $pdo->query('SELECT id, code, name FROM courses WHERE status = 1 ORDER BY name')->fetchAll();
|
||||
$departments = $pdo->query('SELECT id, code, name FROM departments WHERE status = 1 ORDER BY name')->fetchAll();
|
||||
$genders = $pdo->query('SELECT id, name FROM genders ORDER BY id')->fetchAll();
|
||||
$schools = $pdo->query('SELECT id, name FROM schools WHERE status = 1 ORDER BY name')->fetchAll();
|
||||
|
||||
if (is_post()) {
|
||||
if (!validate_csrf_token($_POST['csrf_token'] ?? null)) {
|
||||
$errors[] = 'Invalid session token.';
|
||||
} else {
|
||||
$action = $_POST['action'] ?? 'create';
|
||||
|
||||
if ($action === 'delete') {
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
$pdo->prepare('DELETE FROM students WHERE id = :id')->execute(['id' => $id]);
|
||||
add_flash('success', 'Student removed.');
|
||||
redirect('admin/students.php');
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'student_id' => trim($_POST['student_id'] ?? ''),
|
||||
'full_name' => trim($_POST['full_name'] ?? ''),
|
||||
'gender_id' => (int) ($_POST['gender_id'] ?? 0),
|
||||
'year_level' => (int) ($_POST['year_level'] ?? 1),
|
||||
'course_id' => (int) ($_POST['course_id'] ?? 0),
|
||||
'department_id' => (int) ($_POST['department_id'] ?? 0),
|
||||
'school_id' => (int) ($_POST['school_id'] ?? 0),
|
||||
'email' => trim($_POST['email'] ?? ''),
|
||||
'contact_number' => trim($_POST['contact_number'] ?? ''),
|
||||
'address' => trim($_POST['address'] ?? ''),
|
||||
'status' => (int) ($_POST['status'] ?? 1),
|
||||
];
|
||||
|
||||
if ($payload['student_id'] === '' || $payload['full_name'] === '') {
|
||||
$errors[] = 'Student ID and full name are required.';
|
||||
}
|
||||
|
||||
if (!$errors) {
|
||||
if ($action === 'update') {
|
||||
$payload['id'] = (int) ($_POST['id'] ?? 0);
|
||||
$stmt = $pdo->prepare(
|
||||
'UPDATE students SET student_id = :student_id, full_name = :full_name, gender_id = :gender_id,
|
||||
year_level = :year_level, course_id = :course_id, department_id = :department_id,
|
||||
school_id = :school_id, email = :email, contact_number = :contact_number,
|
||||
address = :address, status = :status
|
||||
WHERE id = :id'
|
||||
);
|
||||
$stmt->execute($payload);
|
||||
add_flash('success', 'Student updated.');
|
||||
} else {
|
||||
$qrToken = sprintf('STU-%s', strtoupper(random_token(6)));
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO students (student_id, full_name, gender_id, year_level, course_id, department_id, school_id,
|
||||
email, contact_number, address, qr_code, status)
|
||||
VALUES (:student_id, :full_name, :gender_id, :year_level, :course_id, :department_id, :school_id,
|
||||
:email, :contact_number, :address, :qr_code, :status)'
|
||||
);
|
||||
$stmt->execute(array_merge($payload, ['qr_code' => $qrToken]));
|
||||
add_flash('success', 'Student registered.');
|
||||
}
|
||||
|
||||
redirect('admin/students.php');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$pagination = paginate($pdo, 'SELECT COUNT(*) FROM students');
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT s.*, c.name AS course_name, d.name AS department_name
|
||||
FROM students s
|
||||
LEFT JOIN courses c ON s.course_id = c.id
|
||||
LEFT JOIN departments d ON s.department_id = d.id
|
||||
ORDER BY s.full_name ASC
|
||||
LIMIT :limit OFFSET :offset'
|
||||
);
|
||||
$stmt->bindValue(':limit', $pagination['per_page'], PDO::PARAM_INT);
|
||||
$stmt->bindValue(':offset', $pagination['offset'], PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
$students = $stmt->fetchAll();
|
||||
|
||||
render_header('Students', ['active' => 'students']);
|
||||
?>
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-white d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h5 class="mb-0">Student Directory</h5>
|
||||
<small class="text-muted">Page <?= $pagination['page'] ?> of <?= $pagination['pages'] ?></small>
|
||||
</div>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<?php if ($errors): ?>
|
||||
<span class="text-danger small"><?= sanitize(implode(' ', $errors)) ?></span>
|
||||
<?php endif; ?>
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#studentModal" data-mode="create">
|
||||
Add Student
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<?php if (!$students): ?>
|
||||
<p class="text-muted mb-0 text-center">No students yet.</p>
|
||||
<?php else: ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Student</th>
|
||||
<th>Course</th>
|
||||
<th>Department</th>
|
||||
<th>QR Token</th>
|
||||
<th>Status</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($students as $student): ?>
|
||||
<?php
|
||||
$payload = encode_record([
|
||||
'id' => (int) $student['id'],
|
||||
'student_id' => $student['student_id'],
|
||||
'full_name' => $student['full_name'],
|
||||
'gender_id' => (int) $student['gender_id'],
|
||||
'year_level' => (int) $student['year_level'],
|
||||
'course_id' => (int) $student['course_id'],
|
||||
'department_id' => (int) $student['department_id'],
|
||||
'school_id' => (int) $student['school_id'],
|
||||
'email' => $student['email'],
|
||||
'contact_number' => $student['contact_number'],
|
||||
'address' => $student['address'],
|
||||
'status' => (int) $student['status'],
|
||||
]);
|
||||
?>
|
||||
<tr>
|
||||
<td>
|
||||
<strong><?= sanitize($student['full_name']) ?></strong><br>
|
||||
<span class="text-muted-sm"><?= sanitize($student['student_id']) ?></span>
|
||||
</td>
|
||||
<td><?= sanitize($student['course_name'] ?? '—') ?></td>
|
||||
<td><?= sanitize($student['department_name'] ?? '—') ?></td>
|
||||
<td>
|
||||
<code class="d-block mb-1"><?= sanitize($student['qr_code']) ?></code>
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-link px-0"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#qrModal"
|
||||
data-qr-token="<?= sanitize($student['qr_code']) ?>"
|
||||
data-student-id="<?= sanitize($student['student_id']) ?>"
|
||||
data-student-name="<?= sanitize($student['full_name']) ?>">
|
||||
View QR
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge <?= $student['status'] ? 'bg-success-subtle text-success' : 'bg-secondary' ?>">
|
||||
<?= $student['status'] ? 'Active' : 'Inactive' ?>
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<button class="btn btn-sm btn-outline-primary"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#studentModal"
|
||||
data-mode="edit"
|
||||
data-record="<?= sanitize($payload) ?>">
|
||||
Edit
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#deleteStudentModal"
|
||||
data-student-name="<?= sanitize($student['full_name']) ?>"
|
||||
data-student-id="<?= sanitize($student['student_id']) ?>"
|
||||
data-record-name="<?= sanitize($student['full_name']) ?>"
|
||||
data-record-ref="<?= sanitize($student['student_id']) ?>"
|
||||
data-id="<?= (int) $student['id'] ?>">
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php render_pagination($pagination); ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Student modal -->
|
||||
<div class="modal fade" id="studentModal" tabindex="-1" aria-hidden="true"
|
||||
data-create-title="Add Student" data-edit-title="Edit Student"
|
||||
data-create-submit="Add Student" data-edit-submit="Save Changes">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<form method="post" class="needs-validation" novalidate>
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" data-modal-title>Add Student</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="csrf_token" value="<?= sanitize(ensure_csrf_token()) ?>">
|
||||
<input type="hidden" name="action" value="create">
|
||||
<input type="hidden" name="id" value="">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Student ID</label>
|
||||
<input type="text" name="student_id" class="form-control" required>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Full Name</label>
|
||||
<input type="text" name="full_name" class="form-control" required>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Gender</label>
|
||||
<select name="gender_id" class="form-select">
|
||||
<?php foreach ($genders as $gender): ?>
|
||||
<option value="<?= $gender['id'] ?>"><?= sanitize($gender['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Year Level</label>
|
||||
<input type="number" min="1" max="6" name="year_level" class="form-control" value="1">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Course</label>
|
||||
<select name="course_id" class="form-select">
|
||||
<?php foreach ($courses as $course): ?>
|
||||
<option value="<?= $course['id'] ?>"><?= sanitize($course['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Department</label>
|
||||
<select name="department_id" class="form-select">
|
||||
<?php foreach ($departments as $department): ?>
|
||||
<option value="<?= $department['id'] ?>"><?= sanitize($department['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">School</label>
|
||||
<select name="school_id" class="form-select">
|
||||
<?php foreach ($schools as $school): ?>
|
||||
<option value="<?= $school['id'] ?>"><?= sanitize($school['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Email</label>
|
||||
<input type="email" name="email" class="form-control">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Contact Number</label>
|
||||
<input type="text" name="contact_number" class="form-control">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Address</label>
|
||||
<textarea name="address" class="form-control" rows="2"></textarea>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Status</label>
|
||||
<select name="status" class="form-select">
|
||||
<option value="1">Active</option>
|
||||
<option value="0">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Add Student</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR preview modal -->
|
||||
<div class="modal fade" id="qrModal" tabindex="-1" aria-hidden="true" data-generator="<?= url_for('qr/generate.php') ?>">
|
||||
<div class="modal-dialog modal-sm modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<div class="text-muted small">QR Code</div>
|
||||
<h5 class="modal-title mb-0" data-qr-name>Student Name</h5>
|
||||
</div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body text-center">
|
||||
<div class="mb-2 text-muted small" data-qr-id>ID: —</div>
|
||||
<div class="qr-preview mb-3">
|
||||
<img src="" alt="QR Code" class="img-fluid" data-qr-image>
|
||||
</div>
|
||||
<code class="d-block" data-qr-token></code>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<a href="#" class="btn btn-primary w-100" data-qr-download download>
|
||||
Download PNG
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete confirmation modal -->
|
||||
<div class="modal fade" id="deleteStudentModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form method="post">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Remove Student</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="csrf_token" value="<?= sanitize(ensure_csrf_token()) ?>">
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="id" value="">
|
||||
<p class="mb-1">Are you sure you want to remove this student?</p>
|
||||
<p class="fw-semibold mb-0" data-delete-name>Student Name</p>
|
||||
<small class="text-muted" data-delete-id data-label="ID">ID: —</small>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-danger">Delete</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
render_footer();
|
||||
262
src/admin/users.php
Normal file
@@ -0,0 +1,262 @@
|
||||
<?php
|
||||
require __DIR__ . '/../includes/bootstrap.php';
|
||||
require_login();
|
||||
require_admin();
|
||||
|
||||
$pdo = db();
|
||||
$errors = [];
|
||||
|
||||
if (is_post()) {
|
||||
if (!validate_csrf_token($_POST['csrf_token'] ?? null)) {
|
||||
$errors[] = 'Invalid session token.';
|
||||
} else {
|
||||
$action = $_POST['action'] ?? 'create';
|
||||
|
||||
if ($action === 'create') {
|
||||
$payload = [
|
||||
'username' => strtolower(trim($_POST['username'] ?? '')),
|
||||
'full_name' => trim($_POST['full_name'] ?? ''),
|
||||
'email' => trim($_POST['email'] ?? ''),
|
||||
'role' => $_POST['role'] ?? 'admin',
|
||||
'password' => $_POST['password'] ?? '',
|
||||
];
|
||||
|
||||
if ($payload['username'] === '' || $payload['password'] === '' || $payload['full_name'] === '') {
|
||||
$errors[] = 'Username, full name, and password are required.';
|
||||
} else {
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO users (username, password, role, full_name, email, status)
|
||||
VALUES (:username, :password, :role, :full_name, :email, 1)'
|
||||
);
|
||||
$stmt->execute([
|
||||
'username' => $payload['username'],
|
||||
'password' => password_hash($payload['password'], PASSWORD_BCRYPT),
|
||||
'role' => $payload['role'],
|
||||
'full_name' => $payload['full_name'],
|
||||
'email' => $payload['email'],
|
||||
]);
|
||||
add_flash('success', 'User account created.');
|
||||
redirect('admin/users.php');
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === 'toggle') {
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
$stmt = $pdo->prepare('UPDATE users SET status = IF(status = 1, 0, 1) WHERE id = :id');
|
||||
$stmt->execute(['id' => $id]);
|
||||
add_flash('success', 'User status updated.');
|
||||
redirect('admin/users.php');
|
||||
}
|
||||
|
||||
if ($action === 'reset_password') {
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
$newPassword = trim($_POST['new_password'] ?? '');
|
||||
if ($newPassword === '') {
|
||||
$errors[] = 'New password is required.';
|
||||
} else {
|
||||
$stmt = $pdo->prepare('UPDATE users SET password = :password WHERE id = :id');
|
||||
$stmt->execute([
|
||||
'password' => password_hash($newPassword, PASSWORD_BCRYPT),
|
||||
'id' => $id,
|
||||
]);
|
||||
add_flash('success', 'Password updated.');
|
||||
redirect('admin/users.php');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$users = $pdo->query(
|
||||
'SELECT id, username, full_name, email, role, status, created_at
|
||||
FROM users ORDER BY created_at DESC'
|
||||
)->fetchAll();
|
||||
|
||||
render_header('Users', ['active' => 'users']);
|
||||
?>
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-white d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h5 class="mb-0">Users</h5>
|
||||
<small class="text-muted"><?= count($users) ?> accounts</small>
|
||||
</div>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<?php if ($errors): ?>
|
||||
<span class="text-danger small"><?= sanitize(implode(' ', $errors)) ?></span>
|
||||
<?php endif; ?>
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#userModal">
|
||||
Create User
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<?php if (!$users): ?>
|
||||
<p class="text-muted text-center mb-0">No users found.</p>
|
||||
<?php else: ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Role</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($users as $user): ?>
|
||||
<?php
|
||||
$statusLabel = $user['status'] ? 'Active' : 'Disabled';
|
||||
$toggleAction = $user['status'] ? 'Disable' : 'Enable';
|
||||
$toggleConfirm = $user['status'] ? 'Disable this user?' : 'Enable this user?';
|
||||
?>
|
||||
<tr>
|
||||
<td>
|
||||
<strong><?= sanitize($user['full_name']) ?></strong><br>
|
||||
<span class="text-muted-sm"><?= sanitize($user['username']) ?> · <?= sanitize($user['email']) ?></span>
|
||||
</td>
|
||||
<td><?= strtoupper(sanitize($user['role'])) ?></td>
|
||||
<td>
|
||||
<span class="badge <?= $user['status'] ? 'bg-success-subtle text-success' : 'bg-secondary' ?>">
|
||||
<?= $statusLabel ?>
|
||||
</span>
|
||||
</td>
|
||||
<td><?= format_datetime($user['created_at']) ?></td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group" role="group">
|
||||
<button class="btn btn-sm btn-outline-secondary"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#toggleUserModal"
|
||||
data-id="<?= (int) $user['id'] ?>"
|
||||
data-record-name="<?= sanitize($user['full_name']) ?>"
|
||||
data-record-ref="<?= sanitize($user['username']) ?>"
|
||||
data-confirm-text="<?= sanitize($toggleConfirm) ?>"
|
||||
data-confirm-label="<?= sanitize($toggleAction) ?>">
|
||||
<?= $toggleAction ?>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-primary"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#resetPasswordModal"
|
||||
data-id="<?= (int) $user['id'] ?>"
|
||||
data-record-name="<?= sanitize($user['full_name']) ?>"
|
||||
data-record-ref="<?= sanitize($user['username']) ?>">
|
||||
Reset Password
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create user modal -->
|
||||
<div class="modal fade" id="userModal" tabindex="-1" aria-hidden="true"
|
||||
data-create-title="Create User" data-edit-title="Create User"
|
||||
data-create-submit="Create Account" data-edit-submit="Create Account">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<form method="post" class="needs-validation" novalidate>
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" data-modal-title>Create User</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="csrf_token" value="<?= sanitize(ensure_csrf_token()) ?>">
|
||||
<input type="hidden" name="action" value="create">
|
||||
<input type="hidden" name="id" value="">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Username</label>
|
||||
<input type="text" name="username" class="form-control text-lowercase" required>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Full Name</label>
|
||||
<input type="text" name="full_name" class="form-control" required>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Email</label>
|
||||
<input type="email" name="email" class="form-control">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Role</label>
|
||||
<select name="role" class="form-select">
|
||||
<option value="admin">Admin</option>
|
||||
<option value="staff">Staff</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Password</label>
|
||||
<input type="password" name="password" class="form-control" required>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Create Account</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toggle status modal -->
|
||||
<div class="modal fade" id="toggleUserModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form method="post">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Update Status</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="csrf_token" value="<?= sanitize(ensure_csrf_token()) ?>">
|
||||
<input type="hidden" name="action" value="toggle">
|
||||
<input type="hidden" name="id" value="">
|
||||
<p class="mb-1" data-delete-message>Update user status?</p>
|
||||
<p class="fw-semibold mb-0" data-delete-name>User Name</p>
|
||||
<small class="text-muted" data-delete-id data-label="Username">Username: —</small>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-warning" data-delete-confirm>Confirm</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reset password modal -->
|
||||
<div class="modal fade" id="resetPasswordModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form method="post" class="needs-validation" novalidate>
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Reset Password</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="csrf_token" value="<?= sanitize(ensure_csrf_token()) ?>">
|
||||
<input type="hidden" name="action" value="reset_password">
|
||||
<input type="hidden" name="id" value="">
|
||||
<p class="mb-1">Reset password for:</p>
|
||||
<p class="fw-semibold mb-0" data-delete-name>User Name</p>
|
||||
<small class="text-muted" data-delete-id data-label="Username">Username: —</small>
|
||||
<div class="mt-3">
|
||||
<label class="form-label">New Password</label>
|
||||
<input type="password" name="new_password" class="form-control" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" data-delete-confirm>Update Password</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
render_footer();
|
||||
123
src/api/manual_entry.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
require __DIR__ . '/../includes/bootstrap.php';
|
||||
require_login();
|
||||
|
||||
$pdo = db();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$studentIdentifier = trim($_POST['student'] ?? '');
|
||||
$activityId = (int) ($_POST['activity_id'] ?? 0);
|
||||
$status = $_POST['status'] ?? 'present';
|
||||
$notes = trim($_POST['notes'] ?? 'Manual entry');
|
||||
|
||||
if ($studentIdentifier === '' || !$activityId) {
|
||||
http_response_code(422);
|
||||
echo json_encode(['success' => false, 'message' => 'Student and activity are required.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$studentStmt = $pdo->prepare(
|
||||
'SELECT * FROM students WHERE (student_id = :identifier OR qr_code = :identifier) LIMIT 1'
|
||||
);
|
||||
$studentStmt->execute(['identifier' => $studentIdentifier]);
|
||||
$student = $studentStmt->fetch();
|
||||
if (!$student) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['success' => false, 'message' => 'Student not found.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$activityStmt = $pdo->prepare('SELECT * FROM activities WHERE id = :id LIMIT 1');
|
||||
$activityStmt->execute(['id' => $activityId]);
|
||||
$activity = $activityStmt->fetch();
|
||||
if (!$activity) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['success' => false, 'message' => 'Activity not found.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$now = (new DateTime())->format('Y-m-d H:i:s');
|
||||
$attendanceStmt = $pdo->prepare(
|
||||
'INSERT INTO attendance (student_id, activity_id, time_in, status, notes)
|
||||
VALUES (:student_id, :activity_id, :time_in, :status, :notes)
|
||||
ON DUPLICATE KEY UPDATE status = VALUES(status), notes = VALUES(notes),
|
||||
updated_at = CURRENT_TIMESTAMP'
|
||||
);
|
||||
$attendanceStmt->execute([
|
||||
'student_id' => $student['id'],
|
||||
'activity_id' => $activityId,
|
||||
'time_in' => $now,
|
||||
'status' => $status,
|
||||
'notes' => $notes,
|
||||
]);
|
||||
|
||||
$attendanceId = (int) $pdo->lastInsertId();
|
||||
if (!$attendanceId) {
|
||||
$idLookup = $pdo->prepare(
|
||||
'SELECT id FROM attendance WHERE student_id = :student_id AND activity_id = :activity_id LIMIT 1'
|
||||
);
|
||||
$idLookup->execute([
|
||||
'student_id' => $student['id'],
|
||||
'activity_id' => $activityId,
|
||||
]);
|
||||
$attendanceId = (int) $idLookup->fetchColumn();
|
||||
}
|
||||
$log = $pdo->prepare(
|
||||
'INSERT INTO attendance_logs (attendance_id, action, old_value, new_value, changed_by, notes)
|
||||
VALUES (:attendance_id, :action, :old_value, :new_value, :changed_by, :notes)'
|
||||
);
|
||||
$log->execute([
|
||||
'attendance_id' => $attendanceId,
|
||||
'action' => 'manual_entry',
|
||||
'old_value' => null,
|
||||
'new_value' => $now,
|
||||
'changed_by' => current_user()['id'],
|
||||
'notes' => $notes,
|
||||
]);
|
||||
|
||||
echo json_encode(['success' => true, 'message' => 'Manual attendance recorded.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// GET export
|
||||
$filters = [
|
||||
'start_date' => $_GET['start_date'] ?? null,
|
||||
'end_date' => $_GET['end_date'] ?? null,
|
||||
'activity_id' => $_GET['activity_id'] ?? null,
|
||||
'status' => $_GET['status'] ?? null,
|
||||
];
|
||||
|
||||
$conditions = [];
|
||||
$params = [];
|
||||
if ($filters['start_date']) {
|
||||
$conditions[] = 'DATE(att.time_in) >= :start_date';
|
||||
$params['start_date'] = $filters['start_date'];
|
||||
}
|
||||
if ($filters['end_date']) {
|
||||
$conditions[] = 'DATE(att.time_in) <= :end_date';
|
||||
$params['end_date'] = $filters['end_date'];
|
||||
}
|
||||
if ($filters['activity_id']) {
|
||||
$conditions[] = 'att.activity_id = :activity_id';
|
||||
$params['activity_id'] = (int) $filters['activity_id'];
|
||||
}
|
||||
if ($filters['status']) {
|
||||
$conditions[] = 'att.status = :status';
|
||||
$params['status'] = $filters['status'];
|
||||
}
|
||||
$where = $conditions ? 'WHERE ' . implode(' AND ', $conditions) : '';
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT att.*, s.full_name, s.student_id, act.name AS activity_name
|
||||
FROM attendance att
|
||||
INNER JOIN students s ON att.student_id = s.id
|
||||
INNER JOIN activities act ON att.activity_id = act.id
|
||||
$where
|
||||
ORDER BY att.time_in DESC"
|
||||
);
|
||||
$stmt->execute($params);
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode($stmt->fetchAll());
|
||||
137
src/api/scan.php
Normal file
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
require __DIR__ . '/../includes/bootstrap.php';
|
||||
require_login();
|
||||
require_admin();
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$payload = json_decode(file_get_contents('php://input'), true);
|
||||
if (!is_array($payload)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'message' => 'Invalid payload.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$qrToken = trim($payload['qr_token'] ?? '');
|
||||
$activityId = (int) ($payload['activity_id'] ?? 0);
|
||||
$notes = trim($payload['notes'] ?? '');
|
||||
|
||||
if ($qrToken === '' || !$activityId) {
|
||||
http_response_code(422);
|
||||
echo json_encode(['success' => false, 'message' => 'QR token and activity are required.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$pdo = db();
|
||||
|
||||
$activityStmt = $pdo->prepare('SELECT * FROM activities WHERE id = :id AND status = 1 LIMIT 1');
|
||||
$activityStmt->execute(['id' => $activityId]);
|
||||
$activity = $activityStmt->fetch();
|
||||
|
||||
if (!$activity) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['success' => false, 'message' => 'Activity not found or inactive.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$studentStmt = $pdo->prepare(
|
||||
'SELECT * FROM students WHERE (qr_code = :token OR student_id = :token) AND status = 1 LIMIT 1'
|
||||
);
|
||||
$studentStmt->execute(['token' => $qrToken]);
|
||||
$student = $studentStmt->fetch();
|
||||
|
||||
if (!$student) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['success' => false, 'message' => 'Student not found or inactive.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$attendanceStmt = $pdo->prepare(
|
||||
'SELECT * FROM attendance WHERE student_id = :student_id AND activity_id = :activity_id LIMIT 1'
|
||||
);
|
||||
$attendanceStmt->execute([
|
||||
'student_id' => $student['id'],
|
||||
'activity_id' => $activity['id'],
|
||||
]);
|
||||
$attendance = $attendanceStmt->fetch();
|
||||
|
||||
$now = (new DateTime())->format('Y-m-d H:i:s');
|
||||
$action = '';
|
||||
|
||||
if (!$attendance) {
|
||||
$insert = $pdo->prepare(
|
||||
'INSERT INTO attendance (student_id, activity_id, time_in, status, notes)
|
||||
VALUES (:student_id, :activity_id, :time_in, :status, :notes)'
|
||||
);
|
||||
$insert->execute([
|
||||
'student_id' => $student['id'],
|
||||
'activity_id' => $activity['id'],
|
||||
'time_in' => $now,
|
||||
'status' => 'present',
|
||||
'notes' => $notes,
|
||||
]);
|
||||
$attendanceId = (int) $pdo->lastInsertId();
|
||||
$action = 'time_in';
|
||||
} elseif ($attendance['time_out'] === null) {
|
||||
$update = $pdo->prepare(
|
||||
'UPDATE attendance SET time_out = :time_out, notes = :notes, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = :id'
|
||||
);
|
||||
$update->execute([
|
||||
'time_out' => $now,
|
||||
'notes' => $notes ?: $attendance['notes'],
|
||||
'id' => $attendance['id'],
|
||||
]);
|
||||
$attendanceId = (int) $attendance['id'];
|
||||
$action = 'time_out';
|
||||
} else {
|
||||
$pdo->rollBack();
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => 'Attendance already completed for this student.',
|
||||
'meta' => [
|
||||
'student' => $student['full_name'],
|
||||
'activity' => $activity['name'],
|
||||
'action' => 'ignored',
|
||||
'timestamp' => format_datetime($attendance['updated_at']),
|
||||
],
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$log = $pdo->prepare(
|
||||
'INSERT INTO attendance_logs (attendance_id, action, old_value, new_value, changed_by, notes)
|
||||
VALUES (:attendance_id, :action, :old_value, :new_value, :changed_by, :notes)'
|
||||
);
|
||||
$log->execute([
|
||||
'attendance_id' => $attendanceId,
|
||||
'action' => $action,
|
||||
'old_value' => null,
|
||||
'new_value' => $now,
|
||||
'changed_by' => current_user()['id'],
|
||||
'notes' => $notes,
|
||||
]);
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => sprintf(
|
||||
'Marked %s for %s.',
|
||||
$action === 'time_in' ? 'time in' : 'time out',
|
||||
$student['full_name']
|
||||
),
|
||||
'meta' => [
|
||||
'student' => $student['full_name'],
|
||||
'activity' => $activity['name'],
|
||||
'action' => $action,
|
||||
'timestamp' => format_datetime($now),
|
||||
],
|
||||
]);
|
||||
} catch (Throwable $exception) {
|
||||
$pdo->rollBack();
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'message' => 'Unable to record attendance.']);
|
||||
}
|
||||
424
src/assets/css/style.css
Normal file
@@ -0,0 +1,424 @@
|
||||
:root {
|
||||
--brand-primary: #1b7f5c;
|
||||
--brand-secondary: #3fc380;
|
||||
--brand-accent: #a2d729;
|
||||
--brand-muted: #edf6f0;
|
||||
--bs-primary: var(--brand-primary);
|
||||
--bs-primary-rgb: 27, 127, 92;
|
||||
--bs-primary-bg-subtle: #d6f5ea;
|
||||
--bs-primary-border-subtle: #a1dfc6;
|
||||
--bs-link-color: var(--brand-primary);
|
||||
--bs-link-hover-color: #146946;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #f2f9f4;
|
||||
font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.auth-shell {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.google-auth-shell {
|
||||
width: 100%;
|
||||
min-height: calc(100vh - 4rem);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
padding: 2rem 1rem 3rem;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(66, 133, 244, 0.12), transparent 55%),
|
||||
radial-gradient(circle at 20% 20%, rgba(52, 168, 83, 0.12), transparent 45%),
|
||||
radial-gradient(circle at 80% 0%, rgba(234, 67, 53, 0.12), transparent 50%),
|
||||
#f2f9f4;
|
||||
}
|
||||
|
||||
.google-auth-card {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(218, 220, 224, 0.8);
|
||||
padding: 2.75rem 3.25rem;
|
||||
max-width: 460px;
|
||||
width: 100%;
|
||||
box-shadow:
|
||||
0 10px 30px rgba(26, 115, 232, 0.08),
|
||||
0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.google-auth-head .google-title {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.google-subtitle {
|
||||
color: #5f6368;
|
||||
font-size: 0.95rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.google-logo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.qr-logo-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 16px;
|
||||
padding: 0.5rem;
|
||||
background: linear-gradient(145deg, rgba(27, 127, 92, 0.12), rgba(15, 81, 50, 0.08));
|
||||
border: 1px solid rgba(27, 127, 92, 0.3);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.qr-logo-icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.google-logo-text {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
color: #5f6368;
|
||||
}
|
||||
|
||||
.google-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.google-field {
|
||||
position: relative;
|
||||
margin-bottom: 1.5rem;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.google-field:focus-within {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.google-input {
|
||||
width: 100%;
|
||||
border: 1px solid #dadce0;
|
||||
border-radius: 10px;
|
||||
padding: 1.4rem 3rem 0.6rem 1rem;
|
||||
background: transparent;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.google-input:focus {
|
||||
border-color: #1a73e8;
|
||||
box-shadow: 0 6px 18px rgba(26, 115, 232, 0.15);
|
||||
background: #fff;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.google-input:not(:placeholder-shown) + .google-label,
|
||||
.google-input:focus + .google-label {
|
||||
top: 0.55rem;
|
||||
font-size: 0.75rem;
|
||||
color: #1a73e8;
|
||||
}
|
||||
|
||||
.google-label {
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 0.95rem;
|
||||
color: #5f6368;
|
||||
transition: all 0.15s ease;
|
||||
pointer-events: none;
|
||||
background: #fff;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.google-toggle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0.75rem;
|
||||
transform: translateY(-50%);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #1a73e8;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.google-toggle:hover {
|
||||
color: #174ea6;
|
||||
}
|
||||
|
||||
.google-links {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.google-links a {
|
||||
color: #1a73e8;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.google-links a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.google-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.google-alt-action {
|
||||
color: #1a73e8;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.google-alt-action:hover {
|
||||
color: #174ea6;
|
||||
}
|
||||
|
||||
.google-primary-action {
|
||||
min-width: 110px;
|
||||
border-radius: 50px;
|
||||
font-weight: 600;
|
||||
background-color: #1a73e8;
|
||||
border-color: #1a73e8;
|
||||
box-shadow: 0 6px 18px rgba(26, 115, 232, 0.25);
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.google-primary-action:hover {
|
||||
background-color: #1668d8;
|
||||
border-color: #1668d8;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 24px rgba(26, 115, 232, 0.3);
|
||||
}
|
||||
|
||||
.google-footnote {
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 575.98px) {
|
||||
.google-auth-card {
|
||||
padding: 2rem;
|
||||
border-radius: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background-color: #f5f7fb;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 260px;
|
||||
padding: 2rem 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.sidebar-icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 12px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.sidebar-link {
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.65rem 0.85rem;
|
||||
color: #495057;
|
||||
text-decoration: none;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.sidebar-link:hover {
|
||||
background-color: rgba(27, 127, 92, 0.1);
|
||||
color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.sidebar-link.active {
|
||||
background-color: var(--brand-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.sidebar-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.75rem;
|
||||
background-color: var(--brand-muted);
|
||||
border: 1px solid rgba(27, 127, 92, 0.15);
|
||||
}
|
||||
|
||||
.content-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
transition: margin-left 0.25s ease;
|
||||
}
|
||||
|
||||
.content-body {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.camera-viewer {
|
||||
min-height: 240px;
|
||||
border: 1px dashed rgba(0, 0, 0, 0.15);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body.sidebar-collapsed .sidebar {
|
||||
width: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
body.sidebar-collapsed .content-area {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.sidebar-toggle {
|
||||
border-radius: 999px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
color: var(--brand-primary) !important;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
border-radius: 1rem;
|
||||
background: #fff;
|
||||
padding: 1.5rem;
|
||||
min-height: 130px;
|
||||
}
|
||||
|
||||
.stat-card h6 {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
font-size: 0.75rem;
|
||||
color: #6c757d;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-card .stat-value {
|
||||
font-size: 2.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--brand-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
border-radius: 1rem;
|
||||
border-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.table thead {
|
||||
background-color: var(--brand-muted);
|
||||
}
|
||||
|
||||
.badge-status {
|
||||
text-transform: uppercase;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.scan-panel {
|
||||
background: linear-gradient(135deg, var(--brand-primary), var(--brand-secondary));
|
||||
color: #fff;
|
||||
border-radius: 1.25rem;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 20px 40px rgba(27, 127, 92, 0.25);
|
||||
}
|
||||
|
||||
.scan-panel h2 {
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.scan-panel .form-control,
|
||||
.scan-panel .form-select {
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.qr-preview {
|
||||
border: 1px dashed rgba(0, 0, 0, 0.2);
|
||||
border-radius: 1rem;
|
||||
padding: 1rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.85rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
background-color: var(--brand-muted);
|
||||
color: #1f5137;
|
||||
}
|
||||
|
||||
.text-muted-sm {
|
||||
font-size: 0.9rem;
|
||||
color: #868e96 !important;
|
||||
}
|
||||
BIN
src/assets/images/aldersgate.png
Normal file
|
After Width: | Height: | Size: 511 KiB |
175
src/assets/js/admin.js
Normal file
@@ -0,0 +1,175 @@
|
||||
(() => {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
['studentModal', 'activityModal', 'courseModal', 'departmentModal', 'userModal']
|
||||
.forEach(setupRecordModal);
|
||||
setupSidebarToggle();
|
||||
setupQrModal('qrModal');
|
||||
['deleteStudentModal', 'deleteActivityModal', 'deleteCourseModal', 'deleteDepartmentModal', 'toggleUserModal', 'resetPasswordModal']
|
||||
.forEach(setupDeleteModal);
|
||||
});
|
||||
|
||||
function setupRecordModal(id) {
|
||||
const modalEl = document.getElementById(id);
|
||||
if (!modalEl) return;
|
||||
|
||||
const createTitle = modalEl.dataset.createTitle || 'Add Record';
|
||||
const editTitle = modalEl.dataset.editTitle || 'Edit Record';
|
||||
const createSubmit = modalEl.dataset.createSubmit || 'Add Record';
|
||||
const editSubmit = modalEl.dataset.editSubmit || 'Save Changes';
|
||||
|
||||
modalEl.addEventListener('show.bs.modal', (event) => {
|
||||
const button = event.relatedTarget;
|
||||
const mode = button && button.dataset ? button.dataset.mode || 'create' : 'create';
|
||||
const form = modalEl.querySelector('form');
|
||||
if (!form) return;
|
||||
const hasRecordPayload = Boolean(button && button.dataset && button.dataset.record);
|
||||
const isEdit = mode === 'edit' || hasRecordPayload;
|
||||
|
||||
form.reset();
|
||||
form.querySelector('[name="action"]').value = isEdit ? 'update' : 'create';
|
||||
form.querySelector('[name="id"]').value = '';
|
||||
|
||||
const title = modalEl.querySelector('[data-modal-title]');
|
||||
const submit = modalEl.querySelector('[type="submit"]');
|
||||
|
||||
if (hasRecordPayload) {
|
||||
try {
|
||||
const data = JSON.parse(atob(button.dataset.record));
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
const field = form.querySelector(`[name="${key}"]`);
|
||||
if (!field) return;
|
||||
field.value = value ?? '';
|
||||
});
|
||||
form.querySelector('[name="id"]').value = data.id ?? '';
|
||||
} catch (error) {
|
||||
console.error('Failed to parse record payload', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (title) {
|
||||
title.textContent = isEdit ? editTitle : createTitle;
|
||||
}
|
||||
if (submit) {
|
||||
submit.textContent = isEdit ? editSubmit : createSubmit;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setupSidebarToggle() {
|
||||
const toggleButtons = document.querySelectorAll('[data-sidebar-toggle]');
|
||||
if (!toggleButtons.length) return;
|
||||
|
||||
const storageKey = 'sidebarCollapsed';
|
||||
if (localStorage.getItem(storageKey) === '1') {
|
||||
document.body.classList.add('sidebar-collapsed');
|
||||
}
|
||||
|
||||
toggleButtons.forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
document.body.classList.toggle('sidebar-collapsed');
|
||||
const collapsed = document.body.classList.contains('sidebar-collapsed');
|
||||
localStorage.setItem(storageKey, collapsed ? '1' : '0');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function setupQrModal(id) {
|
||||
const modalEl = document.getElementById(id);
|
||||
if (!modalEl) return;
|
||||
|
||||
const generator = modalEl.getAttribute('data-generator') || '';
|
||||
const nameEl = modalEl.querySelector('[data-qr-name]');
|
||||
const idEl = modalEl.querySelector('[data-qr-id]');
|
||||
const tokenEl = modalEl.querySelector('[data-qr-token]');
|
||||
const imageEl = modalEl.querySelector('[data-qr-image]');
|
||||
const downloadEl = modalEl.querySelector('[data-qr-download]');
|
||||
|
||||
modalEl.addEventListener('show.bs.modal', (event) => {
|
||||
const trigger = event.relatedTarget;
|
||||
if (!trigger || !trigger.dataset) return;
|
||||
|
||||
const token = trigger.dataset.qrToken || '';
|
||||
const studentId = trigger.dataset.studentId || '';
|
||||
const studentName = trigger.dataset.studentName || 'Student';
|
||||
|
||||
if (nameEl) nameEl.textContent = studentName;
|
||||
if (idEl) idEl.textContent = `ID: ${studentId || '—'}`;
|
||||
if (tokenEl) tokenEl.textContent = token;
|
||||
|
||||
if (imageEl) {
|
||||
if (generator && token) {
|
||||
imageEl.src = `${generator}?token=${encodeURIComponent(token)}&v=${Date.now()}`;
|
||||
imageEl.alt = `QR code for ${studentName}`;
|
||||
} else {
|
||||
imageEl.removeAttribute('src');
|
||||
imageEl.alt = 'QR code unavailable';
|
||||
}
|
||||
}
|
||||
|
||||
if (downloadEl) {
|
||||
const canDownload = Boolean(generator && token);
|
||||
if (canDownload) {
|
||||
downloadEl.href = `${generator}?token=${encodeURIComponent(token)}&download=1`;
|
||||
downloadEl.setAttribute('download', `qr-${studentId || token}.png`);
|
||||
downloadEl.classList.remove('disabled');
|
||||
downloadEl.removeAttribute('aria-disabled');
|
||||
} else {
|
||||
downloadEl.href = '#';
|
||||
downloadEl.setAttribute('aria-disabled', 'true');
|
||||
downloadEl.classList.add('disabled');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setupDeleteModal(id) {
|
||||
const modalEl = document.getElementById(id);
|
||||
if (!modalEl) return;
|
||||
|
||||
const nameEl = modalEl.querySelector('[data-delete-name]');
|
||||
const idEl = modalEl.querySelector('[data-delete-id]');
|
||||
const messageEl = modalEl.querySelector('[data-delete-message]');
|
||||
const confirmButton = modalEl.querySelector('[data-delete-confirm]');
|
||||
const hiddenId = modalEl.querySelector('input[name="id"]');
|
||||
const defaultNameText = nameEl ? nameEl.textContent : 'Record Name';
|
||||
const defaultIdText = idEl ? idEl.textContent : '';
|
||||
const defaultMessageText = messageEl ? messageEl.textContent : '';
|
||||
const defaultConfirmText = confirmButton ? confirmButton.textContent : '';
|
||||
|
||||
modalEl.addEventListener('show.bs.modal', (event) => {
|
||||
const trigger = event.relatedTarget;
|
||||
if (!trigger || !trigger.dataset) return;
|
||||
|
||||
const recordId = trigger.dataset.id || '';
|
||||
const recordName = trigger.dataset.recordName || trigger.dataset.studentName || 'Record';
|
||||
const recordRef = trigger.dataset.recordRef || trigger.dataset.studentId || '';
|
||||
|
||||
if (hiddenId) hiddenId.value = recordId;
|
||||
if (nameEl) nameEl.textContent = recordName || defaultNameText;
|
||||
if (idEl) {
|
||||
if (recordRef) {
|
||||
const label = idEl.dataset.label || '';
|
||||
idEl.textContent = label ? `${label}: ${recordRef}` : recordRef;
|
||||
} else {
|
||||
idEl.textContent = defaultIdText;
|
||||
}
|
||||
}
|
||||
if (messageEl) {
|
||||
const confirmText = trigger.dataset.confirmText || '';
|
||||
messageEl.textContent = confirmText || defaultMessageText;
|
||||
}
|
||||
if (confirmButton) {
|
||||
const confirmLabel = trigger.dataset.confirmLabel || '';
|
||||
confirmButton.textContent = confirmLabel || defaultConfirmText;
|
||||
}
|
||||
});
|
||||
|
||||
modalEl.addEventListener('hidden.bs.modal', () => {
|
||||
if (hiddenId) hiddenId.value = '';
|
||||
if (nameEl) nameEl.textContent = defaultNameText;
|
||||
if (idEl) idEl.textContent = defaultIdText;
|
||||
if (messageEl) messageEl.textContent = defaultMessageText;
|
||||
if (confirmButton) confirmButton.textContent = defaultConfirmText;
|
||||
});
|
||||
}
|
||||
})();
|
||||
223
src/assets/js/scan.js
Normal file
@@ -0,0 +1,223 @@
|
||||
(() => {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const scanForm = document.querySelector('[data-scan-form]');
|
||||
if (scanForm) {
|
||||
const endpoint = scanForm.dataset.endpoint;
|
||||
const qrInput = scanForm.querySelector('input[name="qr_token"]');
|
||||
const resultBox = document.querySelector('#scan-result');
|
||||
const statusBox = document.querySelector('#scan-status');
|
||||
const submitBtn = scanForm.querySelector('button[type="submit"]');
|
||||
const setStatus = (message, classes = 'text-white fw-semibold') => {
|
||||
if (!statusBox) return;
|
||||
statusBox.textContent = message;
|
||||
statusBox.className = classes;
|
||||
};
|
||||
|
||||
scanForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
if (!endpoint) return;
|
||||
|
||||
const payload = Object.fromEntries(new FormData(scanForm));
|
||||
submitBtn.disabled = true;
|
||||
setStatus('Processing scan…');
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
setStatus(
|
||||
data.message || 'No response message',
|
||||
data.success ? 'text-success fw-semibold' : 'text-danger fw-semibold'
|
||||
);
|
||||
|
||||
if (data.meta && resultBox) {
|
||||
resultBox.innerHTML = `
|
||||
<div class="list-group">
|
||||
<div class="list-group-item">
|
||||
<strong>Student:</strong> ${data.meta.student ?? '—'}
|
||||
</div>
|
||||
<div class="list-group-item">
|
||||
<strong>Activity:</strong> ${data.meta.activity ?? '—'}
|
||||
</div>
|
||||
<div class="list-group-item">
|
||||
<strong>Action:</strong> ${data.meta.action ?? '—'}
|
||||
</div>
|
||||
<div class="list-group-item">
|
||||
<strong>Recorded:</strong> ${data.meta.timestamp ?? '—'}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
qrInput.value = '';
|
||||
qrInput.focus();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setStatus('Unable to process scan. Please try again.', 'text-danger fw-semibold');
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
setupCameraScanner(scanForm, qrInput, setStatus);
|
||||
}
|
||||
});
|
||||
|
||||
function setupCameraScanner(form, qrInput, setStatus) {
|
||||
const cameraRegion = document.querySelector('#camera-viewer');
|
||||
const startButton = document.querySelector('[data-scan-start]');
|
||||
if (!cameraRegion || !startButton) return;
|
||||
|
||||
let html5Scanner = null;
|
||||
let fallbackController = null;
|
||||
|
||||
startButton.addEventListener('click', async () => {
|
||||
if (startButton.dataset.state === 'running') {
|
||||
await stopScanner();
|
||||
return;
|
||||
}
|
||||
|
||||
startButton.disabled = true;
|
||||
setStatus('Initializing camera scanner…');
|
||||
const started = await startScanner();
|
||||
startButton.disabled = false;
|
||||
|
||||
if (!started) {
|
||||
startButton.classList.add('disabled');
|
||||
cameraRegion.innerHTML = '<div class="text-danger text-center small">Camera scan unavailable in this browser.</div>';
|
||||
setStatus('Camera scan unavailable. Please type QR token manually.', 'text-warning fw-semibold');
|
||||
}
|
||||
});
|
||||
|
||||
async function startScanner() {
|
||||
if (window.Html5Qrcode) {
|
||||
return startHtml5Scanner();
|
||||
}
|
||||
if ('BarcodeDetector' in window) {
|
||||
fallbackController = await startFallbackScanner();
|
||||
if (fallbackController) {
|
||||
startButton.dataset.state = 'running';
|
||||
startButton.textContent = 'Stop Camera Scan';
|
||||
setStatus('Camera scanner ready (fallback mode).');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function startHtml5Scanner() {
|
||||
try {
|
||||
if (!html5Scanner) {
|
||||
html5Scanner = new Html5Qrcode(cameraRegion.id);
|
||||
}
|
||||
cameraRegion.innerHTML = '';
|
||||
await html5Scanner.start(
|
||||
{ facingMode: 'environment' },
|
||||
{ fps: 10, qrbox: 240 },
|
||||
(decodedText) => {
|
||||
if (!decodedText) {
|
||||
return;
|
||||
}
|
||||
qrInput.value = decodedText.trim();
|
||||
form.requestSubmit();
|
||||
stopScanner();
|
||||
},
|
||||
() => {}
|
||||
);
|
||||
startButton.dataset.state = 'running';
|
||||
startButton.textContent = 'Stop Camera Scan';
|
||||
setStatus('Camera scanner ready (high fidelity).');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
await stopScanner();
|
||||
setStatus('Camera scanner could not start. Trying fallback…', 'text-warning fw-semibold');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function stopScanner() {
|
||||
if (html5Scanner) {
|
||||
await html5Scanner.stop().catch(() => {});
|
||||
html5Scanner.clear();
|
||||
html5Scanner = null;
|
||||
}
|
||||
if (fallbackController) {
|
||||
fallbackController.stop();
|
||||
fallbackController = null;
|
||||
}
|
||||
startButton.dataset.state = '';
|
||||
startButton.textContent = 'Start Camera Scan';
|
||||
cameraRegion.innerHTML = '<div class="text-center text-muted small">Camera idle</div>';
|
||||
startButton.classList.remove('disabled');
|
||||
setStatus('Camera idle. Ready for manual scans.', 'text-white-50 fw-semibold');
|
||||
}
|
||||
|
||||
async function startFallbackScanner() {
|
||||
let stream;
|
||||
let detector;
|
||||
let detectionTimer;
|
||||
const video = document.createElement('video');
|
||||
video.className = 'w-100 rounded';
|
||||
video.setAttribute('playsinline', 'true');
|
||||
video.muted = true;
|
||||
cameraRegion.innerHTML = '';
|
||||
cameraRegion.appendChild(video);
|
||||
|
||||
try {
|
||||
detector = new BarcodeDetector({ formats: ['qr_code'] });
|
||||
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } });
|
||||
video.srcObject = stream;
|
||||
await video.play();
|
||||
|
||||
const scanFrame = async () => {
|
||||
if (!detector) return;
|
||||
try {
|
||||
const codes = await detector.detect(video);
|
||||
if (codes.length > 0) {
|
||||
qrInput.value = codes[0].rawValue.trim();
|
||||
form.requestSubmit();
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
detectionTimer = requestAnimationFrame(scanFrame);
|
||||
};
|
||||
|
||||
detectionTimer = requestAnimationFrame(scanFrame);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
stop();
|
||||
setStatus('Fallback scanner unavailable. Please allow camera access.', 'text-warning fw-semibold');
|
||||
return null;
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (detectionTimer) {
|
||||
cancelAnimationFrame(detectionTimer);
|
||||
detectionTimer = null;
|
||||
}
|
||||
if (stream) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
stream = null;
|
||||
}
|
||||
if (video.srcObject) {
|
||||
video.pause();
|
||||
video.srcObject = null;
|
||||
}
|
||||
detector = null;
|
||||
}
|
||||
|
||||
return { stop };
|
||||
}
|
||||
}
|
||||
})();
|
||||
122
src/auth/login.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
|
||||
if (is_logged_in()) {
|
||||
redirect('admin/dashboard.php');
|
||||
}
|
||||
|
||||
$appName = config('app.name', 'QR Attendance');
|
||||
$supportEmail = config('app.support_email', 'support@campus.local');
|
||||
$supportLink = 'mailto:' . $supportEmail;
|
||||
|
||||
$errors = [];
|
||||
|
||||
if (is_post()) {
|
||||
if (!validate_csrf_token($_POST['csrf_token'] ?? null)) {
|
||||
$errors[] = 'Invalid session token. Please refresh.';
|
||||
} else {
|
||||
$username = trim($_POST['username'] ?? '');
|
||||
$password = trim($_POST['password'] ?? '');
|
||||
|
||||
if ($username === '' || $password === '') {
|
||||
$errors[] = 'Username and password are required.';
|
||||
} elseif (!attempt_login($username, $password)) {
|
||||
$errors[] = 'Invalid credentials.';
|
||||
} else {
|
||||
add_flash('success', 'Welcome back!');
|
||||
redirect('admin/dashboard.php');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render_header('Sign in', ['hide_nav' => true]);
|
||||
?>
|
||||
<section class="google-auth-shell d-flex flex-column align-items-center justify-content-center gap-4 w-100">
|
||||
<div class="text-center">
|
||||
<span class="badge text-bg-light px-3 py-2 text-uppercase fw-semibold">Admin Portal</span>
|
||||
<p class="text-muted mb-0">Use your institution-issued credentials to continue.</p>
|
||||
</div>
|
||||
<div class="google-auth-card card shadow-sm border-0">
|
||||
<div class="text-center mb-4 google-auth-head">
|
||||
<div class="google-logo" aria-label="<?= sanitize($appName) ?>">
|
||||
<span class="qr-logo-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 64 64" role="presentation" focusable="false">
|
||||
<rect x="2" y="2" width="20" height="20" rx="4" fill="#0f5132"></rect>
|
||||
<rect x="42" y="2" width="20" height="20" rx="4" fill="#1b7f5c"></rect>
|
||||
<rect x="2" y="42" width="20" height="20" rx="4" fill="#146946"></rect>
|
||||
<rect x="26" y="26" width="12" height="12" rx="2" fill="#0f5132"></rect>
|
||||
<rect x="44" y="36" width="12" height="12" rx="2" fill="#1b7f5c"></rect>
|
||||
<rect x="32" y="48" width="8" height="8" rx="2" fill="#146946"></rect>
|
||||
<rect x="24" y="8" width="8" height="8" rx="2" fill="#1b7f5c"></rect>
|
||||
<rect x="8" y="32" width="8" height="8" rx="2" fill="#0f5132"></rect>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="google-logo-text"><?= sanitize($appName) ?></span>
|
||||
</div>
|
||||
<h1 class="google-title mb-1">Sign in</h1>
|
||||
<p class="google-subtitle">Use your administrator account</p>
|
||||
</div>
|
||||
<?php if ($errors): ?>
|
||||
<div class="alert alert-danger mb-4">
|
||||
<?php foreach ($errors as $error): ?>
|
||||
<div><?= sanitize($error) ?></div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<form method="post" novalidate class="google-form">
|
||||
<input type="hidden" name="csrf_token" value="<?= sanitize(ensure_csrf_token()) ?>">
|
||||
<div class="google-field">
|
||||
<input type="text"
|
||||
name="username"
|
||||
id="username"
|
||||
class="google-input"
|
||||
placeholder=" "
|
||||
value="<?= sanitize($_POST['username'] ?? '') ?>"
|
||||
autocomplete="username"
|
||||
required>
|
||||
<label for="username" class="google-label">Email or username</label>
|
||||
</div>
|
||||
<div class="google-field">
|
||||
<input type="password"
|
||||
name="password"
|
||||
id="password"
|
||||
class="google-input"
|
||||
placeholder=" "
|
||||
autocomplete="current-password"
|
||||
required
|
||||
data-password-input>
|
||||
<label for="password" class="google-label">Password</label>
|
||||
<button type="button" class="google-toggle" data-password-toggle>Show</button>
|
||||
</div>
|
||||
<div class="google-links">
|
||||
<a href="<?= sanitize($supportLink) ?>">Need help?</a>
|
||||
</div>
|
||||
<div class="google-actions">
|
||||
<button type="button" class="btn btn-link google-alt-action px-0">Create account</button>
|
||||
<button type="submit" class="btn btn-primary google-primary-action">
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<p class="google-footnote text-muted text-center">
|
||||
This console is managed by <?= sanitize($appName) ?> IT. By continuing, you agree to internal security policies.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<?php
|
||||
?>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const input = document.querySelector('[data-password-input]');
|
||||
const toggle = document.querySelector('[data-password-toggle]');
|
||||
if (!input || !toggle) return;
|
||||
|
||||
toggle.addEventListener('click', () => {
|
||||
const isHidden = input.type === 'password';
|
||||
input.type = isHidden ? 'text' : 'password';
|
||||
toggle.textContent = isHidden ? 'Hide' : 'Show';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<?php
|
||||
render_footer(['extra_js' => []]);
|
||||
6
src/auth/logout.php
Normal file
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
require __DIR__ . '/../includes/bootstrap.php';
|
||||
|
||||
logout_user();
|
||||
add_flash('success', 'You have been signed out.');
|
||||
redirect('auth/login.php');
|
||||
22
src/config/config.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'app' => [
|
||||
'name' => getenv('APP_NAME') ?: 'QR Attendance System',
|
||||
'timezone' => getenv('APP_TZ') ?: 'Asia/Manila',
|
||||
'base_url' => rtrim(getenv('APP_BASE_URL') ?: '', '/'),
|
||||
],
|
||||
'db' => [
|
||||
'host' => getenv('DB_HOST') ?: '127.0.0.1',
|
||||
'port' => getenv('DB_PORT') ?: '3306',
|
||||
'name' => getenv('DB_NAME') ?: 'attendance_system',
|
||||
'user' => getenv('DB_USER') ?: 'root',
|
||||
'pass' => getenv('DB_PASS') ?: '',
|
||||
'charset' => 'utf8mb4',
|
||||
],
|
||||
'qr' => [
|
||||
'provider' => strtolower(getenv('QR_PROVIDER') ?: 'auto'),
|
||||
'goqr_size' => getenv('QR_SIZE') ?: '300x300',
|
||||
],
|
||||
];
|
||||
BIN
src/d4c.zip
Normal file
73
src/includes/auth.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
function current_user(): ?array
|
||||
{
|
||||
return $_SESSION['user'] ?? null;
|
||||
}
|
||||
|
||||
function is_logged_in(): bool
|
||||
{
|
||||
return current_user() !== null;
|
||||
}
|
||||
|
||||
function require_login(): void
|
||||
{
|
||||
if (!is_logged_in()) {
|
||||
redirect('auth/login.php');
|
||||
}
|
||||
}
|
||||
|
||||
function require_admin(): void
|
||||
{
|
||||
$user = current_user();
|
||||
if (!$user || $user['role'] !== 'admin') {
|
||||
http_response_code(403);
|
||||
echo 'Forbidden';
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
function attempt_login(string $username, string $password): bool
|
||||
{
|
||||
$pdo = db();
|
||||
$stmt = $pdo->prepare('SELECT * FROM users WHERE username = :username AND status = 1 LIMIT 1');
|
||||
$stmt->execute(['username' => $username]);
|
||||
$user = $stmt->fetch();
|
||||
|
||||
if (!$user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!password_verify($password, $user['password'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$_SESSION['user'] = [
|
||||
'id' => $user['id'],
|
||||
'username' => $user['username'],
|
||||
'full_name' => $user['full_name'],
|
||||
'role' => $user['role'],
|
||||
'email' => $user['email'],
|
||||
];
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function logout_user(): void
|
||||
{
|
||||
$_SESSION = [];
|
||||
if (ini_get('session.use_cookies')) {
|
||||
$params = session_get_cookie_params();
|
||||
setcookie(
|
||||
session_name(),
|
||||
'',
|
||||
time() - 42000,
|
||||
$params['path'],
|
||||
$params['domain'],
|
||||
$params['secure'],
|
||||
$params['httponly']
|
||||
);
|
||||
}
|
||||
session_destroy();
|
||||
}
|
||||
16
src/includes/bootstrap.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/helpers.php';
|
||||
require_once __DIR__ . '/db.php';
|
||||
require_once __DIR__ . '/auth.php';
|
||||
require_once __DIR__ . '/layout.php';
|
||||
require_once __DIR__ . '/setup.php';
|
||||
|
||||
date_default_timezone_set(config('app.timezone', 'UTC'));
|
||||
|
||||
ensure_default_admin();
|
||||
30
src/includes/db.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
function db(): PDO
|
||||
{
|
||||
static $pdo;
|
||||
|
||||
if ($pdo === null) {
|
||||
$config = config('db');
|
||||
$dsn = sprintf(
|
||||
'mysql:host=%s;port=%s;dbname=%s;charset=%s',
|
||||
$config['host'],
|
||||
$config['port'],
|
||||
$config['name'],
|
||||
$config['charset']
|
||||
);
|
||||
|
||||
$pdo = new PDO(
|
||||
$dsn,
|
||||
$config['user'],
|
||||
$config['pass'],
|
||||
[
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
return $pdo;
|
||||
}
|
||||
219
src/includes/helpers.php
Normal file
@@ -0,0 +1,219 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
function app_config(): array
|
||||
{
|
||||
static $config;
|
||||
if ($config === null) {
|
||||
$configPath = __DIR__ . '/../config/config.php';
|
||||
if (!file_exists($configPath)) {
|
||||
throw new RuntimeException('Config file missing.');
|
||||
}
|
||||
$config = require $configPath;
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
function config(string $key, mixed $default = null): mixed
|
||||
{
|
||||
$config = app_config();
|
||||
$segments = explode('.', $key);
|
||||
$value = $config;
|
||||
|
||||
foreach ($segments as $segment) {
|
||||
if (!is_array($value) || !array_key_exists($segment, $value)) {
|
||||
return $default;
|
||||
}
|
||||
$value = $value[$segment];
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
function url_for(string $path = ''): string
|
||||
{
|
||||
$base = resolve_base_url();
|
||||
$path = '/' . ltrim($path, '/');
|
||||
|
||||
if ($base === '' || $base === '/') {
|
||||
return $path;
|
||||
}
|
||||
|
||||
return rtrim($base, '/') . $path;
|
||||
}
|
||||
|
||||
function asset_url(string $path): string
|
||||
{
|
||||
return url_for('assets/' . ltrim($path, '/'));
|
||||
}
|
||||
|
||||
function resolve_base_url(): string
|
||||
{
|
||||
static $base = null;
|
||||
if ($base !== null) {
|
||||
return $base;
|
||||
}
|
||||
|
||||
$configured = config('app.base_url', '');
|
||||
if ($configured !== '') {
|
||||
if (!preg_match('#^https?://#i', $configured)) {
|
||||
$configured = '/' . ltrim($configured, '/');
|
||||
}
|
||||
$base = rtrim($configured, '/') ?: '/';
|
||||
|
||||
return $base;
|
||||
}
|
||||
|
||||
$documentRoot = $_SERVER['DOCUMENT_ROOT'] ?? '';
|
||||
$documentRoot = $documentRoot ? rtrim(normalize_path($documentRoot), '/') : '';
|
||||
$appRoot = rtrim(normalize_path(dirname(__DIR__)), '/');
|
||||
|
||||
if ($documentRoot && strpos($appRoot, $documentRoot) === 0) {
|
||||
$relative = substr($appRoot, strlen($documentRoot));
|
||||
$relative = trim($relative, '/');
|
||||
$base = $relative === '' ? '' : '/' . $relative;
|
||||
} else {
|
||||
$base = '';
|
||||
}
|
||||
|
||||
return $base;
|
||||
}
|
||||
|
||||
function normalize_path(string $path): string
|
||||
{
|
||||
$real = realpath($path);
|
||||
|
||||
return str_replace('\\', '/', $real ?: $path);
|
||||
}
|
||||
|
||||
function redirect(string $path): void
|
||||
{
|
||||
if (preg_match('#^https?://#i', $path)) {
|
||||
$target = $path;
|
||||
} else {
|
||||
$target = url_for($path);
|
||||
}
|
||||
|
||||
header('Location: ' . $target);
|
||||
exit;
|
||||
}
|
||||
|
||||
function sanitize(?string $value, int $flags = ENT_QUOTES): string
|
||||
{
|
||||
return htmlspecialchars((string) $value, $flags, 'UTF-8');
|
||||
}
|
||||
|
||||
function add_flash(string $type, string $message): void
|
||||
{
|
||||
$_SESSION['flash'][$type][] = $message;
|
||||
}
|
||||
|
||||
function get_flashes(): array
|
||||
{
|
||||
$messages = $_SESSION['flash'] ?? [];
|
||||
unset($_SESSION['flash']);
|
||||
|
||||
return $messages;
|
||||
}
|
||||
|
||||
function is_post(): bool
|
||||
{
|
||||
return strtoupper($_SERVER['REQUEST_METHOD'] ?? '') === 'POST';
|
||||
}
|
||||
|
||||
function random_token(int $bytes = 8): string
|
||||
{
|
||||
return bin2hex(random_bytes($bytes));
|
||||
}
|
||||
|
||||
function format_datetime(?string $value, string $format = 'M d, Y h:i A'): string
|
||||
{
|
||||
if (!$value) {
|
||||
return '—';
|
||||
}
|
||||
$date = new DateTime($value);
|
||||
|
||||
return $date->format($format);
|
||||
}
|
||||
|
||||
function format_date(?string $value, string $format = 'M d, Y'): string
|
||||
{
|
||||
if (!$value) {
|
||||
return '—';
|
||||
}
|
||||
$date = new DateTime($value);
|
||||
|
||||
return $date->format($format);
|
||||
}
|
||||
|
||||
function selected(mixed $current, mixed $value): string
|
||||
{
|
||||
return (string) $current === (string) $value ? 'selected' : '';
|
||||
}
|
||||
|
||||
function checked(bool $condition): string
|
||||
{
|
||||
return $condition ? 'checked' : '';
|
||||
}
|
||||
|
||||
function paginate(PDO $pdo, string $countSql, array $params = [], int $perPage = 10): array
|
||||
{
|
||||
$page = max(1, (int) ($_GET['page'] ?? 1));
|
||||
$stmt = $pdo->prepare($countSql);
|
||||
$stmt->execute($params);
|
||||
$total = (int) $stmt->fetchColumn();
|
||||
$pages = max(1, (int) ceil($total / $perPage));
|
||||
$page = min($page, $pages);
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
return [
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'total' => $total,
|
||||
'pages' => $pages,
|
||||
'offset' => $offset,
|
||||
];
|
||||
}
|
||||
|
||||
function render_pagination(array $pagination, string $basePath = '', array $query = []): void
|
||||
{
|
||||
if (($pagination['pages'] ?? 1) <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
$base = $basePath ?: parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
||||
?>
|
||||
<nav aria-label="Pagination" class="mt-4">
|
||||
<ul class="pagination">
|
||||
<?php for ($i = 1; $i <= $pagination['pages']; $i++): ?>
|
||||
<?php
|
||||
$queryString = http_build_query(array_merge($query, ['page' => $i]));
|
||||
$href = $base . '?' . $queryString;
|
||||
?>
|
||||
<li class="page-item <?= $pagination['page'] === $i ? 'active' : '' ?>">
|
||||
<a class="page-link" href="<?= sanitize($href) ?>"><?= $i ?></a>
|
||||
</li>
|
||||
<?php endfor; ?>
|
||||
</ul>
|
||||
</nav>
|
||||
<?php
|
||||
}
|
||||
|
||||
function encode_record(array $record): string
|
||||
{
|
||||
return base64_encode(json_encode($record, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
function ensure_csrf_token(): string
|
||||
{
|
||||
if (!isset($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = random_token(16);
|
||||
}
|
||||
|
||||
return $_SESSION['csrf_token'];
|
||||
}
|
||||
|
||||
function validate_csrf_token(?string $token): bool
|
||||
{
|
||||
return isset($_SESSION['csrf_token']) && hash_equals($_SESSION['csrf_token'], (string) $token);
|
||||
}
|
||||
171
src/includes/layout.php
Normal file
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
function render_header(string $title, array $options = []): void
|
||||
{
|
||||
$active = $options['active'] ?? '';
|
||||
$hideNav = $options['hide_nav'] ?? false;
|
||||
$extraCss = $options['extra_css'] ?? [];
|
||||
$appName = config('app.name', 'QR Attendance');
|
||||
$user = current_user();
|
||||
$flashes = get_flashes();
|
||||
|
||||
$GLOBALS['__layout_hide_nav'] = $hideNav;
|
||||
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en" data-bs-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><?= sanitize($title) ?> | <?= sanitize($appName) ?></title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="<?= asset_url('css/style.css') ?>">
|
||||
<?php foreach ($extraCss as $css): ?>
|
||||
<link rel="stylesheet" href="<?= sanitize($css) ?>">
|
||||
<?php endforeach; ?>
|
||||
</head>
|
||||
<body>
|
||||
<?php if ($hideNav): ?>
|
||||
<main class="auth-shell container py-5">
|
||||
<?php else: ?>
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar bg-white border-end">
|
||||
<div class="sidebar-brand mb-4">
|
||||
<div class="sidebar-icon bg-primary-subtle text-primary">QR</div>
|
||||
<div>
|
||||
<div class="fw-semibold text-primary">QR Code Attendance</div>
|
||||
<small class="text-muted">Admin Console</small>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="sidebar-nav flex-grow-1">
|
||||
<?php foreach (nav_items() as $item): ?>
|
||||
<?php if (!$item['show']): continue; endif; ?>
|
||||
<a class="sidebar-link <?= $active === $item['key'] ? 'active' : '' ?>"
|
||||
href="<?= url_for($item['path']) ?>">
|
||||
<?= sanitize($item['label']) ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</nav>
|
||||
<?php if ($user): ?>
|
||||
<div class="sidebar-user mt-4">
|
||||
<div>
|
||||
<div class="fw-semibold"><?= sanitize($user['full_name']) ?></div>
|
||||
<small class="text-muted text-uppercase"><?= sanitize($user['role']) ?></small>
|
||||
</div>
|
||||
<a href="<?= url_for('auth/logout.php') ?>" class="btn btn-sm btn-outline-primary ms-auto">
|
||||
Logout
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</aside>
|
||||
<div class="content-area flex-grow-1">
|
||||
<header class="content-topbar bg-white border-bottom d-flex justify-content-between align-items-center px-4 py-3">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<button class="btn btn-outline-secondary btn-sm sidebar-toggle" type="button" data-sidebar-toggle>
|
||||
<span class="sidebar-toggle-icon">☰</span>
|
||||
</button>
|
||||
<div class="text-muted small">QR Attendance System</div>
|
||||
<h1 class="h5 mb-0 mb-0"><?= sanitize($title) ?></h1>
|
||||
</div>
|
||||
<span class="badge text-bg-light"><?= date('D, M j') ?></span>
|
||||
</header>
|
||||
<main class="content-body px-4 py-4">
|
||||
<?php endif; ?>
|
||||
<?php foreach ($flashes as $type => $messages): ?>
|
||||
<?php foreach ($messages as $message): ?>
|
||||
<div class="alert alert-<?= sanitize($type) ?> alert-dismissible fade show" role="alert">
|
||||
<?= sanitize($message) ?>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php endforeach; ?>
|
||||
<?php
|
||||
}
|
||||
|
||||
function render_footer(array $options = []): void
|
||||
{
|
||||
$extraJs = $options['extra_js'] ?? [];
|
||||
$hideNav = $GLOBALS['__layout_hide_nav'] ?? false;
|
||||
?>
|
||||
<?php if ($hideNav): ?>
|
||||
</main>
|
||||
<?php else: ?>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($extraJs as $script): ?>
|
||||
<script src="<?= sanitize($script) ?>"></script>
|
||||
<?php endforeach; ?>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="<?= asset_url('js/admin.js') ?>" defer></script>
|
||||
<script src="<?= asset_url('js/scan.js') ?>" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
<?php
|
||||
}
|
||||
|
||||
function nav_items(): array
|
||||
{
|
||||
$user = current_user();
|
||||
$isAdmin = $user && $user['role'] === 'admin';
|
||||
|
||||
return [
|
||||
[
|
||||
'key' => 'dashboard',
|
||||
'label' => 'Dashboard',
|
||||
'path' => 'admin/dashboard.php',
|
||||
'show' => (bool) $user,
|
||||
],
|
||||
[
|
||||
'key' => 'students',
|
||||
'label' => 'Students',
|
||||
'path' => 'admin/students.php',
|
||||
'show' => (bool) $user,
|
||||
],
|
||||
[
|
||||
'key' => 'activities',
|
||||
'label' => 'Activities',
|
||||
'path' => 'admin/activities.php',
|
||||
'show' => (bool) $user,
|
||||
],
|
||||
[
|
||||
'key' => 'courses',
|
||||
'label' => 'Courses',
|
||||
'path' => 'admin/courses.php',
|
||||
'show' => (bool) $user,
|
||||
],
|
||||
[
|
||||
'key' => 'departments',
|
||||
'label' => 'Departments',
|
||||
'path' => 'admin/departments.php',
|
||||
'show' => (bool) $user,
|
||||
],
|
||||
[
|
||||
'key' => 'attendance',
|
||||
'label' => 'Scan Center',
|
||||
'path' => 'admin/attendance.php',
|
||||
'show' => $isAdmin,
|
||||
],
|
||||
[
|
||||
'key' => 'reports',
|
||||
'label' => 'Reports',
|
||||
'path' => 'admin/reports.php',
|
||||
'show' => (bool) $user,
|
||||
],
|
||||
[
|
||||
'key' => 'settings',
|
||||
'label' => 'Settings',
|
||||
'path' => 'admin/settings.php',
|
||||
'show' => $isAdmin,
|
||||
],
|
||||
[
|
||||
'key' => 'users',
|
||||
'label' => 'Users',
|
||||
'path' => 'admin/users.php',
|
||||
'show' => $isAdmin,
|
||||
],
|
||||
];
|
||||
}
|
||||
38
src/includes/phpqrcode/CHANGELOG
Normal file
@@ -0,0 +1,38 @@
|
||||
* 1.0.0 build 2010031920
|
||||
|
||||
- first public release
|
||||
- help in readme, install
|
||||
- cleanup ans separation of QRtools and QRspec
|
||||
- now TCPDF binding requires minimal changes in TCPDF, having most of job
|
||||
done in QRtools tcpdfBarcodeArray
|
||||
- nicer QRtools::timeBenchmark output
|
||||
- license and copyright notices in files
|
||||
- indent cleanup - from tab to 4spc, keep it that way please :)
|
||||
- sf project, repository, wiki
|
||||
- simple code generator in index.php
|
||||
|
||||
* 1.1.0 build 2010032113
|
||||
|
||||
- added merge tool wich generate merged version of code
|
||||
located in phpqrcode.php
|
||||
- splited qrconst.php from qrlib.php
|
||||
|
||||
* 1.1.1 build 2010032405
|
||||
|
||||
- patch by Rick Seymour allowing saving PNG and displaying it at the same time
|
||||
- added version info in VERSION file
|
||||
- modified merge tool to include version info into generated file
|
||||
- fixed e-mail in almost all head comments
|
||||
|
||||
* 1.1.2 build 2010032722
|
||||
|
||||
- full integration with TCPDF thanks to Nicola Asuni, it's author
|
||||
- fixed bug with alphanumeric encoding detection
|
||||
|
||||
* 1.1.3 build 2010081807
|
||||
|
||||
- short opening tags replaced with standard ones
|
||||
|
||||
* 1.1.4 build 2010100721
|
||||
|
||||
- added missing static keyword QRinput::check (found by Luke Brookhart, Onjax LLC)
|
||||
67
src/includes/phpqrcode/INSTALL
Normal file
@@ -0,0 +1,67 @@
|
||||
== REQUIREMENTS ==
|
||||
|
||||
* PHP5
|
||||
* PHP GD2 extension with JPEG and PNG support
|
||||
|
||||
== INSTALLATION ==
|
||||
|
||||
If you want to recreate cache by yourself make sure cache directory is
|
||||
writable and you have permisions to write into it. Also make sure you are
|
||||
able to read files in it if you have cache option enabled
|
||||
|
||||
== CONFIGURATION ==
|
||||
|
||||
Feel free to modify config constants in qrconfig.php file. Read about it in
|
||||
provided comments and project wiki page (links in README file)
|
||||
|
||||
== QUICK START ==
|
||||
|
||||
Notice: probably you should'nt use all of this in same script :)
|
||||
|
||||
<?phpb
|
||||
|
||||
//include only that one, rest required files will be included from it
|
||||
include "qrlib.php"
|
||||
|
||||
//write code into file, Error corection lecer is lowest, L (one form: L,M,Q,H)
|
||||
//each code square will be 4x4 pixels (4x zoom)
|
||||
//code will have 2 code squares white boundary around
|
||||
|
||||
QRcode::png('PHP QR Code :)', 'test.png', 'L', 4, 2);
|
||||
|
||||
//same as above but outputs file directly into browser (with appr. header etc.)
|
||||
//all other settings are default
|
||||
//WARNING! it should be FIRST and ONLY output generated by script, otherwise
|
||||
//rest of output will land inside PNG binary, breaking it for sure
|
||||
QRcode::png('PHP QR Code :)');
|
||||
|
||||
//show benchmark
|
||||
QRtools::timeBenchmark();
|
||||
|
||||
//rebuild cache
|
||||
QRtools::buildCache();
|
||||
|
||||
//code generated in text mode - as a binary table
|
||||
//then displayed out as HTML using Unicode block building chars :)
|
||||
$tab = $qr->encode('PHP QR Code :)');
|
||||
QRspec::debug($tab, true);
|
||||
|
||||
== TCPDF INTEGRATION ==
|
||||
|
||||
Inside bindings/tcpdf you will find slightly modified 2dbarcodes.php.
|
||||
Instal phpqrcode liblaty inside tcpdf folder, then overwrite (or merge)
|
||||
2dbarcodes.php
|
||||
|
||||
Then use similar as example #50 from TCPDF examples:
|
||||
|
||||
<?php
|
||||
|
||||
$style = array(
|
||||
'border' => true,
|
||||
'padding' => 4,
|
||||
'fgcolor' => array(0,0,0),
|
||||
'bgcolor' => false, //array(255,255,255)
|
||||
);
|
||||
|
||||
//code name: QR, specify error correction level after semicolon (L,M,Q,H)
|
||||
$pdf->write2DBarcode('PHP QR Code :)', 'QR,L', '', '', 30, 30, $style, 'N');
|
||||
165
src/includes/phpqrcode/LICENSE
Normal file
@@ -0,0 +1,165 @@
|
||||
GNU LESSER GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
|
||||
This version of the GNU Lesser General Public License incorporates
|
||||
the terms and conditions of version 3 of the GNU General Public
|
||||
License, supplemented by the additional permissions listed below.
|
||||
|
||||
0. Additional Definitions.
|
||||
|
||||
As used herein, "this License" refers to version 3 of the GNU Lesser
|
||||
General Public License, and the "GNU GPL" refers to version 3 of the GNU
|
||||
General Public License.
|
||||
|
||||
"The Library" refers to a covered work governed by this License,
|
||||
other than an Application or a Combined Work as defined below.
|
||||
|
||||
An "Application" is any work that makes use of an interface provided
|
||||
by the Library, but which is not otherwise based on the Library.
|
||||
Defining a subclass of a class defined by the Library is deemed a mode
|
||||
of using an interface provided by the Library.
|
||||
|
||||
A "Combined Work" is a work produced by combining or linking an
|
||||
Application with the Library. The particular version of the Library
|
||||
with which the Combined Work was made is also called the "Linked
|
||||
Version".
|
||||
|
||||
The "Minimal Corresponding Source" for a Combined Work means the
|
||||
Corresponding Source for the Combined Work, excluding any source code
|
||||
for portions of the Combined Work that, considered in isolation, are
|
||||
based on the Application, and not on the Linked Version.
|
||||
|
||||
The "Corresponding Application Code" for a Combined Work means the
|
||||
object code and/or source code for the Application, including any data
|
||||
and utility programs needed for reproducing the Combined Work from the
|
||||
Application, but excluding the System Libraries of the Combined Work.
|
||||
|
||||
1. Exception to Section 3 of the GNU GPL.
|
||||
|
||||
You may convey a covered work under sections 3 and 4 of this License
|
||||
without being bound by section 3 of the GNU GPL.
|
||||
|
||||
2. Conveying Modified Versions.
|
||||
|
||||
If you modify a copy of the Library, and, in your modifications, a
|
||||
facility refers to a function or data to be supplied by an Application
|
||||
that uses the facility (other than as an argument passed when the
|
||||
facility is invoked), then you may convey a copy of the modified
|
||||
version:
|
||||
|
||||
a) under this License, provided that you make a good faith effort to
|
||||
ensure that, in the event an Application does not supply the
|
||||
function or data, the facility still operates, and performs
|
||||
whatever part of its purpose remains meaningful, or
|
||||
|
||||
b) under the GNU GPL, with none of the additional permissions of
|
||||
this License applicable to that copy.
|
||||
|
||||
3. Object Code Incorporating Material from Library Header Files.
|
||||
|
||||
The object code form of an Application may incorporate material from
|
||||
a header file that is part of the Library. You may convey such object
|
||||
code under terms of your choice, provided that, if the incorporated
|
||||
material is not limited to numerical parameters, data structure
|
||||
layouts and accessors, or small macros, inline functions and templates
|
||||
(ten or fewer lines in length), you do both of the following:
|
||||
|
||||
a) Give prominent notice with each copy of the object code that the
|
||||
Library is used in it and that the Library and its use are
|
||||
covered by this License.
|
||||
|
||||
b) Accompany the object code with a copy of the GNU GPL and this license
|
||||
document.
|
||||
|
||||
4. Combined Works.
|
||||
|
||||
You may convey a Combined Work under terms of your choice that,
|
||||
taken together, effectively do not restrict modification of the
|
||||
portions of the Library contained in the Combined Work and reverse
|
||||
engineering for debugging such modifications, if you also do each of
|
||||
the following:
|
||||
|
||||
a) Give prominent notice with each copy of the Combined Work that
|
||||
the Library is used in it and that the Library and its use are
|
||||
covered by this License.
|
||||
|
||||
b) Accompany the Combined Work with a copy of the GNU GPL and this license
|
||||
document.
|
||||
|
||||
c) For a Combined Work that displays copyright notices during
|
||||
execution, include the copyright notice for the Library among
|
||||
these notices, as well as a reference directing the user to the
|
||||
copies of the GNU GPL and this license document.
|
||||
|
||||
d) Do one of the following:
|
||||
|
||||
0) Convey the Minimal Corresponding Source under the terms of this
|
||||
License, and the Corresponding Application Code in a form
|
||||
suitable for, and under terms that permit, the user to
|
||||
recombine or relink the Application with a modified version of
|
||||
the Linked Version to produce a modified Combined Work, in the
|
||||
manner specified by section 6 of the GNU GPL for conveying
|
||||
Corresponding Source.
|
||||
|
||||
1) Use a suitable shared library mechanism for linking with the
|
||||
Library. A suitable mechanism is one that (a) uses at run time
|
||||
a copy of the Library already present on the user's computer
|
||||
system, and (b) will operate properly with a modified version
|
||||
of the Library that is interface-compatible with the Linked
|
||||
Version.
|
||||
|
||||
e) Provide Installation Information, but only if you would otherwise
|
||||
be required to provide such information under section 6 of the
|
||||
GNU GPL, and only to the extent that such information is
|
||||
necessary to install and execute a modified version of the
|
||||
Combined Work produced by recombining or relinking the
|
||||
Application with a modified version of the Linked Version. (If
|
||||
you use option 4d0, the Installation Information must accompany
|
||||
the Minimal Corresponding Source and Corresponding Application
|
||||
Code. If you use option 4d1, you must provide the Installation
|
||||
Information in the manner specified by section 6 of the GNU GPL
|
||||
for conveying Corresponding Source.)
|
||||
|
||||
5. Combined Libraries.
|
||||
|
||||
You may place library facilities that are a work based on the
|
||||
Library side by side in a single library together with other library
|
||||
facilities that are not Applications and are not covered by this
|
||||
License, and convey such a combined library under terms of your
|
||||
choice, if you do both of the following:
|
||||
|
||||
a) Accompany the combined library with a copy of the same work based
|
||||
on the Library, uncombined with any other library facilities,
|
||||
conveyed under the terms of this License.
|
||||
|
||||
b) Give prominent notice with the combined library that part of it
|
||||
is a work based on the Library, and explaining where to find the
|
||||
accompanying uncombined form of the same work.
|
||||
|
||||
6. Revised Versions of the GNU Lesser General Public License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions
|
||||
of the GNU Lesser General Public License from time to time. Such new
|
||||
versions will be similar in spirit to the present version, but may
|
||||
differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Library as you received it specifies that a certain numbered version
|
||||
of the GNU Lesser General Public License "or any later version"
|
||||
applies to it, you have the option of following the terms and
|
||||
conditions either of that published version or of any later version
|
||||
published by the Free Software Foundation. If the Library as you
|
||||
received it does not specify a version number of the GNU Lesser
|
||||
General Public License, you may choose any version of the GNU Lesser
|
||||
General Public License ever published by the Free Software Foundation.
|
||||
|
||||
If the Library as you received it specifies that a proxy can decide
|
||||
whether future versions of the GNU Lesser General Public License shall
|
||||
apply, that proxy's public statement of acceptance of any version is
|
||||
permanent authorization for you to choose that version for the
|
||||
Library.
|
||||
45
src/includes/phpqrcode/README
Normal file
@@ -0,0 +1,45 @@
|
||||
This is PHP implementation of QR Code 2-D barcode generator. It is pure-php
|
||||
LGPL-licensed implementation based on C libqrencode by Kentaro Fukuchi.
|
||||
|
||||
== LICENSING ==
|
||||
|
||||
Copyright (C) 2010 by Dominik Dzienia
|
||||
|
||||
This library is free software; you can redistribute it and/or modify it under
|
||||
the terms of the GNU Lesser General Public License as published by the Free
|
||||
Software Foundation; either version 3 of the License, or any later version.
|
||||
|
||||
This library is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. See the GNU Lesser General Public License (LICENSE file)
|
||||
for more details.
|
||||
|
||||
You should have received a copy of the GNU Lesser General Public License along
|
||||
with this library; if not, write to the Free Software Foundation, Inc., 51
|
||||
Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
== INSTALATION AND USAGE ==
|
||||
|
||||
* INSTALL file
|
||||
* http://sourceforge.net/apps/mediawiki/phpqrcode/index.php?title=Main_Page
|
||||
|
||||
== CONTACT ==
|
||||
|
||||
Fell free to contact me via e-mail (deltalab at poczta dot fm) or using
|
||||
folowing project pages:
|
||||
|
||||
* http://sourceforge.net/projects/phpqrcode/
|
||||
* http://phpqrcode.sourceforge.net/
|
||||
|
||||
== ACKNOWLEDGMENTS ==
|
||||
|
||||
Based on C libqrencode library (ver. 3.1.1)
|
||||
Copyright (C) 2006-2010 by Kentaro Fukuchi
|
||||
http://megaui.net/fukuchi/works/qrencode/index.en.html
|
||||
|
||||
QR Code is registered trademarks of DENSO WAVE INCORPORATED in JAPAN and other
|
||||
countries.
|
||||
|
||||
Reed-Solomon code encoder is written by Phil Karn, KA9Q.
|
||||
Copyright (C) 2002, 2003, 2004, 2006 Phil Karn, KA9Q
|
||||
|
||||
2
src/includes/phpqrcode/VERSION
Normal file
@@ -0,0 +1,2 @@
|
||||
1.1.4
|
||||
2010100721
|
||||
2875
src/includes/phpqrcode/bindings/tcpdf/qrcode.php
Normal file
2
src/includes/phpqrcode/cache/frame_1.dat
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
xڝ<EFBFBD><EFBFBD>
|
||||
<EFBFBD> E9<45>u<06><>`<60>"PńC<C584>牗T!0$
|
||||
BIN
src/includes/phpqrcode/cache/frame_1.png
vendored
Normal file
|
After Width: | Height: | Size: 126 B |
BIN
src/includes/phpqrcode/cache/frame_10.dat
vendored
Normal file
BIN
src/includes/phpqrcode/cache/frame_10.png
vendored
Normal file
|
After Width: | Height: | Size: 202 B |
BIN
src/includes/phpqrcode/cache/frame_11.dat
vendored
Normal file
BIN
src/includes/phpqrcode/cache/frame_11.png
vendored
Normal file
|
After Width: | Height: | Size: 205 B |
BIN
src/includes/phpqrcode/cache/frame_12.dat
vendored
Normal file
BIN
src/includes/phpqrcode/cache/frame_12.png
vendored
Normal file
|
After Width: | Height: | Size: 216 B |
BIN
src/includes/phpqrcode/cache/frame_13.dat
vendored
Normal file
BIN
src/includes/phpqrcode/cache/frame_13.png
vendored
Normal file
|
After Width: | Height: | Size: 210 B |
BIN
src/includes/phpqrcode/cache/frame_14.dat
vendored
Normal file
BIN
src/includes/phpqrcode/cache/frame_14.png
vendored
Normal file
|
After Width: | Height: | Size: 213 B |
BIN
src/includes/phpqrcode/cache/frame_15.dat
vendored
Normal file
BIN
src/includes/phpqrcode/cache/frame_15.png
vendored
Normal file
|
After Width: | Height: | Size: 219 B |
1
src/includes/phpqrcode/cache/frame_16.dat
vendored
Normal file
@@ -0,0 +1 @@
|
||||
x<EFBFBD><EFBFBD><EFBFBD>A<0E> E]s<>IX<49>;<3B><01>n6<6E><36>`<60>q<EFBFBD><71><EFBFBD>W6<57><36><EFBFBD><04>`<60>%A/3!<21><><EFBFBD><EFBFBD><EFBFBD>!g<><67>̡<EFBFBD>1N)<0B>E<EFBFBD><45>|;<3B><>>6⸏<36>97$<0E><><EFBFBD><EFBFBD>c]kk<6B><6B>w<EFBFBD>1<EFBFBD><31>[<5B>m<EFBFBD>C͜c<CD9C>R<><52><EFBFBD><EFBFBD>><3E><><1A><><EFBFBD>E,<2C>hʼnp<C589>#<1C>xF<1C>yW<79><57>VWG<57><47><EFBFBD>3<EFBFBD><33>+<2B><0F><><EFBFBD>˓<EFBFBD>S<EFBFBD><53>}Ğ<>#<1C>G8b^c^c<><63><11>p<EFBFBD>c&3YQ"<11><1B><><EFBFBD><EFBFBD>v<EFBFBD><76><11><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>k<EFBFBD>9<EFBFBD>܇<EFBFBD>}<7D><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <09>Ŀ<EFBFBD>Q<><51>L<EFBFBD>/<2F><><EFBFBD><EFBFBD>
|
||||
BIN
src/includes/phpqrcode/cache/frame_16.png
vendored
Normal file
|
After Width: | Height: | Size: 211 B |
BIN
src/includes/phpqrcode/cache/frame_17.dat
vendored
Normal file
BIN
src/includes/phpqrcode/cache/frame_17.png
vendored
Normal file
|
After Width: | Height: | Size: 211 B |
2
src/includes/phpqrcode/cache/frame_18.dat
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
x<EFBFBD><EFBFBD><EFBFBD>A
|
||||
<EFBFBD>0E]<5D>օ,2;s<><73>&<26>͚h<14><1D><>O<EFBFBD><4F><EFBFBD><7F><EFBFBD><EFBFBD>1&09OIv@DD<44><0C>&<26>ىK<D989>X<EFBFBD><58>Fv<46><<11>dq<64>9<><%h<><68>Y<>s!(d<><64><EFBFBD>s;~||b(<28><>Yůg#<23>`<60>K<16><>S<EFBFBD><53><EFBFBD><EFBFBD>Ķ<EFBFBD><C4B6>s<1C>idߍLg:ә<>t<EFBFBD>/gm<67><6D><EFBFBD><EFBFBD>k<EFBFBD>M<>3<EFBFBD>{<7B>4rT<72>Q<EFBFBD><51>e<EFBFBD><65>s<EFBFBD>><3E><ә<>t<EFBFBD>3<EFBFBD><33><EFBFBD>;<12>H<EFBFBD>#љ<>t<EFBFBD>3<EFBFBD><EFBFBD>Y<EFBFBD>+og<>h<EFBFBD><68><EFBFBD><EFBFBD>ٽ<EFBFBD>ln<6C><6E>F><3E>i^<5E>#awm;g<>~p<>g<EFBFBD>Ns{6z<36><7A><EFBFBD><EFBFBD><19><><EFBFBD><EFBFBD>p<><70>'
|
||||
BIN
src/includes/phpqrcode/cache/frame_18.png
vendored
Normal file
|
After Width: | Height: | Size: 228 B |
3
src/includes/phpqrcode/cache/frame_19.dat
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
x<EFBFBD><EFBFBD><EFBFBD>A
|
||||
<EFBFBD> E<><45><EFBFBD>.<2E>No<4E>7ћ<37><D19B>iiR<>N2<4E><32>W%<25>x<04>@<40>ڜ<EFBFBD>'<27>
|
||||
u<EFBFBD>6<EFBFBD><EFBFBD><EFBFBD>.<2E>*S;}<7D><><EFBFBD>à<EFBFBD>T<0B><><EFBFBD>zr<>t<><74>%<25>,<2C><><EFBFBD><EFBFBD><EFBFBD>}<7D>;<3B><><EFBFBD>)<29><><EFBFBD><EFBFBD><EFBFBD>Z<EFBFBD><5A>L<EFBFBD><4C><EFBFBD><EFBFBD><EFBFBD>P<EFBFBD><50>$<24><><EFBFBD><1E>q<EFBFBD>g<EFBFBD>L<EFBFBD><4C>dJ<64>;<3B><>w<><77><EFBFBD>.]z#<23><><EFBFBD>[͝<><CD9D>Og<4F><67><EFBFBD><EFBFBD>"<22><> <09>B<EFBFBD><17><>}<7D>}<7D>;<3B><>w<><77><1D><>#1Gb<47><62>;<3B><>w<><77><EFBFBD>_<EFBFBD>C+w<>@Df<44><04><><EFBFBD><EFBFBD>u<EFBFBD><75>2<EFBFBD><32><EFBFBD><EFBFBD>N<EFBFBD><4E>9R7|pW<70>k<EFBFBD><6B><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>k<><6B><EFBFBD><07><><1C><><1C><>
|
||||
BIN
src/includes/phpqrcode/cache/frame_19.png
vendored
Normal file
|
After Width: | Height: | Size: 225 B |
1
src/includes/phpqrcode/cache/frame_2.dat
vendored
Normal file
@@ -0,0 +1 @@
|
||||
x<EFBFBD>͒<EFBFBD>
|
||||
BIN
src/includes/phpqrcode/cache/frame_2.png
vendored
Normal file
|
After Width: | Height: | Size: 144 B |
BIN
src/includes/phpqrcode/cache/frame_20.dat
vendored
Normal file
BIN
src/includes/phpqrcode/cache/frame_20.png
vendored
Normal file
|
After Width: | Height: | Size: 225 B |
1
src/includes/phpqrcode/cache/frame_21.dat
vendored
Normal file
@@ -0,0 +1 @@
|
||||
x<EFBFBD><EFBFBD><EFBFBD>A<0E> E]s<>IX<49>;<3B><01>n6Up<55><13><>в<EFBFBD><D0B2>]٘<><i-eW<65><57><EFBFBD><EFBFBD>)<29><>ŕ<EFBFBD><C595>
H\jvq<76>HL\6<><36><EFBFBD>ЅrI<72><06>Lܹ<4C><DCB9>%<25><18>@<40><><EFBFBD>V<EFBFBD>v<EFBFBD><76><EFBFBD><EFBFBD><EFBFBD>(<28>P4|<7C>Xn<58>gɝ<><15>~]D<><44><EFBFBD><EFBFBD>u1Us S\<5C><16><>,<2C><>2<EFBFBD><1F>N<EFBFBD><4E>?D<>K<EFBFBD><4B>F-:<3A>eJ]p_<70><16><>,<2C>a0<61>`<60><><EFBFBD>X<><16>`<60><><0C>w,`X<>]<5D><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>5<0B><>Y4{<7B><><EFBFBD><EFBFBD>2<EFBFBD><32><EFBFBD>v<EFBFBD>Js<4A><73><EFBFBD><EFBFBD>9<EFBFBD><39><EFBFBD>)<29>u<EFBFBD>۹<EFBFBD><DBB9><EFBFBD>,<17>]<5D><><EFBFBD><EFBFBD>^_<>7$<24>_<EFBFBD>
|
||||
BIN
src/includes/phpqrcode/cache/frame_21.png
vendored
Normal file
|
After Width: | Height: | Size: 235 B |
3
src/includes/phpqrcode/cache/frame_22.dat
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
x<EFBFBD><EFBFBD><EFBFBD>A
|
||||
<EFBFBD>0E]{<7B><>.<2E>]{{{<7B><>Z<EFBFBD>Bep<65><06>we@<1F>V<EFBFBD>ERZ3<5A><33>"*2o<32>4<EFBFBD>y<EFBFBD>)i#d<>bdF҅<46><12>I"<22><><14>4<EFBFBD><34>W<17>I<EFBFBD>u<EFBFBD><75>45<34>x<EFBFBD>.Z<>S<EFBFBD>{<7B><><EFBFBD>8<EFBFBD><38><07>k={o.<2E>q<EFBFBD><71><01>[<13><>:帒q<E5B892><71><EFBFBD>y
|
||||
)t#<23><>N8<4E>dCj<43>-O<>OG}<7D>:/<2F>:s<>z!<21>)^<<3C>e<EFBFBD><65>S<EFBFBD>u<EFBFBD>{<7B> '<27>p<EFBFBD> '<27>=<3D>=<3D>=<3D>'<27>p<EFBFBD> '<27>p<EFBFBD>ߣߣ<DFA3><DFA3><1F>N8<4E><38><EFBFBD><EFBFBD>9<EFBFBD><39><EFBFBD><EFBFBD>pQQ<51>]H<19>pz<70><7A><EFBFBD>G<EFBFBD>^<5E><>Q<EFBFBD><51>I|<7C>߳<EFBFBD>u;9<><39><EFBFBD><EFBFBD><EFBFBD>d;<3B>X~$<24><><EFBFBD><13>t<1E><><1B><>dy
|
||||
BIN
src/includes/phpqrcode/cache/frame_22.png
vendored
Normal file
|
After Width: | Height: | Size: 226 B |
3
src/includes/phpqrcode/cache/frame_23.dat
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
x<EFBFBD><EFBFBD><EFBFBD>A
|
||||
<EFBFBD> E<><45><EFBFBD>fo<>7ћU<D19B>) %M!Δ<><CE94>Yu(<<1E><><17>sK<73><4B>T<EFBFBD><54><EFBFBD>
|
||||
<EFBFBD>&<26>I<>\i+<2B>Ъ<EFBFBD>(m<><6D>FQ<46><51><EFBFBD>h<EFBFBD><68><EFBFBD><EFBFBD><EFBFBD>v~n1<6E>o<>]s<><73><EFBFBD><EFBFBD><EFBFBD>_ޟ<18>3`<60>_w2<77>ȹ<EFBFBD>lc[<5B><>;<3B><12>c֟ˤ<D69F>N<EFBFBD><4E>4<EFBFBD>p<EFBFBD>
|
||||
BIN
src/includes/phpqrcode/cache/frame_23.png
vendored
Normal file
|
After Width: | Height: | Size: 220 B |
1
src/includes/phpqrcode/cache/frame_24.dat
vendored
Normal file
@@ -0,0 +1 @@
|
||||
x<EFBFBD><EFBFBD><EFBFBD>A<0E> E<><45><EFBFBD>MX0;<3B><><EFBFBD>nVP4<50>HSS<19>x<EFBFBD>U3<55>/O<><4F>LiJ4<4A><34><EFBFBD>V<EFBFBD>JC<4A>%<25><>6VR&<16><>D<EFBFBD>B<EFBFBD>HjD<6A><44>J<0E>??<3F><><EFBFBD>Bl<42>cDZ<><C7B1><EFBFBD>'<27>U<EFBFBD><55>X<EFBFBD>U<EFBFBD>ޏ0<DE8F><30>yw<79>į<EFBFBD>j<EFBFBD><6A>똳<EFBFBD>3ś<33><C59B><EFBFBD>cj<63><6A><EFBFBD>{<7B><><12>:Gq<>G<1C><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><0E>N<EFBFBD>v;<3B><>笓J<0C><><EFBFBD><<3C><><EFBFBD>]<5D><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>#<23>8<EFBFBD><38>#<23>8<EFBFBD>H'<27><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Gq<>G<1C><>tr:9<>#<23>8<EFBFBD><38>#<23>8<EFBFBD>ؓh<D893><68><15>N<EFBFBD>t<EFBFBD><74><EFBFBD><EFBFBD>_<EFBFBD><5F>>t<>e<EFBFBD><65>S<EFBFBD><53><EFBFBD><EFBFBD><EFBFBD><EFBFBD>^<5E>\g<><67><EFBFBD>Qe?<3F>vu<><75>o<EFBFBD><6F>;<3B><1A>><3E><>*<2A>wl<77><02>m<>
|
||||
BIN
src/includes/phpqrcode/cache/frame_24.png
vendored
Normal file
|
After Width: | Height: | Size: 242 B |
3
src/includes/phpqrcode/cache/frame_25.dat
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
x<EFBFBD><EFBFBD><EFBFBD>A
|
||||
<EFBFBD> <10><><EFBFBD>s낋<73>]r<>x<13>Y51mM<>BG
|
||||
<EFBFBD><EFBFBD>*Sx|Ua5Ƶ<35>Z<><5A><EFBFBD>-,<2C>1<EFBFBD><31>H<15>P<EFBFBD>Rj<52><6A>X5<58><35>i<EFBFBD><69><EFBFBD><EFBFBD>G<EFBFBD>>W<><57><EFBFBD>R<EFBFBD><52><EFBFBD>/<2F><>+uT廯<54><0C>ӯ嗴<D3AF>u<EFBFBD><75><0E><>[S<>a<EFBFBD>[kv<6B><76>5<EFBFBD>+5n<1F><><EFBFBD>J<EFBFBD><4A>%+V<>X<EFBFBD>bŊ<62>߬u'<27><07><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>SR<53><52><EFBFBD><EFBFBD>tzZ<7A><5A>+<2B>+V<>X<EFBFBD>bŊ<62>ٟٟٟ<D99F><D99F>+V<>X<EFBFBD>b<EFBFBD><62><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>}Ŋ+V<>X<EFBFBD><58><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>VI<56><49><EFBFBD><EFBFBD><15>+k<>q<>[<5B><>t<1E><>oVZ<56><5A>voNV<4E>w<1C>}<7D>{<7B>r<ýR<C3BD><52>"<22>R<EFBFBD><52>]<1D>
|
||||
BIN
src/includes/phpqrcode/cache/frame_25.png
vendored
Normal file
|
After Width: | Height: | Size: 242 B |
2
src/includes/phpqrcode/cache/frame_26.dat
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
x<EFBFBD><EFBFBD><EFBFBD>A
|
||||
<EFBFBD> E<><45>օ,t<>7<EFBFBD>7ћU<D19B> E)i7<><37>*~c<><63><EFBFBD><EFBFBD>X<14>EB<45><42><EFBFBD>FC<><43><EFBFBD>6<EFBFBD>:&<26>L,<2C><>Mv.<2E><><EFBFBD><EFBFBD>Kg<4B>ո<EFBFBD>YM<59>><3E><><EFBFBD>><3E>mۚ<6D>?<3F><>v<><76><EFBFBD>mg?<3F><>ұ<EFBFBD><D2B1><EFBFBD><EFBFBD>η<1D>d<EFBFBD><64>C<><16>U<EFBFBD><55>Ik<49><6B><EFBFBD>E\<5C><>Ms<4D>f<>a<EFBFBD>f<>a><3E>[sӈ9쬩ެ8b<38><k<><6B>7<EFBFBD>}<7D><>k<><6B><EFBFBD><EFBFBD><EFBFBD><EFBFBD>3<EFBFBD>0<EFBFBD>3<>0<EFBFBD>3<><33>*r<15><>\<5C>7f<>a<EFBFBD>f<>a<EFBFBD>fr<15><>\<5C>7f<>a<EFBFBD>f<>a<EFBFBD>Y<EFBFBD><59><18><><0C>d<EFBFBD>4<EFBFBD>9k<><6B><EFBFBD><EFBFBD><EFBFBD>y<EFBFBD>X y<>g<EFBFBD><67><EFBFBD>)<1B><>dw<64>n̢<6E>U<EFBFBD>><3E><><EFBFBD>]<5D><>Lg<4C><67><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Eo<45> w1
|
||||
BIN
src/includes/phpqrcode/cache/frame_26.png
vendored
Normal file
|
After Width: | Height: | Size: 244 B |
BIN
src/includes/phpqrcode/cache/frame_27.dat
vendored
Normal file
BIN
src/includes/phpqrcode/cache/frame_27.png
vendored
Normal file
|
After Width: | Height: | Size: 237 B |
BIN
src/includes/phpqrcode/cache/frame_28.dat
vendored
Normal file
BIN
src/includes/phpqrcode/cache/frame_28.png
vendored
Normal file
|
After Width: | Height: | Size: 234 B |
2
src/includes/phpqrcode/cache/frame_29.dat
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
x<EFBFBD><EFBFBD><EFBFBD>A<0E> <10>a<EFBFBD> <09><><EFBFBD><EFBFBD>@n7+*<2A><><EFBFBD><EFBFBD>4<EFBFBD>!<21>?<3F>J<EFBFBD><4A><EFBFBD> <09><><EFBFBD>抮<EFBFBD>]<5D><1A><>S<EFBFBD><53>Tf)<29><>s<EFBFBD>I<EFBFBD>"<22>Ȕb<C894><62>0<EFBFBD><30>|<7C>"Luٸ<75>,<2C><>E<18>1\6<>*<2A>uQ<75>?<3F>>a<>υ<EFBFBD><CF85><EFBFBD><EFBFBD><EFBFBD>R<EFBFBD>-r<><72><EFBFBD>n.<2E>ꯋ\<5C>T<EFBFBD><54>:<3A>*)|)<29><>,<2C><>,<2C><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>x_<78><5F>}:^R<><52>Uoɢ<6F>u<EFBFBD>~<7E>މX`<60>XЏЏЏЏ<D08F>_`<60>X`<60>XЏЏЏ<D08F>_`<60>X`<60>XЏЏЏЏ<D08F>wb<77>X`<60><16><>PU<><55>)D<><44>"c<>{<7B>z<EFBFBD><7A><EFBFBD>3<EFBFBD><33><EFBFBD><}<7D><><EFBFBD>^?b<>m<EFBFBD><6D><EFBFBD>잃<EFBFBD><EC9E83><EFBFBD><EFBFBD><EFBFBD>a<EFBFBD><61><EFBFBD><EFBFBD><EFBFBD><EFBFBD>.<2E>]
|
||||
<EFBFBD>{Q6u<07>T,9
|
||||
BIN
src/includes/phpqrcode/cache/frame_29.png
vendored
Normal file
|
After Width: | Height: | Size: 232 B |
1
src/includes/phpqrcode/cache/frame_3.dat
vendored
Normal file
@@ -0,0 +1 @@
|
||||
x<EFBFBD><EFBFBD><EFBFBD><EFBFBD>
|
||||
BIN
src/includes/phpqrcode/cache/frame_3.png
vendored
Normal file
|
After Width: | Height: | Size: 147 B |
BIN
src/includes/phpqrcode/cache/frame_30.dat
vendored
Normal file
BIN
src/includes/phpqrcode/cache/frame_30.png
vendored
Normal file
|
After Width: | Height: | Size: 255 B |
1
src/includes/phpqrcode/cache/frame_31.dat
vendored
Normal file
@@ -0,0 +1 @@
|
||||
x<EFBFBD><EFBFBD><EFBFBD>A<0E> <10>a<EFBFBD> <0B><>
|
||||
BIN
src/includes/phpqrcode/cache/frame_31.png
vendored
Normal file
|
After Width: | Height: | Size: 260 B |
2
src/includes/phpqrcode/cache/frame_32.dat
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
x<EFBFBD><EFBFBD><EFBFBD><EFBFBD>
|
||||
<EFBFBD> <14><>־<EFBFBD><D6BE><EFBFBD><EFBFBD>.<2E> <20>D<EFBFBD>l<EFBFBD>,<0C><>Mz<4D><7A>6<EFBFBD><10>Ç gcJ<63>D;<3B>'.<2E>A<EFBFBD>Iq<49>މ<EFBFBD>I,Ir<49>Y<EFBFBD><59><EFBFBD><EFBFBD>Fk%<25>D<EFBFBD>O<14>y|ED<45>D<EFBFBD><44>(L<>_Y<5F><59>>*ߚ?a<>O<EFBFBD><15>k<7F>L_<4C><[c<><63><EFBFBD><EFBFBD>><3E>c˘<63>u<1C>LI<4C><49>%<25>#<23>0<EFBFBD>#<23>0<EFBFBD>#<23><>otѢ<74><D1A2><EFBFBD>}<7D><>4<EFBFBD>f<EFBFBD>v_)<29><>E<EFBFBD>p<EFBFBD><1F><>h5R<35><52>8<EFBFBD>8<EFBFBD>1<EFBFBD>#<23>0<EFBFBD>#<23>0<EFBFBD><30><EFBFBD>i<EFBFBD><69>tZ<74>#<23>0<EFBFBD>#<23>0<EFBFBD>#<23>0<EFBFBD><30><EFBFBD>i<EFBFBD><69>tZ<74>#<23>0<EFBFBD>#<23>0<EFBFBD>#<23>0<EFBFBD><30><EFBFBD>i<EFBFBD><69>tZ<74>l<EFBFBD>0<EFBFBD>#<23>0<EFBFBD><08><>9q"<22><>HܜH<DC9C>Q<EFBFBD><51><1B><>"<22><>L5}-<2D><>Y<59><D7BE><EFBFBD>k<EFBFBD>`<60><>><3E>z鸳<><E9B8B3><EFBFBD>4&<26>p<EFBFBD><70>!<21><><EFBFBD>!<21><>`<60>:5
|
||||
BIN
src/includes/phpqrcode/cache/frame_32.png
vendored
Normal file
|
After Width: | Height: | Size: 262 B |
14
src/includes/phpqrcode/cache/frame_33.dat
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
x<EFBFBD><EFBFBD><EFBFBD>A<0E> <10>a<EFBFBD><EFBFBD><DEBA><EFBFBD><EFBFBD><EFBFBD>@n7+*L++<2B>柮<12><><05><>bb<62>*LC<4C><12><><EFBFBD><EFBFBD>ck<>H<EFBFBD>r<><72>j<EFBFBD><6A><EFBFBD>J5Y<35>i~0<>_<EFBFBD><5F><EFBFBD><EFBFBD><EFBFBD>T<EFBFBD>T<EFBFBD>}<7D><>e<EFBFBD>><3E><>5<EFBFBD>b_<62>w<EFBFBD>͟?<3F><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><0E>\<5C><>Ra<>i+7<><37>W<EFBFBD><57>\<5C><>wLUN<55>L<EFBFBD><4C>
|
||||
+<2B><><EFBFBD>
|
||||
+<2B><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>j<EFBFBD><6A>O<4F><7F>kc<6B><63><EFBFBD><EFBFBD><1D>\˩|%<25>o<<3C><>k<EFBFBD><6B>L<EFBFBD>+<2B>+<2B>v<EFBFBD><76><EFBFBD>
|
||||
+<2B><><EFBFBD>
|
||||
+<2B><>>}<06><0C>8<><38><EFBFBD>
|
||||
+<2B><><EFBFBD>
|
||||
+<2B><><EFBFBD>
|
||||
+<2B><0C><19>3<EFBFBD>g<EFBFBD><67><EFBFBD>
|
||||
+<2B><><EFBFBD>
|
||||
+<2B><><EFBFBD>
|
||||
+<2B><>3<EFBFBD>g<EFBFBD><67>@<40><><EFBFBD>
|
||||
+<2B><><EFBFBD>
|
||||
+<2B><><EFBFBD>
|
||||
+<2B><>:R<><52><EFBFBD>X<EFBFBD><58>B<EFBFBD>9<EFBFBD><39>I<EFBFBD>=<>k<EFBFBD><07><>o/Sw<53>ؘ<EFBFBD>ٯ<EFBFBD>`g<><67><EFBFBD><EFBFBD><EFBFBD><1C>r_ٙ<5F>Y<EFBFBD><59>VSY<53><59>zIefnmQoz
|
||||
BIN
src/includes/phpqrcode/cache/frame_33.png
vendored
Normal file
|
After Width: | Height: | Size: 253 B |
BIN
src/includes/phpqrcode/cache/frame_34.dat
vendored
Normal file
BIN
src/includes/phpqrcode/cache/frame_34.png
vendored
Normal file
|
After Width: | Height: | Size: 256 B |
BIN
src/includes/phpqrcode/cache/frame_35.dat
vendored
Normal file
BIN
src/includes/phpqrcode/cache/frame_35.png
vendored
Normal file
|
After Width: | Height: | Size: 243 B |
BIN
src/includes/phpqrcode/cache/frame_36.dat
vendored
Normal file
BIN
src/includes/phpqrcode/cache/frame_36.png
vendored
Normal file
|
After Width: | Height: | Size: 272 B |
BIN
src/includes/phpqrcode/cache/frame_37.dat
vendored
Normal file
BIN
src/includes/phpqrcode/cache/frame_37.png
vendored
Normal file
|
After Width: | Height: | Size: 279 B |
1
src/includes/phpqrcode/cache/frame_38.dat
vendored
Normal file
@@ -0,0 +1 @@
|
||||
x<EFBFBD><EFBFBD><EFBFBD>A<EFBFBD><EFBFBD>0Ў<>u<EFBFBD>A2<41>;Н<><D09D>k<>(<28>g<><67>y<1D>tp9<70><14>$<24><><EFBFBD><EFBFBD>D<7F><44><EFBFBD><EFBFBD>\<5C>e^'t<>-aI<61><49>FM<46>S<EFBFBD>k<EFBFBD><6B>I<EFBFBD>Ť<EFBFBD>:7<><37>|L<>k<EFBFBD>N<EFBFBD>8N7<4E><37><EFBFBD>i}<7D><><EFBFBD><EFBFBD>i,<2C>[W<><57>g<67>Ӵ<><1E><>?3<>1<EFBFBD>i<EFBFBD><69>N<EFBFBD>}}=<3D>OM:4<><34>)S<>L<EFBFBD>2eʔ)S<>L#$<24><>
|
||||
BIN
src/includes/phpqrcode/cache/frame_38.png
vendored
Normal file
|
After Width: | Height: | Size: 279 B |
BIN
src/includes/phpqrcode/cache/frame_39.dat
vendored
Normal file
BIN
src/includes/phpqrcode/cache/frame_39.png
vendored
Normal file
|
After Width: | Height: | Size: 264 B |
1
src/includes/phpqrcode/cache/frame_4.dat
vendored
Normal file
@@ -0,0 +1 @@
|
||||
x<EFBFBD><EFBFBD><EFBFBD><EFBFBD>
|
||||
BIN
src/includes/phpqrcode/cache/frame_4.png
vendored
Normal file
|
After Width: | Height: | Size: 149 B |
2
src/includes/phpqrcode/cache/frame_40.dat
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
x<EFBFBD><EFBFBD><EFBFBD>A<EFBFBD><EFBFBD>@Ь<><D0AC><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>@o<>7<1B><>`<60>Qfe<66>䕫PA><3E><><EFBFBD><EFBFBD><EFBFBD><?jjo5WNiz<06><>y<EFBFBD>W<EFBFBD><57><EFBFBD><EFBFBD>&]߅C?<3F>I<>r<EFBFBD>W<EFBFBD><57>^;<3B>8<EFBFBD><38>
|
||||
<EFBFBD><EFBFBD>s<ð<><C3B0>S{<7B>9^gE<67>}><3E><><]<5D><><EFBFBD><EFBFBD>߳bZ<62>n<EFBFBD><6E>^A<><41>Q}[<5B>9^<5E>]<5D>y<EFBFBD><79>najM܇K̘1cƌ3f̘1<CC98><31><EFBFBD>{<7B>W5}<7D><>{<7B><>7lM<6C><4D><EFBFBD>ޚx<DE9A>I<<1E><>K<EFBFBD><4B><EFBFBD><EFBFBD>αyl3f̘1cƌ3f̘1<CC98><31>ۻٻ={<7B><>αyl3f̘1cƌ3f̘1<CC98><31>ۻٻ={<7B><>αyl3f̘1cƌ3f̘1<CC98><31>ۻٻ={<7B><>αyl3f̘1cƌ3f̘1<CC98><31>ۻٻ={<7B><>αyl3f̘1cƌ3f̘<66><CC98><12><>Sʑ<53>Ӓ7<D392>H<EFBFBD>Kg\<5C><><EFBFBD>u<EFBFBD><75><EFBFBD>_<EFBFBD><5F>r'4<1F>[<5B><>-<2D>]<5D><>q<EFBFBD><71>L<EFBFBD><4C>8Ɲ<38><C69D>Y1q<31><71><EFBFBD><EFBFBD><EFBFBD>!<21><><EFBFBD><EFBFBD>/(%<25>
|
||||
BIN
src/includes/phpqrcode/cache/frame_40.png
vendored
Normal file
|
After Width: | Height: | Size: 267 B |