diff --git a/.gitignore b/.gitignore
index d1bed128..9fa90284 100644
--- a/.gitignore
+++ b/.gitignore
@@ -59,3 +59,10 @@ typings/
# next.js build output
.next
+
+# Playwright
+/test-results/
+/playwright-report/
+/blob-report/
+/playwright/.cache/
+/playwright/.auth/
diff --git a/GEMINI.md b/GEMINI.md
index 0c3b3091..d3463724 100644
--- a/GEMINI.md
+++ b/GEMINI.md
@@ -53,7 +53,7 @@ To protect a route with a specific permission (or any one of multiple permission
const { checkPermission } = require('../middlewares/acl');
// Single permission
-router.get('/admin/users', checkPermission('manage_users'), userController.list);
+router.get('/reports', checkPermission('view_reports'), reportController.index);
// Multiple permissions (user must have at least one)
router.get('/reports', checkPermission(['view_reports', 'manage_all']), reportController.index);
diff --git a/README.md b/README.md
new file mode 100644
index 00000000..69667a27
--- /dev/null
+++ b/README.md
@@ -0,0 +1,93 @@
+# Facultyware
+
+Facultyware adalah aplikasi web sistem informasi pengadaan barang untuk lingkungan fakultas. Aplikasi ini membantu proses pengajuan usulan pengadaan barang oleh Ketua Departemen, pengelolaan permohonan oleh Pengelola Aset/Pengelola Sistem, serta proses persetujuan atau penolakan oleh Wakil Dekan.
+
+Fitur utama aplikasi meliputi pengajuan usulan pengadaan, edit dan hapus usulan, pemantauan status usulan, pengelolaan permohonan pengadaan, penambahan barang hasil pengadaan ke sistem, dashboard tiap role, laporan rekapan, dan endpoint API JSON.
+
+## Cara Instalasi
+
+1. Pastikan Node.js dan MySQL sudah terpasang.
+
+2. Masuk ke folder project:
+
+ ```bash
+ cd facultyware
+ ```
+
+3. Install dependency:
+
+ ```bash
+ npm install
+ ```
+
+4. Buat atau sesuaikan file `.env`:
+
+ ```env
+ DB_HOST=localhost
+ DB_USER=root
+ DB_PASSWORD=
+ DB_NAME=facultyware
+ SESSION_SECRET=facultyware-secret
+ PORT=3000
+ ```
+
+5. Buat database MySQL dengan nama sesuai `DB_NAME`.
+
+6. Jalankan script inisialisasi atau seed database jika diperlukan:
+
+ ```bash
+ node scripts/init_db.js
+ node scripts/seed_data.js
+ ```
+
+ Jika menggunakan seed SQL pengadaan, jalankan file:
+
+ ```text
+ database/seed_procurement_roles.sql
+ ```
+
+## Cara Menjalankan Aplikasi
+
+Jalankan aplikasi dengan perintah:
+
+```bash
+npm start
+```
+
+Setelah server berjalan, buka aplikasi melalui browser:
+
+```text
+http://localhost:3000
+```
+
+Untuk mode development, gunakan:
+
+```bash
+npm run dev
+```
+
+## Pembagian Tugas Anggota
+
+| No | Fitur | NIM | Nama Anggota |
+| --- | --- | --- | --- |
+| 1 | Ketua Departemen dapat menginputkan pengajuan usulan pengadaan barang | 2411521017 | Diva Ramadhani |
+| 2 | Ketua Departemen dapat mengedit usulan pengadaan barang | 2411521017 | Diva Ramadhani |
+| 3 | Ketua Departemen dapat melihat status usulan pengadaan barang | 2411521017 | Diva Ramadhani |
+| 4 | Ketua Departemen dapat menghapus usulan pengadaan barang | 2411521017 | Diva Ramadhani |
+| 5 | Ketua Departemen dapat mengenerate file laporan rekapan pengadaan barang | 2411521017 | Diva Ramadhani |
+| 6 | Sistem dapat memberikan response semua riwayat pengajuan pengadaan barang oleh Ketua Departemen dalam format JSON (API) | 2411521017 | Diva Ramadhani |
+| 7 | Ketua Departemen dapat melihat dashboard | 2411521017 | Diva Ramadhani |
+| 8 | Pengelola sistem dapat menerima usulan pengadaan barang | 2411522006 | Kevin Rahmat Illahi |
+| 9 | Pengelola Aset dapat menginputkan permohonan pengadaan barang | 2411522006 | Kevin Rahmat Illahi |
+| 10 | Pengelola Aset dapat menambahkan data barang yang disetujui hasil dari pengadaan ke sistem | 2411522006 | Kevin Rahmat Illahi |
+| 11 | Pengelola Aset dapat mengenerate file laporan rekapan pengadaan barang | 2411522006 | Kevin Rahmat Illahi |
+| 12 | Pengelola Aset dapat merubah status usulan pengadaan barang | 2411522006 | Kevin Rahmat Illahi |
+| 13 | Pengelola Aset dapat melihat data barang pengadaan melalui API | 2411522006 | Kevin Rahmat Illahi |
+| 14 | Pengelola Aset dapat melihat dashboard | 2411522006 | Kevin Rahmat Illahi |
+| 15 | Wakil Dekan dapat melihat daftar permohonan pengadaan barang | 2411522013 | Aldo Septia Elvawan |
+| 16 | Wakil Dekan dapat melihat detail permohonan pengadaan barang | 2411522013 | Aldo Septia Elvawan |
+| 17 | Wakil Dekan dapat memberikan keputusan persetujuan/penolakan permohonan pengadaan barang | 2411522013 | Aldo Septia Elvawan |
+| 18 | Wakil Dekan dapat melihat riwayat keputusan permohonan pengadaan barang | 2411522013 | Aldo Septia Elvawan |
+| 19 | Wakil Dekan dapat mengenerate file laporan rekapan pengadaan barang | 2411522013 | Aldo Septia Elvawan |
+| 20 | Sistem dapat memberikan response data permohonan pengadaan barang dalam format JSON (API) | 2411522013 | Aldo Septia Elvawan |
+| 21 | Wakil Dekan dapat melihat dashboard | 2411522013 | Aldo Septia Elvawan |
diff --git a/app.js b/app.js
index f91917a2..5b2a9a5a 100644
--- a/app.js
+++ b/app.js
@@ -1,18 +1,21 @@
-require('dotenv').config();
+const path = require('path');
+require('dotenv').config({ path: path.join(__dirname, '.env') });
var express = require('express');
-var path = require('path');
var cookieParser = require('cookie-parser');
var logger = require('morgan');
var session = require('express-session');
var MySQLStore = require('express-mysql-session')(session);
-var indexRouter = require('./routes/index');
-var usersRouter = require('./routes/users');
+var authRouter = require('./routes/auth/index');
+var pengelolaAsetDashboardRouter = require('./routes/pengelola-aset/dashboard');
+var procurementsRouter = require('./routes/pengelola-aset/procurements');
+var apiProcurementsRouter = require('./routes/pengelola-aset/apiProcurements');
const { notFoundHandler, errorHandler } = require('./middlewares/error');
+const usulanRouter = require('./routes/usulan');
+const wakildekanRouter = require('./routes/wakildekan');
var app = express();
-// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
@@ -22,32 +25,51 @@ app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));
-// Session configuration
-const sessionStore = new MySQLStore({
- host: process.env.DB_HOST,
- user: process.env.DB_USER,
- password: process.env.DB_PASSWORD,
- database: process.env.DB_NAME,
-});
+// Default pakai MemoryStore supaya tidak bentrok dengan tabel sessions Laravel
+// yang memiliki kolom payload/last_activity, bukan data/expires.
+let sessionStore;
+if (process.env.USE_MYSQL_SESSION === 'true') {
+ sessionStore = new MySQLStore({
+ host: process.env.DB_HOST,
+ user: process.env.DB_USER,
+ password: process.env.DB_PASSWORD,
+ database: process.env.DB_NAME,
+ createDatabaseTable: false,
+ schema: {
+ tableName: process.env.EXPRESS_SESSION_TABLE || 'express_sessions',
+ columnNames: {
+ session_id: 'session_id',
+ expires: 'expires',
+ data: 'data'
+ }
+ }
+ });
+}
app.use(session({
- key: 'session_cookie_name',
- secret: process.env.SESSION_SECRET || 'secret',
+ key: 'facultyware_session',
+ secret: process.env.SESSION_SECRET || 'facultyware-secret',
store: sessionStore,
resave: false,
saveUninitialized: false,
- cookie: {
- maxAge: 1000 * 60 * 60 * 24 // 1 day
- }
+ cookie: { maxAge: 1000 * 60 * 60 * 24 }
}));
-app.use('/', indexRouter);
-app.use('/users', usersRouter);
+app.use((req, res, next) => {
+ res.locals.currentUser = req.session.user || null;
+ res.locals.flash = req.session.flash || null;
+ delete req.session.flash;
+ next();
+});
+
+app.use('/', authRouter);
+app.use('/', pengelolaAsetDashboardRouter);
+app.use('/usulan', usulanRouter);
+app.use('/wakildekan', wakildekanRouter);
+app.use('/procurements', procurementsRouter);
+app.use('/api', apiProcurementsRouter);
-// catch 404 and forward to error handler
app.use(notFoundHandler);
-
-// error handler
app.use(errorHandler);
module.exports = app;
diff --git a/controllers/auth/authController.js b/controllers/auth/authController.js
new file mode 100644
index 00000000..4ebb4699
--- /dev/null
+++ b/controllers/auth/authController.js
@@ -0,0 +1,76 @@
+const bcrypt = require('bcryptjs');
+const db = require('../../lib/db');
+
+const index = (req, res) => res.redirect('/home');
+
+const renderLogin = (res, overrides = {}) => {
+ res.render('auth/login', {
+ title: 'Login',
+ error: null,
+ success: null,
+ form: { email: '' },
+ ...overrides
+ });
+};
+
+const loginPage = (req, res) => {
+ if (req.session.userId) return res.redirect('/home');
+ renderLogin(res);
+};
+
+const login = async (req, res, next) => {
+ const email = String(req.body.email || req.body.username || '').trim();
+ const password = String(req.body.password || '');
+
+ try {
+ const [rows] = await db.query('SELECT * FROM users WHERE email = ? OR name = ? LIMIT 1', [email, email]);
+ if (!rows.length) {
+ return renderLogin(res, { error: 'Email atau password tidak valid.', form: { email } });
+ }
+
+ const user = rows[0];
+ const isBcrypt = String(user.password || '').startsWith('$2');
+ const isMatch = isBcrypt ? await bcrypt.compare(password, user.password) : password === user.password;
+ if (!isMatch) {
+ return renderLogin(res, { error: 'Email atau password tidak valid.', form: { email } });
+ }
+
+ const [employeeRows] = await db.query('SELECT id, name FROM employees WHERE id = ? LIMIT 1', [user.id]);
+ const [roleRows] = await db.query(`
+ SELECT r.name
+ FROM roles r
+ JOIN model_has_roles mhr ON mhr.role_id = r.id
+ WHERE mhr.model_id = ?
+ `, [user.id]);
+
+ req.session.userId = user.id;
+ req.session.username = user.name;
+ req.session.email = user.email;
+ req.session.employeeId = employeeRows[0]?.id || user.id;
+ req.session.roles = roleRows.map(row => row.name);
+ req.session.user = {
+ id: user.id,
+ name: user.name,
+ email: user.email,
+ employeeId: req.session.employeeId,
+ roles: req.session.roles
+ };
+
+ if (req.session.roles.includes('Wakil Dekan') || req.session.roles.includes('wakildekan')) {
+ return res.redirect('/wakildekan/permohonan');
+ }
+
+ res.redirect('/home');
+ } catch (err) {
+ next(err);
+ }
+};
+
+const logout = (req, res, next) => {
+ req.session.destroy((err) => {
+ if (err) return next(err);
+ res.redirect('/login');
+ });
+};
+
+module.exports = { index, loginPage, login, logout };
diff --git a/controllers/indexController.js b/controllers/indexController.js
index 5ea918c1..ed808595 100644
--- a/controllers/indexController.js
+++ b/controllers/indexController.js
@@ -5,45 +5,138 @@ const index = (req, res) => {
res.render("index", { title: "Express" });
};
-const home = (req, res) => {
- res.render("home", { title: "Home", user: req.session.username });
+const home = async (req, res, next) => {
+ try {
+ const userId = req.session.userId;
+
+ const summary = {
+ total: 0,
+ draft: 0,
+ submitted: 0,
+ approved: 0,
+ rejected: 0,
+ completed: 0,
+ };
+
+ const [statusRows] = await db.query(
+ `
+ SELECT status, COUNT(*) total
+ FROM equipment_procurements
+ WHERE created_by = ?
+ GROUP BY status
+ `,
+ [userId]
+ );
+
+ statusRows.forEach((row) => {
+ summary.total += Number(row.total);
+
+ if (summary.hasOwnProperty(row.status)) {
+ summary[row.status] = Number(row.total);
+ }
+ });
+
+ const [aggregateRows] = await db.query(
+ `
+ SELECT
+ COALESCE(SUM(epi.quantity),0) AS totalItem,
+ COALESCE(SUM(epi.quantity * epi.estimated_price),0) AS totalBiaya
+ FROM equipment_procurements ep
+ LEFT JOIN equipment_proc_items epi
+ ON ep.id = epi.equipment_proc_id
+ WHERE ep.created_by = ?
+ `,
+ [userId]
+ );
+
+ const totalItem = Number(
+ aggregateRows[0]?.totalItem || 0
+ );
+
+ const totalBiaya = Number(
+ aggregateRows[0]?.totalBiaya || 0
+ );
+
+ const formatRupiah = (angka) => {
+ return new Intl.NumberFormat("id-ID", {
+ style: "currency",
+ currency: "IDR",
+ maximumFractionDigits: 0,
+ }).format(angka);
+ };
+
+ res.render("home", {
+ title: "Home",
+ user: req.session.username,
+ summary,
+ totalItem,
+ totalBiaya,
+ formatRupiah,
+ });
+ } catch (err) {
+ next(err);
+ }
};
const loginPage = (req, res) => {
if (req.session.userId) {
return res.redirect("/home");
}
- res.render("login", { title: "Login", error: null });
+
+ res.render("login", {
+ title: "Login",
+ error: null,
+ });
};
const login = async (req, res, next) => {
- const { username, password } = req.body;
+ const { email, password } = req.body;
try {
- const [rows] = await db.query("SELECT * FROM users WHERE username = ?", [
- username,
- ]);
+ const [rows] = await db.query(
+ "SELECT * FROM users WHERE email = ?",
+ [email]
+ );
if (rows.length === 0) {
return res.render("login", {
title: "Login",
- error: "Invalid username or password",
+ error: "Invalid email or password",
});
}
const user = rows[0];
- const isMatch = await bcrypt.compare(password, user.password);
+
+ const isMatch = await bcrypt.compare(
+ password,
+ user.password
+ );
if (!isMatch) {
return res.render("login", {
title: "Login",
- error: "Invalid username or password",
+ error: "Invalid email or password",
});
}
// Set session
req.session.userId = user.id;
- req.session.username = user.username;
+
+ // tetap gunakan nama variabel username
+ // agar view dosen tidak perlu diubah
+ req.session.username = user.name;
+
+ // Ambil role user untuk redirect
+ const [roleRows] = await db.query(
+ "SELECT r.name FROM roles r JOIN model_has_roles mhr ON r.id = mhr.role_id WHERE mhr.model_id = ?",
+ [user.id]
+ );
+ const roles = roleRows.map(r => r.name);
+
+ // Redirect sesuai role
+ if (roles.includes('wakildekan')) {
+ return res.redirect('/wakildekan/dashboard');
+ }
res.redirect("/home");
} catch (err) {
@@ -56,6 +149,7 @@ const logout = (req, res, next) => {
if (err) {
return next(err);
}
+
res.redirect("/login");
});
};
@@ -65,5 +159,5 @@ module.exports = {
home,
loginPage,
login,
- logout
-};
+ logout,
+};
\ No newline at end of file
diff --git a/controllers/pengelola-aset/apiProcurementController.js b/controllers/pengelola-aset/apiProcurementController.js
new file mode 100644
index 00000000..5b422ccc
--- /dev/null
+++ b/controllers/pengelola-aset/apiProcurementController.js
@@ -0,0 +1,26 @@
+const db = require('../../lib/db');
+
+const ok = (res, data, message = 'Data berhasil diambil') => res.json({ success: true, message, data });
+const fail = (res, status, message, error = null) => res.status(status).json({ success: false, message, error });
+
+const listProcurementItems = async (req, res) => {
+ try {
+ const [rows] = await db.query(`
+ SELECT
+ item.*,
+ ep.request_number,
+ ep.title AS procurement_title,
+ ep.status AS procurement_status,
+ emp.name AS created_by_name
+ FROM equipment_proc_items item
+ JOIN equipment_procurements ep ON ep.id = item.equipment_proc_id
+ LEFT JOIN employees emp ON emp.id = ep.created_by
+ ORDER BY item.created_at DESC, item.id DESC
+ `);
+ ok(res, rows);
+ } catch (err) {
+ fail(res, 500, 'Gagal mengambil data', err.message);
+ }
+};
+
+module.exports = { listProcurementItems };
diff --git a/controllers/pengelola-aset/dashboardController.js b/controllers/pengelola-aset/dashboardController.js
new file mode 100644
index 00000000..1236f34d
--- /dev/null
+++ b/controllers/pengelola-aset/dashboardController.js
@@ -0,0 +1,169 @@
+const db = require('../../lib/db');
+
+const emptySummary = () => ({
+ requests: { total: 0, submitted: 0, rejected: 0 },
+ procurements: { total: 0, draft: 0, submitted: 0, approved: 0, rejected: 0, completed: 0 },
+ assets: { total: 0, totalCost: 0 },
+ recentProcurements: [],
+ recentRequests: []
+});
+
+function formatRupiah(angka) {
+ return new Intl.NumberFormat('id-ID', {
+ style: 'currency',
+ currency: 'IDR',
+ maximumFractionDigits: 0,
+ }).format(angka);
+}
+
+async function renderKetuaDepartemenHome(req, res, next) {
+ try {
+ const userId = req.session.employeeId || req.session.userId;
+ const summary = {
+ total: 0,
+ draft: 0,
+ submitted: 0,
+ approved: 0,
+ rejected: 0,
+ completed: 0,
+ };
+
+ const [statusRows] = await db.query(`
+ SELECT status, COUNT(*) total
+ FROM equipment_procurements
+ WHERE created_by = ?
+ GROUP BY status
+ `, [userId]);
+
+ statusRows.forEach((row) => {
+ summary.total += Number(row.total || 0);
+ if (Object.prototype.hasOwnProperty.call(summary, row.status)) {
+ summary[row.status] = Number(row.total || 0);
+ }
+ });
+
+ const [aggregateRows] = await db.query(`
+ SELECT
+ COALESCE(SUM(epi.quantity), 0) AS totalItem,
+ COALESCE(SUM(epi.quantity * epi.estimated_price), 0) AS totalBiaya
+ FROM equipment_procurements ep
+ LEFT JOIN equipment_proc_items epi ON ep.id = epi.equipment_proc_id
+ WHERE ep.created_by = ?
+ `, [userId]);
+
+ res.render('home', {
+ title: 'Home',
+ user: req.session.username,
+ summary,
+ totalItem: Number(aggregateRows[0]?.totalItem || 0),
+ totalBiaya: Number(aggregateRows[0]?.totalBiaya || 0),
+ formatRupiah,
+ });
+ } catch (err) {
+ next(err);
+ }
+}
+
+const home = async (req, res, next) => {
+ const user = req.session.user || null;
+ const roles = user?.roles || [];
+ const isKetuaDepartemen = roles.includes('Ketua Departemen') || roles.includes('ketua_departemen');
+ const isPengelolaAset = roles.includes('Pengelola Aset');
+ const isWakilDekan = roles.includes('Wakil Dekan') || roles.includes('wakildekan');
+
+ if (isWakilDekan && !isPengelolaAset) {
+ return res.redirect('/wakildekan/permohonan');
+ }
+
+ if (isKetuaDepartemen && !isPengelolaAset) {
+ return renderKetuaDepartemenHome(req, res, next);
+ }
+
+ if (!isPengelolaAset) {
+ return res.render('home_default', {
+ title: 'Home',
+ user
+ });
+ }
+
+ const summary = emptySummary();
+
+ try {
+ const [requestStatusRows] = await db.query(`
+ SELECT status, COUNT(*) AS total
+ FROM equipment_procurements
+ WHERE request_number LIKE 'REQ-%' AND status IN ('submitted', 'rejected')
+ GROUP BY status
+ `);
+
+ requestStatusRows.forEach((row) => {
+ const status = row.status || 'unknown';
+ const total = Number(row.total || 0);
+ summary.requests.total += total;
+ if (Object.prototype.hasOwnProperty.call(summary.requests, status)) {
+ summary.requests[status] = total;
+ }
+ });
+
+ const [procurementStatusRows] = await db.query(`
+ SELECT status, COUNT(*) AS total
+ FROM equipment_procurements
+ WHERE request_number NOT LIKE 'REQ-%'
+ GROUP BY status
+ `);
+
+ procurementStatusRows.forEach((row) => {
+ const status = row.status || 'unknown';
+ const total = Number(row.total || 0);
+ summary.procurements.total += total;
+ if (Object.prototype.hasOwnProperty.call(summary.procurements, status)) {
+ summary.procurements[status] = total;
+ }
+ });
+
+ const [assetRows] = await db.query(`
+ SELECT COUNT(*) AS total_assets, COALESCE(SUM(acquisition_cost), 0) AS total_acquisition_cost
+ FROM assets
+ WHERE acquisition_type = 'procurement'
+ `);
+ summary.assets.total = Number(assetRows[0]?.total_assets || 0);
+ summary.assets.totalCost = Number(assetRows[0]?.total_acquisition_cost || 0);
+
+ const [recentProcurements] = await db.query(`
+ SELECT id, request_number, title, status, created_at
+ FROM equipment_procurements
+ WHERE request_number NOT LIKE 'REQ-%'
+ ORDER BY created_at DESC, id DESC
+ LIMIT 5
+ `);
+ summary.recentProcurements = recentProcurements;
+
+ const [recentRequests] = await db.query(`
+ SELECT
+ ep.id,
+ ep.request_number,
+ ep.title,
+ ep.status,
+ ep.created_at,
+ COALESCE(SUM(item.quantity), 0) AS total_quantity
+ FROM equipment_procurements ep
+ LEFT JOIN equipment_proc_items item ON item.equipment_proc_id = ep.id
+ WHERE ep.request_number LIKE 'REQ-%' AND ep.status IN ('submitted', 'rejected')
+ GROUP BY ep.id
+ ORDER BY created_at DESC, id DESC
+ LIMIT 5
+ `);
+ summary.recentRequests = recentRequests;
+ } catch (err) {
+ console.error('Dashboard summary error:', err.message);
+ }
+
+ res.render('pengelola-aset/dashboard/index', {
+ title: 'Dashboard Pengelola Aset',
+ user,
+ roles,
+ summary
+ });
+};
+
+module.exports = { home };
diff --git a/controllers/pengelola-aset/procurementController.js b/controllers/pengelola-aset/procurementController.js
new file mode 100644
index 00000000..90abb5a0
--- /dev/null
+++ b/controllers/pengelola-aset/procurementController.js
@@ -0,0 +1,674 @@
+const db = require('../../lib/db');
+const { applyProcurementDecision } = require('../../lib/procurement-assets');
+
+const requestStatuses = ['submitted', 'rejected'];
+const procurementStatuses = ['draft', 'submitted', 'approved', 'rejected', 'completed'];
+const reportTypes = ['requests', 'procurements', 'assets'];
+const assetStatuses = ['available', 'in_use', 'maintenance', 'disposed'];
+
+function flash(req, type, message) {
+ req.session.flash = { type, message };
+}
+
+function currentEmployeeId(req) {
+ return req.session.employeeId || req.session.userId;
+}
+
+function redirectBack(req, res, fallback) {
+ return res.redirect(req.get('Referrer') || fallback);
+}
+
+function nowRequestNumber(prefix) {
+ const d = new Date();
+ const pad = (n) => String(n).padStart(2, '0');
+ return `${prefix}-${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}-${Math.floor(Math.random() * 900 + 100)}`;
+}
+
+function rupiah(value) {
+ const n = Number(value || 0);
+ return n.toLocaleString('id-ID');
+}
+
+function isValidDateInput(value) {
+ return /^\d{4}-\d{2}-\d{2}$/.test(String(value || ''));
+}
+
+function isFiniteNonNegativeNumber(value) {
+ return Number.isFinite(value) && value >= 0;
+}
+
+function normalizeReportFilters(query = {}) {
+ const reportType = reportTypes.includes(query.report_type) ? query.report_type : 'procurements';
+ const statuses = reportType === 'requests'
+ ? requestStatuses
+ : reportType === 'assets'
+ ? assetStatuses
+ : procurementStatuses;
+
+ const filters = {
+ report_type: reportType,
+ status: statuses.includes(query.status) ? query.status : '',
+ start_date: isValidDateInput(query.start_date) ? query.start_date : '',
+ end_date: isValidDateInput(query.end_date) ? query.end_date : ''
+ };
+
+ if (filters.start_date && filters.end_date && filters.start_date > filters.end_date) {
+ const startDate = filters.start_date;
+ filters.start_date = filters.end_date;
+ filters.end_date = startDate;
+ }
+
+ return { reportType, filters, statuses };
+}
+
+async function getProcurement(id) {
+ const [rows] = await db.query(`
+ SELECT ep.*, creator.name AS created_by_name, creator_user.email AS created_by_email,
+ (
+ SELECT COUNT(*)
+ FROM assets a
+ WHERE a.acquisition_type = 'procurement' AND a.asset_grant_id = ep.id
+ ) AS asset_count
+ FROM equipment_procurements ep
+ LEFT JOIN employees creator ON creator.id = ep.created_by
+ LEFT JOIN users creator_user ON creator_user.id = creator.id
+ WHERE ep.id = ?
+ LIMIT 1
+ `, [id]);
+ return rows[0];
+}
+
+async function getProcurementItems(id) {
+ const [items] = await db.query(`
+ SELECT *
+ FROM equipment_proc_items
+ WHERE equipment_proc_id = ?
+ ORDER BY id ASC
+ `, [id]);
+ return items;
+}
+
+const listRequests = async (req, res, next) => {
+ try {
+ const search = String(req.query.search || '').trim();
+ const page = Math.max(1, parseInt(req.query.page, 10) || 1);
+ const limit = 10;
+ const offset = (page - 1) * limit;
+
+ let whereClause = `WHERE ep.request_number LIKE 'REQ-%' AND ep.status IN ('submitted', 'rejected')`;
+ const params = [];
+
+ if (search) {
+ whereClause += ' AND (ep.request_number LIKE ? OR ep.title LIKE ? OR item.name LIKE ?)';
+ const like = `%${search}%`;
+ params.push(like, like, like);
+ }
+
+ const [countRows] = await db.query(
+ `SELECT COUNT(DISTINCT ep.id) AS total
+ FROM equipment_procurements ep
+ LEFT JOIN equipment_proc_items item ON item.equipment_proc_id = ep.id
+ ${whereClause}`,
+ params
+ );
+ const totalItems = countRows[0].total;
+ const totalPages = Math.max(1, Math.ceil(totalItems / limit));
+
+ const [requests] = await db.query(`
+ SELECT
+ ep.id,
+ ep.request_number,
+ ep.title,
+ ep.status,
+ ep.created_at,
+ ep.updated_at,
+ emp.name AS employee_name,
+ COUNT(item.id) AS item_count,
+ COALESCE(SUM(item.quantity), 0) AS total_quantity,
+ COALESCE(SUM(item.quantity * item.estimated_price), 0) AS total_estimated_price
+ FROM equipment_procurements ep
+ LEFT JOIN employees emp ON emp.id = ep.created_by
+ LEFT JOIN equipment_proc_items item ON item.equipment_proc_id = ep.id
+ ${whereClause}
+ GROUP BY ep.id, emp.name
+ ORDER BY ep.created_at DESC, ep.id DESC
+ LIMIT ? OFFSET ?
+ `, [...params, limit, offset]);
+ res.render('pengelola-aset/procurements/requests/index', { title: 'Daftar Usulan Pengadaan', requests, rupiah, search, currentPage: page, totalPages, totalItems });
+ } catch (err) {
+ next(err);
+ }
+};
+
+const detailRequest = async (req, res, next) => {
+ try {
+ const [rows] = await db.query(`
+ SELECT ep.*, emp.name AS employee_name
+ FROM equipment_procurements ep
+ LEFT JOIN employees emp ON emp.id = ep.created_by
+ WHERE ep.id = ?
+ LIMIT 1
+ `, [req.params.id]);
+ if (!rows.length) return res.status(404).render('error', { message: 'Usulan tidak ditemukan', error: { status: 404, stack: '' } });
+ const items = await getProcurementItems(req.params.id);
+ res.render('pengelola-aset/procurements/requests/detail', { title: 'Detail Usulan', request: rows[0], items, requestStatuses, rupiah });
+ } catch (err) {
+ next(err);
+ }
+};
+
+const updateRequestStatus = async (req, res, next) => {
+ const { status } = req.body;
+ if (!requestStatuses.includes(status)) {
+ flash(req, 'error', 'Status usulan tidak valid.');
+ return redirectBack(req, res, '/procurements/requests');
+ }
+
+ try {
+ const [result] = await db.query(`
+ UPDATE equipment_procurements
+ SET request_number = CASE WHEN ? = 'submitted' THEN ? ELSE request_number END,
+ status = ?,
+ updated_at = NOW()
+ WHERE id = ? AND status = 'submitted' AND request_number LIKE 'REQ-%'
+ `, [status, nowRequestNumber('PR'), status, req.params.id]);
+ if (!result.affectedRows) {
+ flash(req, 'error', 'Usulan hanya bisa diproses saat masih menunggu Pengelola Aset.');
+ return res.redirect(`/procurements/requests/${req.params.id}`);
+ }
+ flash(req, 'success', status === 'submitted' ? 'Usulan berhasil diteruskan ke Wakil Dekan.' : 'Usulan berhasil ditolak.');
+ res.redirect(`/procurements/requests/${req.params.id}`);
+ } catch (err) {
+ next(err);
+ }
+};
+
+const showCreateProcurement = async (req, res, next) => {
+ try {
+ let sourceRequest = null;
+ if (req.query.request_id) {
+ const [rows] = await db.query(`
+ SELECT
+ ep.id,
+ ep.request_number,
+ ep.title,
+ item.name,
+ item.specification,
+ item.quantity,
+ item.estimated_price
+ FROM equipment_procurements ep
+ LEFT JOIN equipment_proc_items item ON item.equipment_proc_id = ep.id
+ WHERE ep.id = ?
+ ORDER BY item.id ASC
+ LIMIT 1
+ `, [req.query.request_id]);
+ sourceRequest = rows[0] || null;
+ }
+ res.render('pengelola-aset/procurements/create', { title: 'Buat Permohonan Pengadaan', sourceRequest });
+ } catch (err) {
+ next(err);
+ }
+};
+
+const createProcurement = async (req, res, next) => {
+ const title = String(req.body.title || '').trim();
+ const itemName = String(req.body.name || '').trim();
+ const specification = String(req.body.specification || '').trim() || null;
+ const quantity = Number(req.body.quantity || 0);
+ const estimatedPrice = Number(req.body.estimated_price || 0);
+
+ if (!title || !itemName || !Number.isInteger(quantity) || quantity <= 0 || estimatedPrice < 0) {
+ flash(req, 'error', 'Validasi gagal: judul, nama barang, jumlah > 0, dan estimasi harga wajib benar.');
+ return redirectBack(req, res, '/procurements/create');
+ }
+
+ const conn = await db.getConnection();
+ try {
+ await conn.beginTransaction();
+ const requestNumber = nowRequestNumber('PR');
+ const employeeId = currentEmployeeId(req);
+
+ const [procResult] = await conn.query(`
+ INSERT INTO equipment_procurements
+ (request_number, title, status, created_by, employee_id, created_at, updated_at)
+ VALUES (?, ?, 'draft', ?, ?, NOW(), NOW())
+ `, [requestNumber, title, employeeId, employeeId]);
+
+ const procurementId = procResult.insertId;
+ await conn.query(`
+ INSERT INTO equipment_proc_items
+ (equipment_proc_id, name, specification, quantity, estimated_price, asset_equipment_procurement_id, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW())
+ `, [procurementId, itemName, specification, quantity, estimatedPrice, procurementId]);
+
+ await conn.commit();
+ flash(req, 'success', 'Permohonan pengadaan berhasil dibuat.');
+ res.redirect(`/procurements/${procurementId}`);
+ } catch (err) {
+ await conn.rollback();
+ next(err);
+ } finally {
+ conn.release();
+ }
+};
+
+const listProcurements = async (req, res, next) => {
+ try {
+ const search = String(req.query.search || '').trim();
+ const page = Math.max(1, parseInt(req.query.page, 10) || 1);
+ const limit = 10;
+ const offset = (page - 1) * limit;
+
+ let whereClause = `WHERE ep.request_number NOT LIKE 'REQ-%'`;
+ const params = [];
+
+ if (search) {
+ whereClause += ' AND (ep.request_number LIKE ? OR ep.title LIKE ?)';
+ const like = `%${search}%`;
+ params.push(like, like);
+ }
+
+ const [countRows] = await db.query(
+ `SELECT COUNT(*) AS total FROM equipment_procurements ep ${whereClause}`,
+ params
+ );
+ const totalItems = countRows[0].total;
+ const totalPages = Math.max(1, Math.ceil(totalItems / limit));
+
+ const [procurements] = await db.query(`
+ SELECT ep.*, emp.name AS created_by_name, COUNT(item.id) AS item_count, COALESCE(SUM(item.quantity * item.estimated_price), 0) AS total_estimated_price
+ FROM equipment_procurements ep
+ LEFT JOIN employees emp ON emp.id = ep.created_by
+ LEFT JOIN equipment_proc_items item ON item.equipment_proc_id = ep.id
+ ${whereClause}
+ GROUP BY ep.id, emp.name
+ ORDER BY ep.created_at DESC, ep.id DESC
+ LIMIT ? OFFSET ?
+ `, [...params, limit, offset]);
+ res.render('pengelola-aset/procurements/index', { title: 'Daftar Permohonan Pengadaan', procurements, rupiah, search, currentPage: page, totalPages, totalItems });
+ } catch (err) {
+ next(err);
+ }
+};
+
+const procurementItemsPage = async (req, res, next) => {
+ try {
+ res.render('pengelola-aset/procurements/items', { title: 'Data Barang Pengadaan' });
+ } catch (err) {
+ next(err);
+ }
+};
+
+const detailProcurement = async (req, res, next) => {
+ try {
+ const procurement = await getProcurement(req.params.id);
+ if (!procurement) return res.status(404).render('error', { message: 'Permohonan tidak ditemukan', error: { status: 404, stack: '' } });
+ const items = await getProcurementItems(req.params.id);
+ res.render('pengelola-aset/procurements/detail', { title: 'Detail Permohonan', procurement, items, rupiah });
+ } catch (err) {
+ next(err);
+ }
+};
+
+const submitProcurement = async (req, res, next) => {
+ try {
+ const [result] = await db.query(`
+ UPDATE equipment_procurements
+ SET status = 'submitted', updated_at = NOW()
+ WHERE id = ? AND status = 'draft'
+ `, [req.params.id]);
+ flash(req, result.affectedRows ? 'success' : 'error', result.affectedRows ? 'Permohonan berhasil disubmit ke Wakil Dekan.' : 'Permohonan hanya bisa disubmit saat status draft.');
+ res.redirect(`/procurements/${req.params.id}`);
+ } catch (err) {
+ next(err);
+ }
+};
+
+const decideProcurement = async (req, res, next) => {
+ const decision = String(req.body.decision || '').trim();
+ if (!['approved', 'rejected'].includes(decision)) {
+ flash(req, 'error', 'Keputusan tidak valid.');
+ return redirectBack(req, res, `/procurements/${req.params.id}`);
+ }
+ const conn = await db.getConnection();
+ try {
+ await conn.beginTransaction();
+ const result = await applyProcurementDecision(conn, req.params.id, decision);
+ if (!result.updated) {
+ await conn.rollback();
+ flash(req, 'error', 'Permohonan hanya bisa diputuskan saat status submitted.');
+ return res.redirect(`/procurements/${req.params.id}`);
+ }
+ await conn.commit();
+ flash(
+ req,
+ 'success',
+ decision === 'approved'
+ ? 'Permohonan berhasil di-approve. Pengelola Aset dapat mencatat barang ke daftar aset.'
+ : 'Permohonan berhasil di-reject.'
+ );
+ res.redirect(`/procurements/${req.params.id}`);
+ } catch (err) {
+ await conn.rollback();
+ next(err);
+ } finally {
+ conn.release();
+ }
+};
+
+const showAddAsset = async (req, res, next) => {
+ try {
+ const procurement = await getProcurement(req.params.id);
+ if (!procurement) return res.status(404).render('error', { message: 'Permohonan tidak ditemukan', error: { status: 404, stack: '' } });
+ if (procurement.status !== 'approved') {
+ flash(req, 'error', 'Barang hanya bisa ditambahkan jika permohonan sudah approved.');
+ return res.redirect(`/procurements/${req.params.id}`);
+ }
+ const items = await getProcurementItems(req.params.id);
+ const selectedItemId = Number(req.query.item_id || items[0]?.id || 0);
+ const selectedItem = items.find((item) => Number(item.id) === selectedItemId) || items[0] || null;
+
+ res.render('pengelola-aset/procurements/add-asset', {
+ title: 'Tambah Barang Hasil Pengadaan',
+ procurement,
+ items,
+ selectedItemId: selectedItem ? Number(selectedItem.id) : null,
+ initialValues: {
+ item_id: selectedItem ? Number(selectedItem.id) : '',
+ code: '',
+ name: selectedItem?.name || '',
+ brand: '',
+ model: '',
+ serial_number: '',
+ acquisition_date: '',
+ acquisition_cost: selectedItem?.estimated_price || 0,
+ specification: selectedItem?.specification || ''
+ }
+ });
+ } catch (err) {
+ next(err);
+ }
+};
+
+const addAssetFromProcurement = async (req, res, next) => {
+ const selectedItemId = Number(req.body.item_id || 0);
+ const assetCode = String(req.body.code || '').trim();
+ const name = String(req.body.name || '').trim();
+ const acquisitionDate = String(req.body.acquisition_date || '').trim();
+ const acquisitionCost = Number(req.body.acquisition_cost || 0);
+ const brand = String(req.body.brand || '').trim() || null;
+ const model = String(req.body.model || '').trim() || null;
+ const serialNumber = String(req.body.serial_number || '').trim() || null;
+ const specification = String(req.body.specification || '').trim() || null;
+
+ const formValues = {
+ item_id: selectedItemId || '',
+ code: assetCode,
+ name,
+ brand: brand || '',
+ model: model || '',
+ serial_number: serialNumber || '',
+ acquisition_date: acquisitionDate,
+ acquisition_cost: Number.isFinite(acquisitionCost) ? acquisitionCost : '',
+ specification: specification || ''
+ };
+
+ const conn = await db.getConnection();
+ try {
+ await conn.beginTransaction();
+ const [procRows] = await conn.query('SELECT * FROM equipment_procurements WHERE id = ? FOR UPDATE', [req.params.id]);
+ const procurement = procRows[0];
+ if (!procurement || procurement.status !== 'approved') throw new Error('Permohonan belum approved atau tidak ditemukan.');
+ const items = await getProcurementItems(req.params.id);
+ const selectedItem = items.find((item) => Number(item.id) === selectedItemId) || items[0] || null;
+
+ if (!selectedItem) {
+ await conn.rollback();
+ flash(req, 'error', 'Item pengadaan tidak ditemukan.');
+ return res.redirect(`/procurements/${req.params.id}`);
+ }
+
+ if (!assetCode || !name || !isValidDateInput(acquisitionDate) || !isFiniteNonNegativeNumber(acquisitionCost)) {
+ await conn.rollback();
+ return res.status(422).render('pengelola-aset/procurements/add-asset', {
+ title: 'Tambah Barang Hasil Pengadaan',
+ procurement,
+ items,
+ selectedItemId: Number(selectedItem.id),
+ initialValues: formValues,
+ flash: {
+ type: 'error',
+ message: 'Validasi gagal: pilih item, isi kode aset, nama barang, tanggal perolehan valid, dan harga tidak boleh negatif.'
+ }
+ });
+ }
+
+ const [assetResult] = await conn.query(`
+ INSERT INTO assets
+ (name, code, type, acquisition_type, acquisition_date, acquisition_cost, asset_grant_id, \`condition\`, status, created_at, updated_at)
+ VALUES (?, ?, 'equipment', 'procurement', ?, ?, NULL, 'good', 'available', NOW(), NOW())
+ `, [name, assetCode, acquisitionDate, acquisitionCost]);
+
+ await conn.query(`
+ INSERT INTO equipments
+ (asset_id, brand, model, serial_number, specification, purchase_link, photo, depreciation_value, useful_life, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, NOW(), NOW())
+ `, [assetResult.insertId, brand, model, serialNumber, specification]);
+
+ await conn.query(`
+ UPDATE equipment_procurements
+ SET status = 'completed', updated_at = NOW()
+ WHERE id = ?
+ `, [req.params.id]);
+
+ await conn.commit();
+ flash(
+ req,
+ 'success',
+ 'Barang berhasil ditambahkan ke sistem aset.'
+ );
+ res.redirect(`/procurements/${req.params.id}`);
+ } catch (err) {
+ await conn.rollback();
+ if (err.code === 'ER_DUP_ENTRY') {
+ const procurement = await getProcurement(req.params.id);
+ const items = await getProcurementItems(req.params.id);
+ const selectedItem = items.find((item) => Number(item.id) === selectedItemId) || items[0] || null;
+ return res.status(409).render('pengelola-aset/procurements/add-asset', {
+ title: 'Tambah Barang Hasil Pengadaan',
+ procurement,
+ items,
+ selectedItemId: selectedItem ? Number(selectedItem.id) : null,
+ initialValues: formValues,
+ flash: {
+ type: 'error',
+ message: 'Kode aset sudah digunakan. Gunakan kode lain.'
+ }
+ });
+ }
+ next(err);
+ } finally {
+ conn.release();
+ }
+};
+
+function buildReportWhere(query, reportType) {
+ const clauses = [];
+ const params = [];
+ if (reportType === 'requests') {
+ clauses.push(`ep.request_number LIKE 'REQ-%' AND ep.status IN ('submitted', 'rejected')`);
+ }
+ if (reportType === 'procurements') {
+ clauses.push(`ep.request_number NOT LIKE 'REQ-%'`);
+ }
+ if (query.status) {
+ if (reportType === 'requests' && requestStatuses.includes(query.status)) {
+ clauses.push('ep.status = ?');
+ params.push(query.status);
+ }
+ if (reportType === 'procurements' && procurementStatuses.includes(query.status)) {
+ clauses.push('ep.status = ?');
+ params.push(query.status);
+ }
+ if (reportType === 'assets') {
+ clauses.push('a.status = ?');
+ params.push(query.status);
+ }
+ }
+ if (query.start_date) {
+ const dateField = reportType === 'requests' ? 'ep.created_at' : reportType === 'assets' ? 'a.acquisition_date' : 'ep.created_at';
+ clauses.push(`DATE(${dateField}) >= ?`);
+ params.push(query.start_date);
+ }
+ if (query.end_date) {
+ const dateField = reportType === 'requests' ? 'ep.created_at' : reportType === 'assets' ? 'a.acquisition_date' : 'ep.created_at';
+ clauses.push(`DATE(${dateField}) <= ?`);
+ params.push(query.end_date);
+ }
+ return { where: clauses.length ? `WHERE ${clauses.join(' AND ')}` : '', params };
+}
+
+async function getReportRows(filters) {
+ const reportType = filters.report_type;
+ const { where, params } = buildReportWhere(filters, reportType);
+
+ if (reportType === 'requests') {
+ const [rows] = await db.query(`
+ SELECT ep.request_number, ep.title AS name, item.specification, item.quantity, ep.status,
+ ep.created_at, ep.updated_at, emp.name AS employee_name
+ FROM equipment_procurements ep
+ LEFT JOIN employees emp ON emp.id = ep.created_by
+ LEFT JOIN equipment_proc_items item ON item.equipment_proc_id = ep.id
+ ${where}
+ ORDER BY ep.created_at DESC, ep.id DESC, item.id ASC
+ `, params);
+ return { reportType, rows };
+ }
+
+ if (reportType === 'assets') {
+ const assetWhere = where ? `${where} AND a.acquisition_type = 'procurement' AND a.type = 'equipment'` : `WHERE a.acquisition_type = 'procurement' AND a.type = 'equipment'`;
+ const [rows] = await db.query(`
+ SELECT a.code, a.name, a.acquisition_date, a.acquisition_cost, a.status,
+ a.created_at, e.brand, e.model, e.serial_number, e.specification
+ FROM assets a
+ LEFT JOIN equipments e ON e.asset_id = a.id
+ ${assetWhere}
+ ORDER BY a.acquisition_date DESC, a.id DESC
+ `, params);
+ return { reportType, rows };
+ }
+
+ const [rows] = await db.query(`
+ SELECT ep.request_number, ep.title, ep.status, ep.created_at, ep.updated_at,
+ emp.name AS created_by_name,
+ item.name AS item_name, item.quantity, item.estimated_price
+ FROM equipment_procurements ep
+ LEFT JOIN employees emp ON emp.id = ep.created_by
+ LEFT JOIN equipment_proc_items item ON item.equipment_proc_id = ep.id
+ ${where}
+ ORDER BY ep.created_at DESC, ep.id DESC, item.id ASC
+ `, params);
+ return { reportType, rows };
+}
+
+function getReportMeta(reportType) {
+ if (reportType === 'requests') {
+ return {
+ statuses: requestStatuses,
+ exportName: 'rekap-usulan',
+ header: ['nomor_usulan', 'nama_barang', 'spesifikasi', 'jumlah', 'status', 'created_at', 'updated_at', 'pengusul']
+ };
+ }
+
+ if (reportType === 'assets') {
+ return {
+ statuses: assetStatuses,
+ exportName: 'rekap-asset',
+ header: ['kode_asset', 'nama_asset', 'brand', 'model', 'serial_number', 'spesifikasi', 'tanggal_perolehan', 'harga_perolehan', 'status', 'created_at']
+ };
+ }
+
+ return {
+ statuses: procurementStatuses,
+ exportName: 'rekap-permohonan',
+ header: ['nomor_permohonan', 'judul', 'nama_barang', 'jumlah', 'estimasi_harga', 'status', 'created_at', 'updated_at', 'created_by']
+ };
+}
+
+const report = async (req, res, next) => {
+ try {
+ const { reportType, filters, statuses } = normalizeReportFilters(req.query);
+ const { rows } = await getReportRows(filters);
+ res.render('pengelola-aset/procurements/report', {
+ title: 'Rekap Pengadaan',
+ rows,
+ reportType,
+ filters,
+ statuses,
+ reportTypes,
+ rupiah
+ });
+ } catch (err) {
+ next(err);
+ }
+};
+
+const printReport = async (req, res, next) => {
+ try {
+ const { reportType, filters, statuses } = normalizeReportFilters(req.query);
+ const { rows } = await getReportRows(filters);
+ res.render('pengelola-aset/procurements/report-print', {
+ title: 'Cetak Rekap Pengadaan',
+ rows,
+ reportType,
+ filters,
+ statuses,
+ reportTypes,
+ rupiah
+ });
+ } catch (err) {
+ next(err);
+ }
+};
+
+const exportReportCsv = async (req, res, next) => {
+ try {
+ const { reportType, filters } = normalizeReportFilters(req.query);
+ const { rows } = await getReportRows(filters);
+ const meta = getReportMeta(reportType);
+ const escape = (value) => `"${String(value ?? '').replace(/"/g, '""')}"`;
+ const csvRows = reportType === 'requests'
+ ? rows.map(row => [row.request_number, row.name, row.specification, row.quantity, row.status, row.created_at, row.updated_at, row.employee_name])
+ : reportType === 'assets'
+ ? rows.map(row => [row.code, row.name, row.brand, row.model, row.serial_number, row.specification, row.acquisition_date, row.acquisition_cost, row.status, row.created_at])
+ : rows.map(row => [row.request_number, row.title, row.item_name, row.quantity, row.estimated_price, row.status, row.created_at, row.updated_at, row.created_by_name]);
+
+ const csv = [meta.header.join(',')]
+ .concat(csvRows.map(row => row.map(escape).join(',')))
+ .join('\n');
+
+ res.setHeader('Content-Type', 'text/csv; charset=utf-8');
+ res.setHeader('Content-Disposition', `attachment; filename="${meta.exportName}-${Date.now()}.csv"`);
+ res.send(csv);
+ } catch (err) {
+ next(err);
+ }
+};
+
+module.exports = {
+ listRequests,
+ detailRequest,
+ updateRequestStatus,
+ showCreateProcurement,
+ createProcurement,
+ listProcurements,
+ procurementItemsPage,
+ detailProcurement,
+ submitProcurement,
+ decideProcurement,
+ showAddAsset,
+ addAssetFromProcurement,
+ report,
+ printReport,
+ exportReportCsv
+};
diff --git a/controllers/usersController.js b/controllers/usersController.js
deleted file mode 100644
index ac27904c..00000000
--- a/controllers/usersController.js
+++ /dev/null
@@ -1,7 +0,0 @@
-const list = (req, res) => {
- res.send('respond with a resource');
-};
-
-module.exports = {
- list
-};
diff --git a/controllers/usulanController.js b/controllers/usulanController.js
new file mode 100644
index 00000000..96bb5f45
--- /dev/null
+++ b/controllers/usulanController.js
@@ -0,0 +1,905 @@
+const db = require("../lib/db");
+const PDFDocument = require("pdfkit");
+
+async function generateRequestNumber() {
+ const year = new Date().getFullYear();
+ const prefix = `REQ-${year}-`;
+
+ const [rows] = await db.query(
+ `SELECT request_number FROM equipment_procurements
+ WHERE request_number LIKE ?
+ ORDER BY id DESC LIMIT 1`,
+ [`${prefix}%`]
+ );
+
+ if (rows.length === 0) return `${prefix}00001`;
+
+ const lastNum = parseInt(rows[0].request_number.replace(prefix, ""), 10);
+ return `${prefix}${String(lastNum + 1).padStart(5, "0")}`;
+}
+
+function formatRupiah(amount) {
+ if (!amount && amount !== 0) return "Rp 0";
+ return "Rp " + parseFloat(amount).toLocaleString("id-ID");
+}
+
+function statusLabel(status) {
+ const map = {
+ draft: "Draft",
+ submitted: "Diajukan ke Wakil Dekan",
+ approved: "Disetujui",
+ rejected: "Ditolak",
+ completed: "Selesai",
+ };
+ return map[status] || status;
+}
+
+function statusLabelForRequest(status, requestNumber) {
+ const isAssetRequest = String(requestNumber || '').startsWith('REQ-');
+ if (isAssetRequest && status === 'submitted') return 'Menunggu Pengelola Aset';
+ if (isAssetRequest && status === 'rejected') return 'Ditolak Pengelola Aset';
+ return statusLabel(status);
+}
+
+async function getEmployeeId(userId) {
+ const [rows] = await db.query(
+ `SELECT id FROM employees WHERE id = ?`,
+ [userId]
+ );
+ if (rows.length > 0) return rows[0].id;
+
+ const [fallback] = await db.query(`SELECT id FROM employees LIMIT 1`);
+ if (fallback.length > 0) return fallback[0].id;
+
+ throw new Error(
+ "Tidak ada data employee di database. Jalankan setup_database_lengkap.sql terlebih dahulu."
+ );
+}
+
+const index = async (req, res, next) => {
+ try {
+ const userId = req.session.userId;
+ const employeeId = await getEmployeeId(userId);
+ const search = String(req.query.search || '').trim();
+ const page = Math.max(1, parseInt(req.query.page, 10) || 1);
+ const limit = 10;
+ const offset = (page - 1) * limit;
+
+ let whereClause = 'WHERE ep.created_by = ?';
+ const params = [employeeId];
+
+ if (search) {
+ whereClause += ' AND (ep.request_number LIKE ? OR ep.title LIKE ?)';
+ const like = `%${search}%`;
+ params.push(like, like);
+ }
+
+
+ const [countRows] = await db.query(
+ `SELECT COUNT(DISTINCT ep.id) AS total
+ FROM equipment_procurements ep
+ ${whereClause}`,
+ params
+ );
+ const totalItems = countRows[0].total;
+ const totalPages = Math.max(1, Math.ceil(totalItems / limit));
+
+
+ const [procurements] = await db.query(
+ `SELECT
+ ep.id,
+ ep.request_number,
+ ep.title,
+ ep.status,
+ ep.created_at,
+ ep.updated_at,
+ COUNT(epi.id) AS total_items,
+ COALESCE(SUM(epi.quantity * epi.estimated_price), 0) AS total_estimasi
+ FROM equipment_procurements ep
+ LEFT JOIN equipment_proc_items epi ON ep.id = epi.equipment_proc_id
+ ${whereClause}
+ GROUP BY ep.id
+ ORDER BY ep.created_at DESC
+ LIMIT ? OFFSET ?`,
+ [...params, limit, offset]
+ );
+
+ const successMessage = req.session.successMessage || null;
+ const errorMessage = req.session.errorMessage || null;
+ delete req.session.successMessage;
+ delete req.session.errorMessage;
+
+ res.render("usulan/index", {
+ title: "Usulan Pengadaan Barang",
+ user: req.session.username || null,
+ procurements,
+ formatRupiah,
+ statusLabel,
+ successMessage,
+ errorMessage,
+ search,
+ currentPage: page,
+ totalPages,
+ totalItems,
+ });
+ } catch (err) {
+ next(err);
+ }
+};
+
+const createPage = async (req, res, next) => {
+ try {
+ res.render("usulan/create", {
+ title: "Buat Usulan Pengadaan",
+ user: req.session.username || null,
+ errors: [],
+ old: {},
+ });
+ } catch (err) {
+ next(err);
+ }
+};
+
+const store = async (req, res, next) => {
+ const userId = req.session.userId;
+
+ const title = req.body.title;
+ const action = req.body.action;
+
+ const item_names = req.body["item_names[]"];
+ const item_specs = req.body["item_specs[]"];
+ const item_quantities = req.body["item_quantities[]"];
+ const item_prices = req.body["item_prices[]"];
+
+ const errors = [];
+ if (!title || title.trim() === "") errors.push("Judul usulan wajib diisi.");
+
+ const names = Array.isArray(item_names) ? item_names : item_names ? [item_names] : [];
+ const specs = Array.isArray(item_specs) ? item_specs : item_specs ? [item_specs] : [];
+ const quantities = Array.isArray(item_quantities) ? item_quantities : item_quantities ? [item_quantities] : [];
+ const prices = Array.isArray(item_prices) ? item_prices : item_prices ? [item_prices] : [];
+
+ if (names.filter((n) => n && n.trim() !== "").length === 0) {
+ errors.push("Minimal 1 item barang harus ditambahkan.");
+ }
+
+ if (errors.length > 0) {
+ return res.render("usulan/create", {
+ title: "Buat Usulan Pengadaan",
+ user: req.session.username || null,
+ errors,
+ old: req.body,
+ });
+ }
+
+ const status = action === "submit" ? "submitted" : "draft";
+
+ const connection = await db.getConnection();
+ try {
+ await connection.beginTransaction();
+
+ const employeeId = await getEmployeeId(userId);
+ const requestNumber = await generateRequestNumber();
+
+ const [result] = await connection.query(
+ `INSERT INTO equipment_procurements
+ (request_number, title, status, created_by, employee_id, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, NOW(), NOW())`,
+ [requestNumber, title.trim(), status, employeeId, employeeId]
+ );
+
+ const procId = result.insertId;
+
+ for (let i = 0; i < names.length; i++) {
+ if (!names[i] || names[i].trim() === "") continue;
+ const qty = parseInt(quantities[i], 10) || 1;
+ const price = parseFloat(prices[i]) || 0;
+ await connection.query(
+ `INSERT INTO equipment_proc_items
+ (equipment_proc_id, name, specification, quantity, estimated_price,
+ asset_equipment_procurement_id, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW())`,
+ [procId, names[i].trim(), specs[i] || null, qty, price, procId]
+ );
+ }
+
+ await connection.commit();
+
+ const statusText = status === "submitted" ? "diajukan ke Pengelola Aset" : "disimpan sebagai draft";
+ req.session.successMessage = `Usulan "${title.trim()}" berhasil ${statusText} (${requestNumber}).`;
+ res.redirect("/usulan");
+ } catch (err) {
+ await connection.rollback();
+ next(err);
+ } finally {
+ connection.release();
+ }
+};
+
+const editPage = async (req, res, next) => {
+ try {
+ const { id } = req.params;
+ const userId = req.session.userId;
+ const employeeId = await getEmployeeId(userId);
+
+ const [procRows] = await db.query(
+ `SELECT * FROM equipment_procurements WHERE id = ? AND created_by = ?`,
+ [id, employeeId]
+ );
+
+ if (procRows.length === 0) {
+ req.session.errorMessage = "Usulan tidak ditemukan.";
+ return res.redirect("/usulan");
+ }
+
+ if (procRows[0].status !== "draft") {
+ req.session.errorMessage = "Hanya usulan berstatus 'draft' yang dapat diedit.";
+ return res.redirect("/usulan");
+ }
+
+ const [items] = await db.query(
+ `SELECT * FROM equipment_proc_items WHERE equipment_proc_id = ? ORDER BY id`,
+ [id]
+ );
+
+ res.render("usulan/edit", {
+ title: "Edit Usulan Pengadaan",
+ user: req.session.username || null,
+ procurement: procRows[0],
+ items,
+ errors: [],
+ });
+ } catch (err) {
+ next(err);
+ }
+};
+
+const update = async (req, res, next) => {
+ const { id } = req.params;
+ const userId = req.session.userId;
+
+ const title = req.body.title;
+ const action = req.body.action;
+
+ const item_names = req.body["item_names[]"];
+ const item_specs = req.body["item_specs[]"];
+ const item_quantities = req.body["item_quantities[]"];
+ const item_prices = req.body["item_prices[]"];
+
+ const errors = [];
+ if (!title || title.trim() === "") errors.push("Judul usulan wajib diisi.");
+
+ const names = Array.isArray(item_names) ? item_names : item_names ? [item_names] : [];
+ const specs = Array.isArray(item_specs) ? item_specs : item_specs ? [item_specs] : [];
+ const quantities = Array.isArray(item_quantities) ? item_quantities : item_quantities ? [item_quantities] : [];
+ const prices = Array.isArray(item_prices) ? item_prices : item_prices ? [item_prices] : [];
+
+ if (names.filter((n) => n && n.trim() !== "").length === 0) {
+ errors.push("Minimal 1 item barang harus ditambahkan.");
+ }
+
+ if (errors.length > 0) {
+ const [items] = await db.query(`SELECT * FROM equipment_proc_items WHERE equipment_proc_id = ?`, [id]);
+ const [procRows] = await db.query(`SELECT * FROM equipment_procurements WHERE id = ?`, [id]);
+ return res.render("usulan/edit", {
+ title: "Edit Usulan Pengadaan",
+ user: req.session.username || null,
+ procurement: procRows[0] || {},
+ items,
+ errors,
+ });
+ }
+
+ const connection = await db.getConnection();
+ try {
+ await connection.beginTransaction();
+
+ const employeeId = await getEmployeeId(userId);
+
+ const [procRows] = await connection.query(
+ `SELECT * FROM equipment_procurements WHERE id = ? AND created_by = ?`,
+ [id, employeeId]
+ );
+
+ if (procRows.length === 0) {
+ await connection.rollback();
+ req.session.errorMessage = "Usulan tidak ditemukan.";
+ return res.redirect("/usulan");
+ }
+
+ if (procRows[0].status !== "draft") {
+ await connection.rollback();
+ req.session.errorMessage = "Hanya usulan berstatus 'draft' yang dapat diedit.";
+ return res.redirect("/usulan");
+ }
+
+ const newStatus = action === "submit" ? "submitted" : "draft";
+
+ await connection.query(
+ `UPDATE equipment_procurements SET title = ?, status = ?, updated_at = NOW() WHERE id = ?`,
+ [title.trim(), newStatus, id]
+ );
+
+ await connection.query(
+ `DELETE FROM equipment_proc_items WHERE equipment_proc_id = ?`,
+ [id]
+ );
+
+ for (let i = 0; i < names.length; i++) {
+ if (!names[i] || names[i].trim() === "") continue;
+ const qty = parseInt(quantities[i], 10) || 1;
+ const price = parseFloat(prices[i]) || 0;
+ await connection.query(
+ `INSERT INTO equipment_proc_items
+ (equipment_proc_id, name, specification, quantity, estimated_price,
+ asset_equipment_procurement_id, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW())`,
+ [id, names[i].trim(), specs[i] || null, qty, price, id]
+ );
+ }
+
+ await connection.commit();
+
+ const statusText = newStatus === "submitted" ? "diajukan ke Pengelola Aset" : "disimpan sebagai draft";
+ req.session.successMessage = `Usulan berhasil diperbarui dan ${statusText}.`;
+ res.redirect("/usulan");
+ } catch (err) {
+ await connection.rollback();
+ next(err);
+ } finally {
+ connection.release();
+ }
+};
+
+const destroy = async (req, res, next) => {
+ const { id } = req.params;
+ const userId = req.session.userId;
+
+ const connection = await db.getConnection();
+ try {
+ await connection.beginTransaction();
+
+ const employeeId = await getEmployeeId(userId);
+
+ const [procRows] = await connection.query(
+ `SELECT * FROM equipment_procurements WHERE id = ? AND created_by = ?`,
+ [id, employeeId]
+ );
+
+ if (procRows.length === 0) {
+ await connection.rollback();
+ req.session.errorMessage = "Usulan tidak ditemukan.";
+ return res.redirect("/usulan");
+ }
+
+ if (procRows[0].status !== "draft") {
+ await connection.rollback();
+ req.session.errorMessage = "Hanya usulan berstatus 'draft' yang dapat dihapus.";
+ return res.redirect("/usulan");
+ }
+
+ await connection.query(`DELETE FROM equipment_proc_items WHERE equipment_proc_id = ?`, [id]);
+ await connection.query(`DELETE FROM equipment_procurements WHERE id = ?`, [id]);
+
+ await connection.commit();
+ req.session.successMessage = "Usulan berhasil dihapus.";
+ res.redirect("/usulan");
+ } catch (err) {
+ await connection.rollback();
+ next(err);
+ } finally {
+ connection.release();
+ }
+};
+
+const COLORS = {
+ primary: "#1e3a5f",
+ primaryMid: "#2563eb",
+ headerBg: "#1e3a5f",
+ headerText: "#ffffff",
+ subText: "#bfdbfe",
+ labelText: "#6b7280",
+ bodyText: "#111827",
+ rowAlt: "#f8fafc",
+ rowNormal: "#ffffff",
+ tableHeader: "#dbeafe",
+ tableHeaderText: "#1e3a5f",
+ totalRowBg: "#1e3a5f",
+ totalRowText:"#ffffff",
+ borderColor: "#e2e8f0",
+ sectionBg: "#f1f5f9",
+ divider: "#cbd5e1",
+ statusDraft: "#64748b",
+ statusSubmitted: "#2563eb",
+ statusApproved: "#16a34a",
+ statusRejected: "#dc2626",
+ statusCompleted: "#7c3aed",
+};
+
+const MARGIN = 45;
+const PAGE_W = 595;
+const PAGE_H = 842;
+const CONTENT_W = PAGE_W - MARGIN * 2;
+const FOOTER_Y = PAGE_H - 70;
+
+const COL_WIDTHS = [150, 145, 35, 95, 80];
+const TABLE_W = COL_WIDTHS.reduce((a, b) => a + b, 0);
+
+function getColX() {
+ const xs = [];
+ let x = MARGIN;
+ COL_WIDTHS.forEach((w) => { xs.push(x); x += w; });
+ return xs;
+}
+
+function statusColor(status) {
+ return {
+ draft: COLORS.statusDraft,
+ submitted: COLORS.statusSubmitted,
+ approved: COLORS.statusApproved,
+ rejected: COLORS.statusRejected,
+ completed: COLORS.statusCompleted,
+ }[status] || COLORS.statusDraft;
+}
+
+function drawTableRow(doc, y, cells, isHeader = false, bgColor = COLORS.rowNormal, textColor = null) {
+ const PAD_H = 6;
+ const PAD_V = 5;
+ const COL_X = getColX();
+
+
+ doc.fontSize(isHeader ? 8 : 8);
+ let maxHeight = 0;
+ cells.forEach((text, i) => {
+ const h = doc.heightOfString(String(text || ""), {
+ width: COL_WIDTHS[i] - PAD_H * 2,
+ lineBreak: true,
+ });
+ if (h > maxHeight) maxHeight = h;
+ });
+ const rowH = Math.max(isHeader ? 22 : 20, maxHeight + PAD_V * 2);
+
+
+ doc.rect(MARGIN, y, TABLE_W, rowH).fillColor(bgColor).fill();
+
+
+ doc.moveTo(MARGIN, y + rowH)
+ .lineTo(MARGIN + TABLE_W, y + rowH)
+ .strokeColor(COLORS.borderColor)
+ .lineWidth(0.5)
+ .stroke();
+
+
+ doc.moveTo(MARGIN, y).lineTo(MARGIN, y + rowH).strokeColor(COLORS.borderColor).lineWidth(0.5).stroke();
+ doc.moveTo(MARGIN + TABLE_W, y).lineTo(MARGIN + TABLE_W, y + rowH).strokeColor(COLORS.borderColor).lineWidth(0.5).stroke();
+
+
+ let lineX = MARGIN;
+ for (let i = 0; i < COL_WIDTHS.length - 1; i++) {
+ lineX += COL_WIDTHS[i];
+ doc.moveTo(lineX, y).lineTo(lineX, y + rowH).strokeColor(COLORS.borderColor).lineWidth(0.5).stroke();
+ }
+
+
+ const fColor = textColor || (isHeader ? COLORS.tableHeaderText : COLORS.bodyText);
+ doc.fillColor(fColor)
+ .font(isHeader ? "Helvetica-Bold" : "Helvetica")
+ .fontSize(isHeader ? 8 : 8);
+
+ cells.forEach((text, i) => {
+
+ const align = i >= 2 ? "right" : "left";
+ doc.text(
+ String(text ?? "-"),
+ COL_X[i] + PAD_H,
+ y + PAD_V,
+ {
+ width: COL_WIDTHS[i] - PAD_H * 2,
+ height: rowH - PAD_V * 2,
+ align,
+ lineBreak: true,
+ }
+ );
+ });
+
+ return rowH;
+}
+
+function drawTableHeader(doc, y) {
+ const COL_X = getColX();
+ const PAD_H = 6;
+ const rowH = 22;
+
+
+ doc.rect(MARGIN, y, TABLE_W, rowH).fillColor(COLORS.tableHeader).fill();
+
+
+ doc.moveTo(MARGIN, y).lineTo(MARGIN + TABLE_W, y).strokeColor(COLORS.primaryMid).lineWidth(1).stroke();
+
+
+ doc.moveTo(MARGIN, y + rowH).lineTo(MARGIN + TABLE_W, y + rowH).strokeColor(COLORS.borderColor).lineWidth(0.5).stroke();
+
+
+ doc.moveTo(MARGIN, y).lineTo(MARGIN, y + rowH).strokeColor(COLORS.borderColor).lineWidth(0.5).stroke();
+ doc.moveTo(MARGIN + TABLE_W, y).lineTo(MARGIN + TABLE_W, y + rowH).strokeColor(COLORS.borderColor).lineWidth(0.5).stroke();
+
+
+ let lineX = MARGIN;
+ for (let i = 0; i < COL_WIDTHS.length - 1; i++) {
+ lineX += COL_WIDTHS[i];
+ doc.moveTo(lineX, y).lineTo(lineX, y + rowH).strokeColor(COLORS.borderColor).lineWidth(0.5).stroke();
+ }
+
+
+ const headers = ["Nama Barang", "Spesifikasi", "Qty", "Harga Satuan", "Subtotal"];
+ doc.font("Helvetica-Bold").fontSize(8).fillColor(COLORS.tableHeaderText);
+ headers.forEach((h, i) => {
+ const align = i >= 2 ? "right" : "left";
+ doc.text(h, COL_X[i] + PAD_H, y + 7, {
+ width: COL_WIDTHS[i] - PAD_H * 2,
+ align,
+ });
+ });
+
+ return rowH;
+}
+
+function drawTotalRow(doc, y, totalText) {
+ const COL_X = getColX();
+ const PAD_H = 6;
+ const rowH = 22;
+ const totalColW = COL_WIDTHS.slice(2).reduce((a, b) => a + b, 0);
+ const labelW = COL_WIDTHS[0] + COL_WIDTHS[1];
+
+
+ doc.rect(MARGIN, y, TABLE_W, rowH).fillColor(COLORS.primary).fill();
+
+
+ doc.rect(MARGIN, y, TABLE_W, rowH).strokeColor(COLORS.primary).lineWidth(0.5).stroke();
+
+
+ doc.font("Helvetica-Bold").fontSize(8).fillColor(COLORS.headerText)
+ .text("TOTAL ESTIMASI", MARGIN + PAD_H, y + 7, { width: labelW - PAD_H * 2 });
+
+
+ doc.font("Helvetica-Bold").fontSize(8).fillColor(COLORS.headerText)
+ .text(totalText, COL_X[2], y + 7, {
+ width: totalColW - PAD_H,
+ align: "right",
+ });
+
+ return rowH;
+}
+
+function drawFooter(doc, pageNum, totalPages, tanggal) {
+
+ doc.moveTo(MARGIN, FOOTER_Y - 8)
+ .lineTo(MARGIN + CONTENT_W, FOOTER_Y - 8)
+ .strokeColor(COLORS.divider)
+ .lineWidth(0.5)
+ .stroke();
+
+ doc.font("Helvetica").fontSize(7.5).fillColor(COLORS.labelText);
+
+ doc.text(
+ `Dokumen ini digenerate otomatis oleh FacultyWare — ${tanggal}`,
+ MARGIN,
+ FOOTER_Y,
+ { width: CONTENT_W * 0.65, lineBreak: false}
+ );
+
+ doc.text(
+ `Halaman ${pageNum} dari ${totalPages}`,
+ MARGIN,
+ FOOTER_Y,
+ { width: CONTENT_W, align: "right", lineBreak: false }
+ );
+}
+
+const downloadLaporan = async (req, res, next) => {
+ try {
+ const userId = req.session.userId;
+ const employeeId = await getEmployeeId(userId);
+
+ const [procurements] = await db.query(
+ `SELECT ep.*, e.name AS employee_name, e.employee_number
+ FROM equipment_procurements ep
+ LEFT JOIN employees e ON ep.created_by = e.id
+ WHERE ep.created_by = ?
+ ORDER BY ep.created_at DESC`,
+ [employeeId]
+ );
+
+ for (const proc of procurements) {
+ const [items] = await db.query(
+ `SELECT * FROM equipment_proc_items WHERE equipment_proc_id = ? ORDER BY id`,
+ [proc.id]
+ );
+ proc.items = items;
+ proc.total_estimasi = items.reduce(
+ (sum, item) => sum + parseFloat(item.estimated_price || 0) * (item.quantity || 1),
+ 0
+ );
+ }
+
+
+ const doc = new PDFDocument({ margin: MARGIN, size: "A4", bufferPages: true });
+
+ const tanggal = new Date().toLocaleDateString("id-ID", { year: "numeric", month: "long", day: "numeric" });
+ const namaUser = req.session.username || "Ketua Departemen";
+
+ res.setHeader( "Content-Disposition",
+ 'attachment; filename="Laporan Rekapan Pengadaan Barang.pdf"'
+ );
+ doc.pipe(res);
+
+
+ let curY = MARGIN;
+
+
+ const HEADER_H = 75;
+ doc.rect(MARGIN, curY, CONTENT_W, HEADER_H).fillColor(COLORS.headerBg).fill();
+
+
+ doc.font("Helvetica-Bold").fontSize(16).fillColor(COLORS.headerText)
+ .text(
+ "LAPORAN REKAPAN PENGADAAN BARANG",
+ MARGIN, curY + 14,
+ { width: CONTENT_W, align: "center" }
+ );
+
+
+ doc.font("Helvetica").fontSize(9).fillColor(COLORS.subText)
+ .text(
+ "FacultyWare \u2014 Sistem Informasi Aset Fakultas",
+ MARGIN, curY + 36,
+ { width: CONTENT_W, align: "center" }
+ );
+
+
+ doc.rect(MARGIN, curY + HEADER_H - 4, CONTENT_W, 4).fillColor(COLORS.primaryMid).fill();
+
+ curY += HEADER_H + 12;
+
+
+
+ const INFO_H = 42;
+ doc.rect(MARGIN, curY, CONTENT_W, INFO_H).fillColor(COLORS.sectionBg).fill();
+ doc.rect(MARGIN, curY, 3, INFO_H).fillColor(COLORS.primaryMid).fill();
+
+ const halfW = CONTENT_W / 2 - 10;
+ const infoX1 = MARGIN + 10;
+ const infoX2 = MARGIN + CONTENT_W / 2 + 5;
+
+
+ doc.font("Helvetica-Bold").fontSize(7).fillColor(COLORS.labelText)
+ .text("DICETAK OLEH", infoX1, curY + 8, { width: halfW });
+ doc.font("Helvetica").fontSize(9).fillColor(COLORS.bodyText)
+ .text(namaUser, infoX1, curY + 18, { width: halfW });
+
+
+ doc.font("Helvetica-Bold").fontSize(7).fillColor(COLORS.labelText)
+ .text("TANGGAL CETAK", infoX2, curY + 8, { width: halfW / 2 });
+ doc.font("Helvetica").fontSize(9).fillColor(COLORS.bodyText)
+ .text(tanggal, infoX2, curY + 18, { width: halfW / 2 });
+
+
+ const infoX3 = infoX2 + halfW / 2 + 10;
+ doc.font("Helvetica-Bold").fontSize(7).fillColor(COLORS.labelText)
+ .text("TOTAL USULAN", infoX3, curY + 8, { width: halfW / 2 });
+ doc.font("Helvetica").fontSize(9).fillColor(COLORS.bodyText)
+ .text(`${procurements.length} usulan`, infoX3, curY + 18, { width: halfW / 2 });
+
+ curY += INFO_H + 18;
+
+
+ if (procurements.length === 0) {
+ doc.font("Helvetica").fontSize(11).fillColor(COLORS.labelText)
+ .text("Belum ada usulan pengadaan barang.", MARGIN, curY, { width: CONTENT_W, align: "center" });
+ }
+
+ procurements.forEach((proc, procIdx) => {
+
+ const estimatedH = 20 + 54 + 8 + 22 + Math.max(proc.items.length, 1) * 22 + 22 + 18;
+
+ if (
+ procIdx > 0 &&
+ curY + estimatedH > FOOTER_Y - 20
+ ) {
+ doc.addPage();
+ curY = MARGIN;
+ }
+
+
+ doc.x = MARGIN;
+ doc.y = curY;
+
+
+ const BADGE_H = 22;
+
+
+ doc.rect(MARGIN, curY, CONTENT_W, BADGE_H).fillColor(COLORS.sectionBg).fill();
+
+
+ const sColor = statusColor(proc.status);
+ doc.rect(MARGIN, curY, 3, BADGE_H).fillColor(sColor).fill();
+
+
+ doc.font("Helvetica-Bold").fontSize(9).fillColor(COLORS.primary)
+ .text(
+ `#${procIdx + 1} ${proc.request_number}`,
+ MARGIN + 10, curY + 6,
+ { width: CONTENT_W * 0.6 }
+ );
+
+
+ doc.font("Helvetica-Bold")
+ .fontSize(8)
+ .fillColor(sColor)
+ .text(
+ `Status : ${statusLabel(proc.status)}`,
+ MARGIN,
+ curY + 7,
+ {
+ width: CONTENT_W - 10,
+ align: "right"
+ }
+ );
+
+ curY += BADGE_H;
+
+
+ const DETAIL_H = 52;
+ doc.rect(MARGIN, curY, CONTENT_W, DETAIL_H).fillColor(COLORS.rowNormal).fill();
+
+ doc.moveTo(MARGIN, curY + DETAIL_H)
+ .lineTo(MARGIN + CONTENT_W, curY + DETAIL_H)
+ .strokeColor(COLORS.borderColor).lineWidth(0.5).stroke();
+
+ const colA_X = MARGIN + 10;
+ const colB_X = MARGIN + CONTENT_W * 0.5 + 5;
+ const colW = CONTENT_W * 0.48;
+
+
+ const drawField = (label, value, x, y, width) => {
+ doc.font("Helvetica-Bold").fontSize(7).fillColor(COLORS.labelText)
+ .text(label, x, y, { width });
+ doc.font("Helvetica").fontSize(8.5).fillColor(COLORS.bodyText)
+ .text(String(value || "-"), x, y + 9, { width, lineBreak: false, ellipsis: true });
+ };
+
+ const tglDibuat = new Date(proc.created_at).toLocaleDateString("id-ID", {
+ day: "2-digit", month: "long", year: "numeric",
+ });
+
+
+ drawField("JUDUL USULAN", proc.title, colA_X, curY + 7, colW);
+ drawField("DIAJUKAN OLEH", proc.employee_name || namaUser, colB_X, curY + 7, colW);
+
+
+ drawField("TOTAL ESTIMASI", formatRupiah(proc.total_estimasi), colA_X, curY + 29, colW);
+ drawField("TANGGAL DIBUAT", tglDibuat, colB_X, curY + 29, colW);
+
+ curY += DETAIL_H + 6;
+
+
+ curY += drawTableHeader(doc, curY);
+
+ if (proc.items.length === 0) {
+ curY += drawTableRow(doc, curY, ["(Tidak ada item)", "", "", "", ""], false, COLORS.rowAlt);
+ } else {
+ proc.items.forEach((item, itemIdx) => {
+
+ if (curY + 44 > FOOTER_Y - 20) {
+ doc.addPage();
+ curY = MARGIN;
+
+ curY += drawTableHeader(doc, curY);
+ }
+
+ const subtotal = parseFloat(item.estimated_price || 0) * (item.quantity || 1);
+ const bg = itemIdx % 2 === 0 ? COLORS.rowNormal : COLORS.rowAlt;
+
+ curY += drawTableRow(
+ doc,
+ curY,
+ [
+ item.name || "-",
+ item.specification || "-",
+ String(item.quantity || 0),
+ formatRupiah(item.estimated_price),
+ formatRupiah(subtotal),
+ ],
+ false,
+ bg
+ );
+ });
+ }
+
+
+ curY += drawTotalRow(doc, curY, formatRupiah(proc.total_estimasi));
+
+
+ if (procIdx < procurements.length - 1) {
+ curY += 20;
+ }
+ });
+
+
+ const totalPages = doc.bufferedPageRange().count;
+ for (let i = 0; i < totalPages; i++) {
+ doc.switchToPage(i);
+ drawFooter(doc, i + 1, totalPages, tanggal);
+ }
+
+ doc.end();
+ } catch (err) {
+ next(err);
+ }
+};
+
+const apiRiwayat = async (req, res, next) => {
+ try {
+ const userId = req.session.userId;
+ const employeeId = await getEmployeeId(userId);
+
+ const [procurements] = await db.query(
+ `SELECT
+ ep.id,
+ ep.request_number,
+ ep.title,
+ ep.status,
+ ep.created_at,
+ ep.updated_at,
+ e.name AS created_by_name,
+ e.employee_number
+ FROM equipment_procurements ep
+ LEFT JOIN employees e ON ep.created_by = e.id
+ WHERE ep.created_by = ?
+ ORDER BY ep.created_at DESC`,
+ [employeeId]
+ );
+
+ for (const proc of procurements) {
+ const [items] = await db.query(
+ `SELECT id, name, specification, quantity, estimated_price
+ FROM equipment_proc_items
+ WHERE equipment_proc_id = ?
+ ORDER BY id`,
+ [proc.id]
+ );
+ proc.items = items;
+ proc.total_estimasi = items.reduce(
+ (sum, item) => sum + parseFloat(item.estimated_price || 0) * (item.quantity || 1),
+ 0
+ );
+ }
+
+ res.json({
+ status: "success",
+ total_usulan: procurements.length,
+ data: procurements.map(proc => ({
+ nomor_permintaan: proc.request_number,
+ judul_usulan: proc.title,
+ status: proc.status,
+ status_label: statusLabelForRequest(proc.status, proc.request_number),
+ tanggal_dibuat: proc.created_at,
+ total_estimasi: proc.total_estimasi,
+ barang: proc.items,
+ })),
+ });
+ } catch (err) {
+ next(err);
+ }
+};
+
+module.exports = {
+ index,
+ createPage,
+ store,
+ editPage,
+ update,
+ destroy,
+ downloadLaporan,
+ apiRiwayat,
+};
diff --git a/controllers/wakildekanController.js b/controllers/wakildekanController.js
new file mode 100644
index 00000000..454831f2
--- /dev/null
+++ b/controllers/wakildekanController.js
@@ -0,0 +1,441 @@
+const db = require('../lib/db');
+const PDFDocument = require('pdfkit');
+const { applyProcurementDecision } = require('../lib/procurement-assets');
+
+const listPermohonan = async (req, res) => {
+ try {
+ const search = String(req.query.search || '').trim();
+ const page = Math.max(1, parseInt(req.query.page, 10) || 1);
+ const limit = 10;
+ const offset = (page - 1) * limit;
+
+ let whereClause = "WHERE ep.status = 'submitted' AND ep.request_number NOT LIKE 'REQ-%'";
+ const params = [];
+
+ if (search) {
+ whereClause += ' AND (ep.request_number LIKE ? OR ep.title LIKE ?)';
+ const like = `%${search}%`;
+ params.push(like, like);
+ }
+
+ const [countRows] = await db.query(
+ `SELECT COUNT(*) AS total FROM equipment_procurements ep ${whereClause}`,
+ params
+ );
+ const totalItems = countRows[0].total;
+ const totalPages = Math.max(1, Math.ceil(totalItems / limit));
+
+ const [rows] = await db.query(`
+ SELECT ep.id, ep.request_number, ep.title, ep.status, e.name AS created_by_name, ep.created_at
+ FROM equipment_procurements ep
+ JOIN employees e ON ep.created_by = e.id
+ ${whereClause}
+ ORDER BY ep.created_at DESC
+ LIMIT ? OFFSET ?
+ `, [...params, limit, offset]);
+ res.render('wakildekan/index', {
+ permohonan: rows,
+ title: 'Daftar Permohonan Pengadaan',
+ search,
+ currentPage: page,
+ totalPages,
+ totalItems
+ });
+ } catch (error) {
+ console.error('listPermohonan error:', error);
+ res.status(500).render('wakildekan/error', {
+ title: 'Terjadi Kesalahan',
+ message: 'Gagal memuat daftar permohonan. Silakan coba lagi nanti.',
+ backUrl: '/wakildekan/dashboard'
+ });
+ }
+};
+
+const detailPermohonan = async (req, res) => {
+ try {
+ const id = req.params.id;
+ const permohonanQuery = `
+ SELECT ep.*, e.name AS created_by_name
+ FROM equipment_procurements ep
+ JOIN employees e ON ep.created_by = e.id
+ WHERE ep.id = ?
+ `;
+ const [permohonanRows] = await db.query(permohonanQuery, [id]);
+
+ if (permohonanRows.length === 0) {
+ return res.redirect('/wakildekan/permohonan');
+ }
+
+ const itemsQuery = `
+ SELECT * FROM equipment_proc_items
+ WHERE equipment_proc_id = ?
+ `;
+ const [itemRows] = await db.query(itemsQuery, [id]);
+
+ res.render('wakildekan/detail', {
+ permohonan: permohonanRows[0],
+ items: itemRows,
+ title: 'Detail Permohonan',
+ errorMessage: req.session.errorMessage || null
+ });
+ delete req.session.errorMessage;
+ } catch (error) {
+ console.error('detailPermohonan error:', error);
+ res.status(500).render('wakildekan/error', {
+ title: 'Terjadi Kesalahan',
+ message: 'Gagal memuat detail permohonan. Silakan coba lagi nanti.',
+ backUrl: '/wakildekan/permohonan'
+ });
+ }
+};
+
+const approvePermohonan = async (req, res) => {
+ const conn = await db.getConnection();
+ try {
+ const id = req.params.id;
+ await conn.beginTransaction();
+ const result = await applyProcurementDecision(conn, id, 'approved');
+ if (!result.updated) {
+ await conn.rollback();
+ return res.redirect('/wakildekan/permohonan');
+ }
+ await conn.commit();
+ res.redirect('/wakildekan/permohonan');
+ } catch (error) {
+ await conn.rollback();
+ console.error('approvePermohonan error:', error);
+ req.session.errorMessage = 'Gagal menyetujui permohonan: ' + (error.message || 'Terjadi kesalahan server');
+ res.redirect('/wakildekan/permohonan/' + req.params.id);
+ } finally {
+ conn.release();
+ }
+};
+
+const rejectPermohonan = async (req, res) => {
+ const conn = await db.getConnection();
+ try {
+ const id = req.params.id;
+ await conn.beginTransaction();
+ const result = await applyProcurementDecision(conn, id, 'rejected');
+ if (!result.updated) {
+ await conn.rollback();
+ return res.redirect('/wakildekan/permohonan');
+ }
+ await conn.commit();
+ res.redirect('/wakildekan/permohonan');
+ } catch (error) {
+ await conn.rollback();
+ console.error('rejectPermohonan error:', error);
+ req.session.errorMessage = 'Gagal menolak permohonan: ' + (error.message || 'Terjadi kesalahan server');
+ res.redirect('/wakildekan/permohonan/' + req.params.id);
+ } finally {
+ conn.release();
+ }
+};
+
+const riwayatPermohonan = async (req, res) => {
+ try {
+ const search = String(req.query.search || '').trim();
+ const page = Math.max(1, parseInt(req.query.page, 10) || 1);
+ const limit = 10;
+ const offset = (page - 1) * limit;
+
+ let whereClause = "WHERE ep.status IN ('approved', 'rejected') AND ep.request_number NOT LIKE 'REQ-%'";
+ const params = [];
+
+ if (search) {
+ whereClause += ' AND (ep.request_number LIKE ? OR ep.title LIKE ?)';
+ const like = `%${search}%`;
+ params.push(like, like);
+ }
+
+ const [countRows] = await db.query(
+ `SELECT COUNT(*) AS total FROM equipment_procurements ep ${whereClause}`,
+ params
+ );
+ const totalItems = countRows[0].total;
+ const totalPages = Math.max(1, Math.ceil(totalItems / limit));
+
+ const [rows] = await db.query(`
+ SELECT ep.id, ep.request_number, ep.title, ep.status, e.name AS created_by_name, ep.created_at
+ FROM equipment_procurements ep
+ JOIN employees e ON ep.created_by = e.id
+ ${whereClause}
+ ORDER BY ep.created_at DESC
+ LIMIT ? OFFSET ?
+ `, [...params, limit, offset]);
+ res.render('wakildekan/riwayat', {
+ permohonan: rows,
+ title: 'Riwayat Keputusan',
+ search,
+ currentPage: page,
+ totalPages,
+ totalItems
+ });
+ } catch (error) {
+ console.error('riwayatPermohonan error:', error);
+ res.status(500).render('wakildekan/error', {
+ title: 'Terjadi Kesalahan',
+ message: 'Gagal memuat riwayat keputusan. Silakan coba lagi nanti.',
+ backUrl: '/wakildekan/dashboard'
+ });
+ }
+};
+
+const downloadPDF = async (req, res) => {
+ try {
+ const query = `
+ SELECT ep.id, ep.request_number, ep.title, ep.status, e.name AS created_by_name, ep.created_at
+ FROM equipment_procurements ep
+ JOIN employees e ON ep.created_by = e.id
+ WHERE ep.status IN ('approved', 'rejected') AND ep.request_number NOT LIKE 'REQ-%'
+ ORDER BY ep.created_at DESC
+ `;
+ const [rows] = await db.query(query);
+
+ const margin = 50;
+ const doc = new PDFDocument({ margin, size: 'A4', bufferPages: true });
+
+ res.setHeader('Content-disposition', 'attachment; filename="laporan-pengadaan-wakildekan.pdf"');
+ res.setHeader('Content-type', 'application/pdf');
+
+ doc.pipe(res);
+
+ const pageWidth = doc.page.width;
+ const contentWidth = pageWidth - margin * 2;
+ const now = new Date();
+ const bulanIndo = ['Januari','Februari','Maret','April','Mei','Juni','Juli','Agustus','September','Oktober','November','Desember'];
+ const tanggalCetak = `${now.getDate()} ${bulanIndo[now.getMonth()]} ${now.getFullYear()}`;
+
+ // === HEADER / KOP SURAT ===
+ doc.fontSize(13).font('Helvetica-Bold').text('FAKULTAS TEKNOLOGI INFORMASI', margin, margin, { align: 'center', width: contentWidth });
+ doc.fontSize(10).font('Helvetica').text('UNIVERSITAS ANDALAS', { align: 'center', width: contentWidth });
+ doc.moveDown(0.3);
+ doc.moveTo(margin, doc.y).lineTo(pageWidth - margin, doc.y).lineWidth(2).stroke();
+ doc.moveDown(0.2);
+ doc.moveTo(margin, doc.y).lineTo(pageWidth - margin, doc.y).lineWidth(0.5).stroke();
+ doc.moveDown(1.2);
+
+ // === TITLE ===
+ doc.fontSize(12).font('Helvetica-Bold').text('LAPORAN REKAPAN KEPUTUSAN PENGADAAN BARANG', { align: 'center', width: contentWidth, underline: true });
+ doc.moveDown(0.3);
+ doc.fontSize(10).font('Helvetica').text(`Periode: Seluruh Data | Dicetak: ${tanggalCetak}`, { align: 'center', width: contentWidth });
+ doc.moveDown(1.5);
+
+ // === TABLE ===
+ const colWidths = [30, 80, 155, 90, 70, 70];
+ const colX = [margin];
+ for (let i = 1; i < colWidths.length; i++) {
+ colX.push(colX[i - 1] + colWidths[i - 1]);
+ }
+ const headers = ['No', 'Nomor Request', 'Judul Pengadaan', 'Diajukan Oleh', 'Tanggal', 'Status'];
+ const rowHeight = 22;
+ const headerBg = '#2c3e50';
+ const headerColor = '#ffffff';
+
+ function drawTableHeader(yPos) {
+ // Header background
+ doc.rect(margin, yPos, contentWidth, rowHeight + 4).fill(headerBg);
+ doc.fillColor(headerColor).font('Helvetica-Bold').fontSize(9);
+ headers.forEach((h, i) => {
+ doc.text(h, colX[i] + 5, yPos + 6, { width: colWidths[i] - 10, align: 'left' });
+ });
+ doc.fillColor('#000000');
+ return yPos + rowHeight + 4;
+ }
+
+ let y = drawTableHeader(doc.y);
+
+ doc.font('Helvetica').fontSize(8.5);
+
+ let approvedCount = 0;
+ let rejectedCount = 0;
+
+ rows.forEach((row, i) => {
+ if (y > 720) {
+ doc.addPage();
+ y = drawTableHeader(margin);
+ doc.font('Helvetica').fontSize(8.5);
+ }
+
+ // Alternate row background
+ if (i % 2 === 0) {
+ doc.rect(margin, y, contentWidth, rowHeight).fill('#f8f9fa');
+ }
+
+ // Row borders
+ doc.rect(margin, y, contentWidth, rowHeight).lineWidth(0.3).stroke('#dee2e6');
+
+ const reqNum = row.request_number || '-';
+ const title = row.title || '-';
+ const name = row.created_by_name || '-';
+ const date = new Date(row.created_at).toLocaleDateString('id-ID', { day: 'numeric', month: 'long', year: 'numeric' });
+ let status = 'Diajukan';
+ if (row.status === 'approved') { status = 'Disetujui'; approvedCount++; }
+ else if (row.status === 'rejected') { status = 'Ditolak'; rejectedCount++; }
+
+ doc.fillColor('#333333');
+ doc.text(String(i + 1), colX[0] + 5, y + 5, { width: colWidths[0] - 10 });
+ doc.font('Helvetica').text(reqNum, colX[1] + 5, y + 5, { width: colWidths[1] - 10 });
+ doc.text(title, colX[2] + 5, y + 5, { width: colWidths[2] - 10, ellipsis: true, height: rowHeight - 4 });
+ doc.text(name, colX[3] + 5, y + 5, { width: colWidths[3] - 10, ellipsis: true, height: rowHeight - 4 });
+ doc.text(date, colX[4] + 5, y + 5, { width: colWidths[4] - 10 });
+
+ // Status with color
+ if (row.status === 'approved') {
+ doc.fillColor('#16a34a');
+ } else if (row.status === 'rejected') {
+ doc.fillColor('#dc2626');
+ }
+ doc.font('Helvetica-Bold').text(status, colX[5] + 5, y + 5, { width: colWidths[5] - 10 });
+ doc.font('Helvetica').fillColor('#333333');
+
+ y += rowHeight;
+ });
+
+ // Bottom border
+ doc.moveTo(margin, y).lineTo(pageWidth - margin, y).lineWidth(1).stroke('#2c3e50');
+
+ // === SUMMARY ===
+ y += 20;
+ if (y > 700) {
+ doc.addPage();
+ y = margin;
+ }
+
+ doc.font('Helvetica-Bold').fontSize(10).fillColor('#000000');
+ doc.text('Ringkasan:', margin, y);
+ y += 18;
+ doc.font('Helvetica').fontSize(9);
+ doc.text(`Total Permohonan Diproses : ${rows.length}`, margin + 10, y);
+ y += 15;
+ doc.fillColor('#16a34a').text(`Disetujui : ${approvedCount}`, margin + 10, y);
+ y += 15;
+ doc.fillColor('#dc2626').text(`Ditolak : ${rejectedCount}`, margin + 10, y);
+ doc.fillColor('#000000');
+
+ // === SIGNATURE ===
+ y += 40;
+ if (y > 650) {
+ doc.addPage();
+ y = margin;
+ }
+
+ const signX = pageWidth - margin - 200;
+ doc.font('Helvetica').fontSize(9).text(tanggalCetak, signX, y, { width: 200, align: 'center' });
+ y += 15;
+ doc.font('Helvetica-Bold').fontSize(9).text('Wakil Dekan,', signX, y, { width: 200, align: 'center' });
+ y += 60;
+ doc.moveTo(signX + 20, y).lineTo(signX + 180, y).lineWidth(0.5).stroke();
+ y += 5;
+ doc.font('Helvetica').fontSize(8).text('NIP. ____________________', signX, y, { width: 200, align: 'center' });
+
+ // === PAGE NUMBERS ===
+ const totalPages = doc.bufferedPageRange().count;
+ for (let i = 0; i < totalPages; i++) {
+ doc.switchToPage(i);
+ doc.fontSize(7).font('Helvetica').fillColor('#999999');
+ doc.text(
+ `Halaman ${i + 1} dari ${totalPages} — Dicetak oleh Sistem FacultyWare pada ${tanggalCetak}`,
+ margin, doc.page.height - 30,
+ { width: contentWidth, align: 'center' }
+ );
+ }
+
+ doc.end();
+ } catch (error) {
+ console.error('downloadPDF error:', error);
+ res.status(500).render('wakildekan/error', {
+ title: 'Gagal Mengunduh PDF',
+ message: 'Terjadi kesalahan saat membuat laporan PDF. Silakan coba lagi nanti.',
+ backUrl: '/wakildekan/riwayat'
+ });
+ }
+};
+
+const getPermohonanAPI = async (req, res) => {
+ try {
+ const query = `
+ SELECT ep.id, ep.request_number, ep.title, ep.status, e.name AS created_by_name, ep.created_at
+ FROM equipment_procurements ep
+ JOIN employees e ON ep.created_by = e.id
+ ORDER BY ep.created_at DESC
+ `;
+ const [rows] = await db.query(query);
+ res.status(200).json({
+ success: true,
+ message: "Data permohonan berhasil diambil",
+ data: rows
+ });
+ } catch (error) {
+ console.error(error);
+ res.status(500).json({
+ success: false,
+ message: "Server Error",
+ error: error.message
+ });
+ }
+};
+
+const dashboard = async (req, res) => {
+ try {
+ const statsQuery = `
+ SELECT
+ COUNT(*) AS total,
+ SUM(CASE WHEN status = 'submitted' THEN 1 ELSE 0 END) AS pending,
+ SUM(CASE WHEN status = 'approved' THEN 1 ELSE 0 END) AS approved,
+ SUM(CASE WHEN status = 'rejected' THEN 1 ELSE 0 END) AS rejected
+ FROM equipment_procurements
+ WHERE request_number NOT LIKE 'REQ-%'
+ `;
+
+ const recentSubmittedQuery = `
+ SELECT ep.id, ep.request_number, ep.title, ep.status, e.name AS created_by_name, ep.created_at
+ FROM equipment_procurements ep
+ JOIN employees e ON ep.created_by = e.id
+ WHERE ep.status = 'submitted' AND ep.request_number NOT LIKE 'REQ-%'
+ ORDER BY ep.created_at DESC
+ LIMIT 5
+ `;
+
+ const recentDecisionsQuery = `
+ SELECT ep.id, ep.request_number, ep.title, ep.status, e.name AS created_by_name, ep.updated_at
+ FROM equipment_procurements ep
+ JOIN employees e ON ep.created_by = e.id
+ WHERE ep.status IN ('approved', 'rejected') AND ep.request_number NOT LIKE 'REQ-%'
+ ORDER BY ep.updated_at DESC
+ LIMIT 5
+ `;
+
+ const [[statsRows], [submittedRows], [decisionRows]] = await Promise.all([
+ db.query(statsQuery),
+ db.query(recentSubmittedQuery),
+ db.query(recentDecisionsQuery)
+ ]);
+
+ res.render('wakildekan/dashboard', {
+ stats: statsRows[0],
+ recentSubmitted: submittedRows,
+ recentDecisions: decisionRows,
+ title: 'Dashboard Wakil Dekan'
+ });
+ } catch (error) {
+ console.error('dashboard error:', error);
+ res.status(500).render('wakildekan/error', {
+ title: 'Terjadi Kesalahan',
+ message: 'Gagal memuat dashboard. Silakan coba lagi nanti.',
+ backUrl: '/wakildekan/permohonan'
+ });
+ }
+};
+
+module.exports = {
+ listPermohonan,
+ detailPermohonan,
+ approvePermohonan,
+ rejectPermohonan,
+ riwayatPermohonan,
+ downloadPDF,
+ getPermohonanAPI,
+ dashboard
+};
diff --git a/database/seed_procurement_roles.sql b/database/seed_procurement_roles.sql
new file mode 100644
index 00000000..3e0a2aab
--- /dev/null
+++ b/database/seed_procurement_roles.sql
@@ -0,0 +1,53 @@
+USE `facultyware`;
+
+-- Seed ini hanya INSERT/UPDATE data awal. Tidak membuat/mengubah struktur tabel.
+SET @PASSWORD_HASH = '$2b$10$TEUAHN7ky2dkCUF9WCMs.elFsyYXnkJKLdbjkG2XxWglZtt0a5JHW'; -- password123
+
+INSERT INTO organization_units (id, name, code, parent_id, type, description, organization_unit_id, created_at, updated_at)
+VALUES (9001, 'Fakultas Teknologi Informasi', 'FTI-SEED', NULL, 'faculty', 'Seed unit untuk testing Pengadaan Barang', 9001, NOW(), NOW())
+ON DUPLICATE KEY UPDATE name = VALUES(name), updated_at = NOW();
+
+INSERT INTO employment_statuses (id, name, description, created_at, updated_at)
+VALUES (9001, 'Aktif', 'Seed status untuk testing', NOW(), NOW())
+ON DUPLICATE KEY UPDATE name = VALUES(name), updated_at = NOW();
+
+INSERT INTO users (id, name, email, email_verified_at, password, created_at, updated_at) VALUES
+(9001, 'Ketua Departemen', 'ketua.departemen@facultyware.test', NOW(), @PASSWORD_HASH, NOW(), NOW()),
+(9002, 'Pengelola Aset', 'pengelola.aset@facultyware.test', NOW(), @PASSWORD_HASH, NOW(), NOW()),
+(9003, 'Wakil Dekan', 'wakil.dekan@facultyware.test', NOW(), @PASSWORD_HASH, NOW(), NOW())
+ON DUPLICATE KEY UPDATE name = VALUES(name), email = VALUES(email), password = VALUES(password), updated_at = NOW();
+
+INSERT INTO employees (id, employee_number, national_id_number, tax_id_number, name, birth_place, birth_date, gender, religion, marital_status, address, phone_number, organization_unit_id, hire_date, employment_status_id, status, created_at, updated_at) VALUES
+(9001, 'EMP-PROC-001', NULL, NULL, 'Ketua Departemen', 'Padang', '1985-01-01', 'male', NULL, 'married', 'Kampus UNAND', '080000000001', 9001, '2020-01-01', 9001, 'active', NOW(), NOW()),
+(9002, 'EMP-PROC-002', NULL, NULL, 'Pengelola Aset', 'Padang', '1985-01-01', 'male', NULL, 'married', 'Kampus UNAND', '080000000002', 9001, '2020-01-01', 9001, 'active', NOW(), NOW()),
+(9003, 'EMP-PROC-003', NULL, NULL, 'Wakil Dekan', 'Padang', '1985-01-01', 'male', NULL, 'married', 'Kampus UNAND', '080000000003', 9001, '2020-01-01', 9001, 'active', NOW(), NOW())
+ON DUPLICATE KEY UPDATE name = VALUES(name), employee_number = VALUES(employee_number), updated_at = NOW();
+
+INSERT INTO roles (id, name, guard_name, created_at, updated_at) VALUES
+(9001, 'Ketua Departemen', 'web', NOW(), NOW()),
+(9002, 'Pengelola Aset', 'web', NOW(), NOW()),
+(9003, 'Wakil Dekan', 'web', NOW(), NOW()),
+(9005, 'Pengelola Sistem', 'web', NOW(), NOW())
+ON DUPLICATE KEY UPDATE name = VALUES(name), guard_name = VALUES(guard_name), updated_at = NOW();
+
+INSERT INTO permissions (id, name, guard_name, created_at, updated_at) VALUES
+(9001, 'procurement.request.read', 'web', NOW(), NOW()),
+(9002, 'procurement.request.update_status', 'web', NOW(), NOW()),
+(9003, 'procurement.create', 'web', NOW(), NOW()),
+(9004, 'procurement.read', 'web', NOW(), NOW()),
+(9005, 'procurement.submit', 'web', NOW(), NOW()),
+(9006, 'procurement.decision', 'web', NOW(), NOW()),
+(9007, 'procurement.asset.create', 'web', NOW(), NOW()),
+(9008, 'procurement.report', 'web', NOW(), NOW()),
+(9009, 'procurement.api.read', 'web', NOW(), NOW())
+ON DUPLICATE KEY UPDATE name = VALUES(name), guard_name = VALUES(guard_name), updated_at = NOW();
+
+INSERT IGNORE INTO model_has_roles (role_id, model_type, model_id) VALUES
+(9001, 'App\\Models\\User', 9001),
+(9002, 'App\\Models\\User', 9002),
+(9003, 'App\\Models\\User', 9003);
+
+INSERT IGNORE INTO role_has_permissions (permission_id, role_id) VALUES
+(9001, 9002), (9002, 9002), (9003, 9002), (9004, 9002), (9005, 9002), (9007, 9002), (9008, 9002), (9009, 9002),
+(9001, 9005), (9002, 9005), (9003, 9005), (9004, 9005), (9005, 9005), (9007, 9005), (9008, 9005), (9009, 9005),
+(9004, 9003), (9006, 9003), (9008, 9003), (9009, 9003);
diff --git a/find_user.js b/find_user.js
new file mode 100644
index 00000000..048cd0c4
--- /dev/null
+++ b/find_user.js
@@ -0,0 +1,21 @@
+const db = require('./lib/db');
+
+async function findAllUsers() {
+ try {
+ const query = `
+ SELECT u.email, u.password, r.name as role_name
+ FROM users u
+ JOIN model_has_roles mhr ON u.id = mhr.model_id
+ JOIN roles r ON mhr.role_id = r.id
+ `;
+ const [rows] = await db.query(query);
+ console.log("Users and roles:");
+ rows.forEach(r => console.log(r));
+ } catch (e) {
+ console.error(e);
+ } finally {
+ process.exit();
+ }
+}
+
+findAllUsers();
diff --git a/lib/db.js b/lib/db.js
index 76a7d5b8..bc22c043 100644
--- a/lib/db.js
+++ b/lib/db.js
@@ -1,5 +1,11 @@
const mysql = require('mysql2');
-require('dotenv').config();
+const path = require('path');
+require('dotenv').config({ path: path.join(__dirname, '../.env') });
+
+console.log("DB_HOST =", process.env.DB_HOST);
+console.log("DB_USER =", process.env.DB_USER);
+console.log("DB_NAME =", process.env.DB_NAME);
+console.log("DB_PASSWORD =", process.env.DB_PASSWORD);
const pool = mysql.createPool({
host: process.env.DB_HOST,
@@ -12,3 +18,13 @@ const pool = mysql.createPool({
});
module.exports = pool.promise();
+
+pool.getConnection((err, conn) => {
+ if (err) {
+ console.error("MYSQL ERROR:", err);
+ return;
+ }
+
+ console.log("MYSQL CONNECTED");
+ conn.release();
+});
\ No newline at end of file
diff --git a/lib/procurement-assets.js b/lib/procurement-assets.js
new file mode 100644
index 00000000..584080a5
--- /dev/null
+++ b/lib/procurement-assets.js
@@ -0,0 +1,17 @@
+async function applyProcurementDecision(conn, procurementId, decision) {
+ const [result] = await conn.query(`
+ UPDATE equipment_procurements
+ SET status = ?, updated_at = NOW()
+ WHERE id = ? AND status = 'submitted'
+ `, [decision, procurementId]);
+
+ if (!result.affectedRows) {
+ return { updated: false, assetSummary: { createdCount: 0, skipped: false } };
+ }
+
+ return { updated: true, assetSummary: { createdCount: 0, skipped: true } };
+}
+
+module.exports = {
+ applyProcurementDecision
+};
diff --git a/middlewares/acl.js b/middlewares/acl.js
index 1019f567..7e477e46 100644
--- a/middlewares/acl.js
+++ b/middlewares/acl.js
@@ -1,56 +1,63 @@
-const db = require("../lib/db");
-
-/**
- * ACL Middleware to check if a user has the required permission(s).
- *
- * @param {string|string[]} requiredPermissions - A single permission or an array of permissions.
- * If an array is provided, the user must have at least one of the permissions.
- *
- * Database Schema Requirements:
- *
- * 1. roles: id, name
- * 2. permissions: id, name
- * 3. role_has_permissions: role_id, permission_id
- * 4. user_has_roles: user_id, role_id
- */
-
-const checkPermission = (requiredPermissions) => {
- return async (req, res, next) => {
- if (!req.session.userId) {
- return res.status(401).json({ message: "Unauthorized" });
- }
+const db = require('../lib/db');
- const permissionsArray = Array.isArray(requiredPermissions)
- ? requiredPermissions
- : [requiredPermissions];
+function normalize(value) {
+ return String(value || '').trim().toLowerCase();
+}
- try {
- // Query to check if the user has a role that contains any of the required permissions
- const query = `
- SELECT DISTINCT p.name
- FROM permissions p
- JOIN role_has_permissions rhp ON p.id = rhp.permission_id
- JOIN user_has_roles uhr ON rhp.role_id = uhr.role_id
- WHERE uhr.user_id = ? AND p.name IN (?)
- `;
+function hasRole(allowedRoles) {
+ const roles = Array.isArray(allowedRoles) ? allowedRoles : [allowedRoles];
+ const normalizedAllowed = roles.map(normalize);
- const [rows] = await db.query(query, [req.session.userId, permissionsArray]);
+ return async (req, res, next) => {
+ if (!req.session || !req.session.userId) return res.redirect('/login');
- if (rows.length > 0) {
- return next();
+ try {
+ let userRoles = req.session.roles || [];
+ if (!userRoles.length) {
+ const [rows] = await db.query(`
+ SELECT r.name
+ FROM roles r
+ JOIN model_has_roles mhr ON mhr.role_id = r.id
+ WHERE mhr.model_id = ?
+ `, [req.session.userId]);
+ userRoles = rows.map(row => row.name);
+ req.session.roles = userRoles;
}
- // If no matching permission found, return Forbidden
- res.status(403).render("error", {
- message: "Forbidden: You do not have permission to access this resource.",
- error: { status: 403, stack: "" }
+ const ok = userRoles.some(role => normalizedAllowed.includes(normalize(role)));
+ if (ok) return next();
+
+ return res.status(403).render('error', {
+ message: 'Forbidden: Anda tidak memiliki akses ke halaman ini.',
+ error: { status: 403, stack: '' }
+ });
+ } catch (err) {
+ return next(err);
+ }
+ };
+}
+
+function checkPermission(requiredPermissions) {
+ return async (req, res, next) => {
+ if (!req.session || !req.session.userId) return res.redirect('/login');
+ const permissionsArray = Array.isArray(requiredPermissions) ? requiredPermissions : [requiredPermissions];
+ try {
+ const [rows] = await db.query(`
+ SELECT DISTINCT p.name
+ FROM permissions p
+ JOIN role_has_permissions rhp ON p.id = rhp.permission_id
+ JOIN model_has_roles mhr ON rhp.role_id = mhr.role_id
+ WHERE mhr.model_id = ? AND p.name IN (?)
+ `, [req.session.userId, permissionsArray]);
+ if (rows.length) return next();
+ return res.status(403).render('error', {
+ message: 'Forbidden: Anda tidak memiliki permission yang dibutuhkan.',
+ error: { status: 403, stack: '' }
});
} catch (err) {
next(err);
}
};
-};
+}
-module.exports = {
- checkPermission
-};
+module.exports = { hasRole, checkPermission };
diff --git a/middlewares/auth.js b/middlewares/auth.js
index 03b597ea..c78c32ed 100644
--- a/middlewares/auth.js
+++ b/middlewares/auth.js
@@ -1,11 +1,35 @@
-// Middleware to check if user is authenticated
+function normalizeRole(value) {
+ return String(value || '')
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '');
+}
+
function isAuthenticated(req, res, next) {
- if (req.session.userId) {
+ // Prevent caching of protected pages
+ res.set('Cache-Control', 'no-store, no-cache, must-revalidate, private');
+ if (req.session && req.session.userId) {
+ req.userRoles = req.session.roles || [];
return next();
}
- res.redirect("/login");
+ return res.redirect('/login');
}
+const isLogin = isAuthenticated;
+
+const isRole = (role) => {
+ return (req, res, next) => {
+ const userRoles = Array.isArray(req.userRoles) ? req.userRoles : [];
+ const targetRole = normalizeRole(role);
+ if (userRoles.some((item) => normalizeRole(item) === targetRole)) {
+ return next();
+ }
+ return res.status(403).render('errors/403', { title: '403 Forbidden' });
+ };
+};
+
module.exports = {
isAuthenticated,
+ isLogin,
+ isRole,
};
diff --git a/package-lock.json b/package-lock.json
index 59b6794c..5ed72229 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7,6 +7,7 @@
"": {
"name": "central-panel",
"version": "0.0.0",
+ "license": "ISC",
"dependencies": {
"bcryptjs": "^3.0.3",
"cookie-parser": "~1.4.4",
@@ -18,17 +19,48 @@
"express-session": "^1.19.0",
"http-errors": "~1.6.3",
"morgan": "~1.9.1",
- "mysql2": "^3.22.3"
+ "mysql2": "^3.22.3",
+ "pdfkit": "^0.15.2"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.61.0",
+ "@types/node": "^25.9.3",
+ "nodemon": "^3.1.14",
+ "playwright": "^1.60.0"
+ }
+ },
+ "node_modules/@playwright/test": {
+ "version": "1.61.0",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz",
+ "integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.61.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@swc/helpers": {
+ "version": "0.3.17",
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.3.17.tgz",
+ "integrity": "sha512-tb7Iu+oZ+zWJZ3HJqwx8oNwSDIU440hmVMDPhpACWQWnrZHK99Bxs70gT1L2dnr5Hg50ZRWEFkQCAnOVVV0z1Q==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.4.0"
}
},
"node_modules/@types/node": {
- "version": "25.6.0",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
- "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
+ "version": "25.9.3",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz",
+ "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==",
"license": "MIT",
- "peer": true,
"dependencies": {
- "undici-types": "~7.19.0"
+ "undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/accepts": {
@@ -44,12 +76,57 @@
"node": ">= 0.6"
}
},
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/array-buffer-byte-length": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz",
+ "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "is-array-buffer": "^3.0.5"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
+ "node_modules/available-typed-arrays": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
+ "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "possible-typed-array-names": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/aws-ssl-profiles": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
@@ -59,6 +136,36 @@
"node": ">= 6.0.0"
}
},
+ "node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/basic-auth": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz",
@@ -80,6 +187,19 @@
"bcrypt": "bin/bcrypt"
}
},
+ "node_modules/binary-extensions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/body-parser": {
"version": "1.18.3",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.18.3.tgz",
@@ -101,6 +221,50 @@
"node": ">= 0.8"
}
},
+ "node_modules/brace-expansion": {
+ "version": "5.0.6",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
+ "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/brotli": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
+ "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.1.2"
+ }
+ },
+ "node_modules/browserify-zlib": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
+ "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
+ "license": "MIT",
+ "dependencies": {
+ "pako": "~1.0.5"
+ }
+ },
"node_modules/bytes": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz",
@@ -110,6 +274,87 @@
"node": ">= 0.8"
}
},
+ "node_modules/call-bind": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
+ "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "get-intrinsic": "^1.3.0",
+ "set-function-length": "^1.2.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/chokidar": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/clone": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
+ "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/content-disposition": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz",
@@ -156,6 +401,12 @@
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
+ "node_modules/crypto-js": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
+ "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
+ "license": "MIT"
+ },
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -165,6 +416,72 @@
"ms": "2.0.0"
}
},
+ "node_modules/deep-equal": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz",
+ "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==",
+ "license": "MIT",
+ "dependencies": {
+ "array-buffer-byte-length": "^1.0.0",
+ "call-bind": "^1.0.5",
+ "es-get-iterator": "^1.1.3",
+ "get-intrinsic": "^1.2.2",
+ "is-arguments": "^1.1.1",
+ "is-array-buffer": "^3.0.2",
+ "is-date-object": "^1.0.5",
+ "is-regex": "^1.1.4",
+ "is-shared-array-buffer": "^1.0.2",
+ "isarray": "^2.0.5",
+ "object-is": "^1.1.5",
+ "object-keys": "^1.1.1",
+ "object.assign": "^4.1.4",
+ "regexp.prototype.flags": "^1.5.1",
+ "side-channel": "^1.0.4",
+ "which-boxed-primitive": "^1.0.2",
+ "which-collection": "^1.0.1",
+ "which-typed-array": "^1.1.13"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/define-data-property": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
+ "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/define-properties": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
+ "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.0.1",
+ "has-property-descriptors": "^1.0.0",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
@@ -189,6 +506,12 @@
"integrity": "sha512-3NdhDuEXnfun/z7x9GOElY49LoqVHoGScmOKwmxhsS8N5Y+Z8KyPPDnaSzqWgYt/ji4mqwfTS34Htrk0zPIXVg==",
"license": "MIT"
},
+ "node_modules/dfa": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
+ "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
+ "license": "MIT"
+ },
"node_modules/dotenv": {
"version": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
@@ -201,6 +524,20 @@
"url": "https://dotenvx.com"
}
},
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -225,6 +562,56 @@
"node": ">= 0.8"
}
},
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-get-iterator": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz",
+ "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "get-intrinsic": "^1.1.3",
+ "has-symbols": "^1.0.3",
+ "is-arguments": "^1.1.1",
+ "is-map": "^2.0.2",
+ "is-set": "^2.0.2",
+ "is-string": "^1.0.7",
+ "isarray": "^2.0.5",
+ "stop-iteration-iterator": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@@ -421,6 +808,19 @@
"node": ">= 0.6"
}
},
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/finalhandler": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.1.tgz",
@@ -439,6 +839,38 @@
"node": ">= 0.8"
}
},
+ "node_modules/fontkit": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-1.9.0.tgz",
+ "integrity": "sha512-HkW/8Lrk8jl18kzQHvAw9aTHe1cqsyx5sDnxncx652+CIfhawokEPkeM3BoIC+z/Xv7a0yMr0f3pRRwhGH455g==",
+ "license": "MIT",
+ "dependencies": {
+ "@swc/helpers": "^0.3.13",
+ "brotli": "^1.3.2",
+ "clone": "^2.1.2",
+ "deep-equal": "^2.0.5",
+ "dfa": "^1.2.0",
+ "restructure": "^2.0.1",
+ "tiny-inflate": "^1.0.3",
+ "unicode-properties": "^1.3.1",
+ "unicode-trie": "^2.0.0"
+ }
+ },
+ "node_modules/for-each": {
+ "version": "0.3.5",
+ "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
+ "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==",
+ "license": "MIT",
+ "dependencies": {
+ "is-callable": "^1.2.7"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -457,6 +889,39 @@
"node": ">= 0.6"
}
},
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/functions-have-names": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
+ "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/generate-function": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
@@ -466,54 +931,514 @@
"is-property": "^1.0.2"
}
},
- "node_modules/http-errors": {
- "version": "1.6.3",
- "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz",
- "integrity": "sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==",
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
- "depd": "~1.1.2",
- "inherits": "2.0.3",
- "setprototypeof": "1.1.0",
- "statuses": ">= 1.4.0 < 2"
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
},
"engines": {
- "node": ">= 0.6"
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/iconv-lite": {
- "version": "0.4.23",
- "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.23.tgz",
- "integrity": "sha512-neyTUVFtahjf0mB3dZT77u+8O0QB89jFdnBkd5P1JgYPbPaia3gXXOVL2fq8VyU2gMMD7SaN7QukTB/pmXYvDA==",
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
- "safer-buffer": ">= 2.1.2 < 3"
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
},
"engines": {
- "node": ">=0.10.0"
+ "node": ">= 0.4"
}
},
- "node_modules/inherits": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
- "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==",
- "license": "ISC"
+ "node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
},
- "node_modules/ipaddr.js": {
- "version": "1.9.1",
- "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
- "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
- "node": ">= 0.10"
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-bigints": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
+ "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+ "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/has-property-descriptors": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
+ "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "1.6.3",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz",
+ "integrity": "sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~1.1.2",
+ "inherits": "2.0.3",
+ "setprototypeof": "1.1.0",
+ "statuses": ">= 1.4.0 < 2"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.4.23",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.23.tgz",
+ "integrity": "sha512-neyTUVFtahjf0mB3dZT77u+8O0QB89jFdnBkd5P1JgYPbPaia3gXXOVL2fq8VyU2gMMD7SaN7QukTB/pmXYvDA==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/ignore-by-default": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
+ "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/inherits": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
+ "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==",
+ "license": "ISC"
+ },
+ "node_modules/internal-slot": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
+ "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "hasown": "^2.0.2",
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/is-arguments": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
+ "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-array-buffer": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
+ "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "get-intrinsic": "^1.2.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-bigint": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz",
+ "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==",
+ "license": "MIT",
+ "dependencies": {
+ "has-bigints": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-boolean-object": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz",
+ "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-callable": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
+ "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-date-object": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz",
+ "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-map": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz",
+ "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/is-number-object": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz",
+ "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-property": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
+ "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
+ "license": "MIT"
+ },
+ "node_modules/is-regex": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
+ "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "gopd": "^1.2.0",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-set": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz",
+ "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-shared-array-buffer": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz",
+ "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-string": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz",
+ "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-symbol": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz",
+ "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "has-symbols": "^1.1.0",
+ "safe-regex-test": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-weakmap": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
+ "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-weakset": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz",
+ "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "get-intrinsic": "^1.2.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/isarray": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
+ "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
+ "license": "MIT"
+ },
+ "node_modules/jpeg-exif": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz",
+ "integrity": "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ==",
+ "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
+ "license": "MIT"
+ },
+ "node_modules/linebreak": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
+ "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "0.0.8",
+ "unicode-trie": "^2.0.0"
+ }
+ },
+ "node_modules/linebreak/node_modules/base64-js": {
+ "version": "0.0.8",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
+ "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
}
},
- "node_modules/is-property": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
- "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
- "license": "MIT"
- },
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
@@ -544,6 +1469,15 @@
"url": "https://github.com/sponsors/wellwelwel"
}
},
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
@@ -595,7 +1529,23 @@
"mime-db": "1.52.0"
},
"engines": {
- "node": ">= 0.6"
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "10.2.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
+ "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.5"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/morgan": {
@@ -679,6 +1629,127 @@
"node": ">= 0.6"
}
},
+ "node_modules/nodemon": {
+ "version": "3.1.14",
+ "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
+ "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "chokidar": "^3.5.2",
+ "debug": "^4",
+ "ignore-by-default": "^1.0.1",
+ "minimatch": "^10.2.1",
+ "pstree.remy": "^1.1.8",
+ "semver": "^7.5.3",
+ "simple-update-notifier": "^2.0.0",
+ "supports-color": "^5.5.0",
+ "touch": "^3.1.0",
+ "undefsafe": "^2.0.5"
+ },
+ "bin": {
+ "nodemon": "bin/nodemon.js"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/nodemon"
+ }
+ },
+ "node_modules/nodemon/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/nodemon/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/object-is": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
+ "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/object-keys": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
+ "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/object.assign": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz",
+ "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.0.0",
+ "has-symbols": "^1.1.0",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/on-finished": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
@@ -700,6 +1771,12 @@
"node": ">= 0.8"
}
},
+ "node_modules/pako": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+ "license": "(MIT AND Zlib)"
+ },
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -715,6 +1792,96 @@
"integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==",
"license": "MIT"
},
+ "node_modules/pdfkit": {
+ "version": "0.15.2",
+ "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.15.2.tgz",
+ "integrity": "sha512-s3GjpdBFSCaeDSX/v73MI5UsPqH1kjKut2AXCgxQ5OH10lPVOu5q5vLAG0OCpz/EYqKsTSw1WHpENqMvp43RKg==",
+ "license": "MIT",
+ "dependencies": {
+ "crypto-js": "^4.2.0",
+ "fontkit": "^1.8.1",
+ "jpeg-exif": "^1.1.4",
+ "linebreak": "^1.0.2",
+ "png-js": "^1.0.0"
+ }
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.61.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
+ "integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.61.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.61.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
+ "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/playwright/node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/png-js": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz",
+ "integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==",
+ "dependencies": {
+ "browserify-zlib": "^0.2.0"
+ }
+ },
+ "node_modules/possible-typed-array-names": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
+ "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -728,6 +1895,13 @@
"node": ">= 0.10"
}
},
+ "node_modules/pstree.remy": {
+ "version": "1.1.8",
+ "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
+ "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/qs": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz",
@@ -770,18 +1944,87 @@
"node": ">= 0.8"
}
},
+ "node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/regexp.prototype.flags": {
+ "version": "1.5.4",
+ "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
+ "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-errors": "^1.3.0",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "set-function-name": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/restructure": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/restructure/-/restructure-2.0.1.tgz",
+ "integrity": "sha512-e0dOpjm5DseomnXx2M5lpdZ5zoHqF1+bqdMJUohoYVVQa7cBdnk7fdmeI6byNWP/kiME72EeTiSypTCVnpLiDg==",
+ "license": "MIT"
+ },
"node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
+ "node_modules/safe-regex-test": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
+ "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "is-regex": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
+ "node_modules/semver": {
+ "version": "7.8.2",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz",
+ "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/send": {
"version": "0.16.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.16.2.tgz",
@@ -826,12 +2069,129 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/set-function-length": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
+ "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2",
+ "get-intrinsic": "^1.2.4",
+ "gopd": "^1.0.1",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/set-function-name": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz",
+ "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==",
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-errors": "^1.3.0",
+ "functions-have-names": "^1.2.3",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/setprototypeof": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz",
"integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==",
"license": "ISC"
},
+ "node_modules/side-channel": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/simple-update-notifier": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
+ "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/sql-escaper": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz",
@@ -865,6 +2225,67 @@
"node": ">= 0.6"
}
},
+ "node_modules/stop-iteration-iterator": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
+ "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "internal-slot": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+ "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/tiny-inflate": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
+ "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
+ "license": "MIT"
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/touch": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz",
+ "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "nodetouch": "bin/nodetouch.js"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@@ -890,12 +2311,44 @@
"node": ">= 0.8"
}
},
+ "node_modules/undefsafe": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
+ "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/undici-types": {
- "version": "7.19.2",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
- "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
+ "version": "7.24.6",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
+ "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
+ "license": "MIT"
+ },
+ "node_modules/unicode-properties": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
+ "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.0",
+ "unicode-trie": "^2.0.0"
+ }
+ },
+ "node_modules/unicode-trie": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
+ "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
"license": "MIT",
- "peer": true
+ "dependencies": {
+ "pako": "^0.2.5",
+ "tiny-inflate": "^1.0.0"
+ }
+ },
+ "node_modules/unicode-trie/node_modules/pako": {
+ "version": "0.2.9",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
+ "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
+ "license": "MIT"
},
"node_modules/unpipe": {
"version": "1.0.0",
@@ -923,6 +2376,64 @@
"engines": {
"node": ">= 0.8"
}
+ },
+ "node_modules/which-boxed-primitive": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz",
+ "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==",
+ "license": "MIT",
+ "dependencies": {
+ "is-bigint": "^1.1.0",
+ "is-boolean-object": "^1.2.1",
+ "is-number-object": "^1.1.1",
+ "is-string": "^1.1.1",
+ "is-symbol": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/which-collection": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz",
+ "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==",
+ "license": "MIT",
+ "dependencies": {
+ "is-map": "^2.0.3",
+ "is-set": "^2.0.3",
+ "is-weakmap": "^2.0.2",
+ "is-weakset": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/which-typed-array": {
+ "version": "1.1.22",
+ "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz",
+ "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==",
+ "license": "MIT",
+ "dependencies": {
+ "available-typed-arrays": "^1.0.7",
+ "call-bind": "^1.0.9",
+ "call-bound": "^1.0.4",
+ "for-each": "^0.3.5",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
}
}
}
diff --git a/package.json b/package.json
index bf3659a5..03154917 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,26 @@
"express-session": "^1.19.0",
"http-errors": "~1.6.3",
"morgan": "~1.9.1",
- "mysql2": "^3.22.3"
- }
+ "mysql2": "^3.22.3",
+ "pdfkit": "^0.15.2"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.61.0",
+ "@types/node": "^25.9.3",
+ "nodemon": "^3.1.14",
+ "playwright": "^1.60.0"
+ },
+ "description": "Sistem Informasi Pengadaan Barang - FacultyWare",
+ "main": "app.js",
+ "directories": {
+ "lib": "lib"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/END0310/facultyware.git"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
+ "type": "commonjs"
}
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 00000000..7a50318c
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,29 @@
+import { defineConfig, devices } from '@playwright/test';
+
+export default defineConfig({
+ testDir: './tests',
+ fullyParallel: false,
+ forbidOnly: !!process.env.CI,
+ retries: 0,
+ workers: 1,
+ reporter: 'html',
+ use: {
+ baseURL: 'http://localhost:3000',
+ trace: 'on-first-retry',
+ screenshot: 'only-on-failure',
+ },
+
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ },
+ ],
+
+ webServer: {
+ command: 'npm run dev',
+ url: 'http://localhost:3000',
+ reuseExistingServer: true,
+ timeout: 15000,
+ },
+});
diff --git a/public/assets/styles.css b/public/assets/styles.css
index b5c8f7fb..853e8277 100644
--- a/public/assets/styles.css
+++ b/public/assets/styles.css
@@ -2426,6 +2426,17 @@
color: var(--color-destructive);
}
}
+ .alert-success {
+ background-color: var(--color-success-subtle, #f0fdf4);
+ border-color: #86efac;
+ color: #166534;
+ &>svg {
+ color: currentcolor;
+ }
+ > section {
+ color: #166534;
+ }
+ }
}
@layer components {
.badge, .badge-primary, .badge-secondary, .badge-destructive, .badge-outline {
@@ -6306,3 +6317,191 @@ code.hljs {
}
}
}
+
+/* =============================================================================
+ WAKIL DEKAN — Detail Permohonan (Custom Classes)
+ ============================================================================= */
+
+/* Card dengan padding yang lebih lebar */
+.detail-card {
+ padding: 1.75rem 2rem;
+}
+
+/* Tombol approve (hijau) */
+.btn-approve {
+ background-color: #16a34a;
+ border-color: #16a34a;
+ color: white;
+}
+
+/* Header dan footer section tabel */
+.table-section-header {
+ padding: 1.25rem 2rem;
+}
+
+.table-section-footer {
+ padding: 1rem 2rem;
+}
+
+/* Kolom pertama tabel (padding kiri lebih lebar) */
+.table-cell-first {
+ padding-left: 2rem;
+ width: 3.5rem;
+}
+
+/* Kolom terakhir tabel (padding kanan lebih lebar) */
+.table-cell-last {
+ padding-right: 2rem;
+}
+
+/* Kolom jumlah */
+.table-col-qty {
+ width: 5rem;
+}
+
+/* Cell kosong "Tidak ada item" */
+.table-cell-empty {
+ padding-left: 2rem;
+ padding-right: 2rem;
+}
+
+/* ── Modal Konfirmasi ── */
+
+/* Overlay (latar belakang gelap) */
+.confirm-overlay {
+ display: none;
+ position: fixed;
+ inset: 0;
+ z-index: 9999;
+ align-items: center;
+ justify-content: center;
+ background: rgba(0, 0, 0, 0.6);
+ backdrop-filter: blur(4px);
+}
+
+/* Kotak modal */
+.confirm-modal {
+ background: var(--background, #1c1c1c);
+ border: 1px solid var(--border, #333);
+ border-radius: 1rem;
+ width: 100%;
+ max-width: 420px;
+ margin: 1rem;
+ box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
+ overflow: hidden;
+}
+
+/* Konten modal (judul + deskripsi) */
+.confirm-modal-body {
+ padding: 2rem 2rem 1.5rem;
+ text-align: center;
+}
+
+/* Icon di atas modal */
+.confirm-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 56px;
+ height: 56px;
+ border-radius: 50%;
+ margin-bottom: 1rem;
+}
+
+.confirm-icon-approve {
+ background: rgba(22, 163, 74, 0.15);
+ color: #22c55e;
+}
+
+.confirm-icon-reject {
+ background: rgba(239, 68, 68, 0.15);
+ color: #ef4444;
+}
+
+/* Judul modal */
+.confirm-title {
+ font-size: 1.125rem;
+ font-weight: 600;
+ margin: 0 0 0.5rem;
+ color: var(--foreground, #fff);
+}
+
+/* Deskripsi modal */
+.confirm-description {
+ font-size: 0.875rem;
+ color: var(--muted-foreground, #999);
+ margin: 0;
+ line-height: 1.6;
+ max-width: 320px;
+ margin-left: auto;
+ margin-right: auto;
+}
+
+/* Footer modal (tombol-tombol) */
+.confirm-modal-footer {
+ display: flex;
+ justify-content: center;
+ gap: 0.75rem;
+ padding: 1.25rem 2rem;
+ border-top: 1px solid var(--border, #333);
+ background: var(--muted, rgba(255, 255, 255, 0.03));
+}
+
+/* Tombol dalam modal */
+.confirm-btn {
+ padding: 0.5rem 1.5rem;
+ min-width: 100px;
+}
+
+.confirm-btn-action {
+ padding: 0.5rem 1.5rem;
+ min-width: 120px;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.confirm-btn-approve {
+ background: #16a34a;
+ border-color: #16a34a;
+ color: #fff;
+}
+
+/* ── Animasi Modal ── */
+
+.confirm-overlay.show {
+ display: flex !important;
+ animation: overlay-in 0.2s ease-out forwards;
+}
+
+.confirm-overlay.show .confirm-modal {
+ animation: modal-in 0.25s ease-out forwards;
+}
+
+.confirm-overlay.hiding {
+ animation: overlay-out 0.15s ease-in forwards;
+}
+
+.confirm-overlay.hiding .confirm-modal {
+ animation: modal-out 0.15s ease-in forwards;
+}
+
+@keyframes overlay-in {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+@keyframes overlay-out {
+ from { opacity: 1; }
+ to { opacity: 0; }
+}
+
+@keyframes modal-in {
+ from { opacity: 0; transform: scale(0.9) translateY(10px); }
+ to { opacity: 1; transform: scale(1) translateY(0); }
+}
+
+@keyframes modal-out {
+ from { opacity: 1; transform: scale(1) translateY(0); }
+ to { opacity: 0; transform: scale(0.9) translateY(10px); }
+}
diff --git a/routes/auth/index.js b/routes/auth/index.js
new file mode 100644
index 00000000..e7c28420
--- /dev/null
+++ b/routes/auth/index.js
@@ -0,0 +1,10 @@
+const express = require('express');
+const router = express.Router();
+const controller = require('../../controllers/auth/authController');
+
+router.get('/', controller.index);
+router.get('/login', controller.loginPage);
+router.post('/login', controller.login);
+router.get('/logout', controller.logout);
+
+module.exports = router;
diff --git a/routes/index.js b/routes/index.js
deleted file mode 100644
index 2cab4838..00000000
--- a/routes/index.js
+++ /dev/null
@@ -1,17 +0,0 @@
-var express = require("express");
-var router = express.Router();
-const indexController = require("../controllers/indexController");
-const { isAuthenticated } = require("../middlewares/auth");
-
-/* GET home page. */
-router.get("/", indexController.index);
-
-router.get("/home", isAuthenticated, indexController.home);
-
-router.get("/login", indexController.loginPage);
-
-router.post("/login", indexController.login);
-
-router.get("/logout", indexController.logout);
-
-module.exports = router;
diff --git a/routes/pengelola-aset/apiProcurements.js b/routes/pengelola-aset/apiProcurements.js
new file mode 100644
index 00000000..48444776
--- /dev/null
+++ b/routes/pengelola-aset/apiProcurements.js
@@ -0,0 +1,12 @@
+const express = require('express');
+const router = express.Router();
+const controller = require('../../controllers/pengelola-aset/apiProcurementController');
+const { isAuthenticated } = require('../../middlewares/auth');
+const { hasRole } = require('../../middlewares/acl');
+
+router.use(isAuthenticated);
+router.use(hasRole(['Pengelola Aset', 'Pengelola Sistem', 'Wakil Dekan']));
+
+router.get('/procurement-items', controller.listProcurementItems);
+
+module.exports = router;
diff --git a/routes/pengelola-aset/dashboard.js b/routes/pengelola-aset/dashboard.js
new file mode 100644
index 00000000..a36536b4
--- /dev/null
+++ b/routes/pengelola-aset/dashboard.js
@@ -0,0 +1,9 @@
+const express = require('express');
+const router = express.Router();
+const controller = require('../../controllers/pengelola-aset/dashboardController');
+const { isAuthenticated } = require('../../middlewares/auth');
+
+router.get('/home', isAuthenticated, controller.home);
+router.get('/procurements/dashboard', isAuthenticated, controller.home);
+
+module.exports = router;
diff --git a/routes/pengelola-aset/procurements.js b/routes/pengelola-aset/procurements.js
new file mode 100644
index 00000000..9b98287e
--- /dev/null
+++ b/routes/pengelola-aset/procurements.js
@@ -0,0 +1,30 @@
+const express = require('express');
+const router = express.Router();
+const controller = require('../../controllers/pengelola-aset/procurementController');
+const { isAuthenticated } = require('../../middlewares/auth');
+const { hasRole } = require('../../middlewares/acl');
+
+const asetRoles = ['Pengelola Aset', 'Pengelola Sistem'];
+const decisionRoles = ['Wakil Dekan'];
+
+router.use(isAuthenticated);
+
+router.get('/report/export', hasRole(asetRoles), controller.exportReportCsv);
+router.get('/report/print', hasRole(asetRoles), controller.printReport);
+router.get('/report', hasRole(asetRoles), controller.report);
+
+router.get('/requests', hasRole(asetRoles), controller.listRequests);
+router.get('/requests/:id', hasRole(asetRoles), controller.detailRequest);
+router.post('/requests/:id/status', hasRole(asetRoles), controller.updateRequestStatus);
+
+router.get('/create', hasRole(asetRoles), controller.showCreateProcurement);
+router.get('/items', hasRole(asetRoles), controller.procurementItemsPage);
+router.post('/', hasRole(asetRoles), controller.createProcurement);
+router.get('/', hasRole(asetRoles), controller.listProcurements);
+router.get('/:id', hasRole(['Pengelola Aset', 'Pengelola Sistem', 'Wakil Dekan']), controller.detailProcurement);
+router.post('/:id/submit', hasRole(asetRoles), controller.submitProcurement);
+router.post('/:id/decision', hasRole(decisionRoles), controller.decideProcurement);
+router.get('/:id/add-asset', hasRole(asetRoles), controller.showAddAsset);
+router.post('/:id/add-asset', hasRole(asetRoles), controller.addAssetFromProcurement);
+
+module.exports = router;
diff --git a/routes/users.js b/routes/users.js
deleted file mode 100644
index a58d68ce..00000000
--- a/routes/users.js
+++ /dev/null
@@ -1,8 +0,0 @@
-var express = require('express');
-var router = express.Router();
-const usersController = require('../controllers/usersController');
-
-/* GET users listing. */
-router.get('/', usersController.list);
-
-module.exports = router;
diff --git a/routes/usulan.js b/routes/usulan.js
new file mode 100644
index 00000000..2ba2c968
--- /dev/null
+++ b/routes/usulan.js
@@ -0,0 +1,23 @@
+const express = require("express");
+const router = express.Router();
+
+const usulanController = require("../controllers/usulanController");
+const { isAuthenticated } = require("../middlewares/auth");
+
+router.use(isAuthenticated);
+
+router.get("/", usulanController.index);
+
+router.get("/create", usulanController.createPage);
+router.post("/", usulanController.store);
+
+router.get("/laporan/pdf", usulanController.downloadLaporan);
+
+router.get("/api/riwayat", usulanController.apiRiwayat);
+
+router.get("/:id/edit", usulanController.editPage);
+router.post("/:id/update", usulanController.update);
+
+router.post("/:id/delete", usulanController.destroy);
+
+module.exports = router;
diff --git a/routes/wakildekan.js b/routes/wakildekan.js
new file mode 100644
index 00000000..b09c7eb9
--- /dev/null
+++ b/routes/wakildekan.js
@@ -0,0 +1,15 @@
+const express = require('express');
+const router = express.Router();
+const { isLogin, isRole } = require('../middlewares/auth');
+const wakildekanController = require('../controllers/wakildekanController');
+
+router.get('/dashboard', isLogin, isRole('wakildekan'), wakildekanController.dashboard);
+router.get('/permohonan', isLogin, isRole('wakildekan'), wakildekanController.listPermohonan);
+router.get('/permohonan/:id', isLogin, isRole('wakildekan'), wakildekanController.detailPermohonan);
+router.post('/permohonan/:id/approve', isLogin, isRole('wakildekan'), wakildekanController.approvePermohonan);
+router.post('/permohonan/:id/reject', isLogin, isRole('wakildekan'), wakildekanController.rejectPermohonan);
+router.get('/riwayat', isLogin, isRole('wakildekan'), wakildekanController.riwayatPermohonan);
+router.get('/riwayat/download', isLogin, isRole('wakildekan'), wakildekanController.downloadPDF);
+router.get('/api/permohonan', isLogin, isRole('wakildekan'), wakildekanController.getPermohonanAPI);
+
+module.exports = router;
\ No newline at end of file
diff --git a/scripts/check_db.js b/scripts/check_db.js
new file mode 100644
index 00000000..78dc975e
--- /dev/null
+++ b/scripts/check_db.js
@@ -0,0 +1,22 @@
+const db = require('../lib/db');
+
+async function run() {
+ const tables = [
+ 'users', 'employees', 'employment_statuses', 'organization_units',
+ 'roles', 'permissions', 'model_has_roles', 'role_has_permissions',
+ 'equipment_procurements', 'equipment_proc_items'
+ ];
+
+ for (const table of tables) {
+ try {
+ const [desc] = await db.query(`DESCRIBE \`${table}\``);
+ console.log(`Table ${table} description:`);
+ console.log(desc.map(d => `${d.Field} (${d.Type}) - Null: ${d.Null} - Key: ${d.Key}`).join('\n'));
+ console.log('------------------------------');
+ } catch (err) {
+ console.error(`Error describing ${table}:`, err.message);
+ }
+ }
+ process.exit(0);
+}
+run();
diff --git a/scripts/seed_data.js b/scripts/seed_data.js
new file mode 100644
index 00000000..6d62cddd
--- /dev/null
+++ b/scripts/seed_data.js
@@ -0,0 +1,147 @@
+const db = require('../lib/db');
+const bcrypt = require('bcryptjs');
+
+async function run() {
+ const connection = await db.getConnection();
+ try {
+ await connection.beginTransaction();
+
+ console.log("Seeding database...");
+
+ // 1. Clear existing data in correct order
+ await connection.query("DELETE FROM model_has_roles");
+ await connection.query("DELETE FROM role_has_permissions");
+ await connection.query("DELETE FROM roles");
+ await connection.query("DELETE FROM permissions");
+ await connection.query("DELETE FROM equipment_proc_items");
+ await connection.query("DELETE FROM equipment_procurements");
+ await connection.query("DELETE FROM employees");
+ await connection.query("DELETE FROM users");
+ await connection.query("DELETE FROM employment_statuses");
+ await connection.query("DELETE FROM organization_units");
+
+ // 2. Insert organization unit
+ await connection.query(`
+ INSERT INTO organization_units
+ (id, name, code, parent_id, type, description, organization_unit_id, created_at, updated_at)
+ VALUES
+ (1, 'Departemen Teknologi Informasi', 'DTI', NULL, 'department', 'Departemen TI', 1, NOW(), NOW())
+ `);
+
+ // 3. Insert employment status
+ await connection.query(`
+ INSERT INTO employment_statuses
+ (id, name, description, created_at, updated_at)
+ VALUES
+ (1, 'PNS Dosen', 'Pegawai Negeri Sipil Dosen', NOW(), NOW())
+ `);
+
+ // 4. Create Roles
+ await connection.query(`
+ INSERT INTO roles (id, name, guard_name, created_at, updated_at)
+ VALUES
+ (1, 'ketua_departemen', 'web', NOW(), NOW()),
+ (2, 'wakildekan', 'web', NOW(), NOW())
+ `);
+
+ // 5. Create Permissions
+ await connection.query(`
+ INSERT INTO permissions (id, name, guard_name, created_at, updated_at)
+ VALUES
+ (1, 'create_procurements', 'web', NOW(), NOW()),
+ (2, 'approve_procurements', 'web', NOW(), NOW())
+ `);
+
+ // 6. Map Roles to Permissions
+ await connection.query(`
+ INSERT INTO role_has_permissions (permission_id, role_id)
+ VALUES
+ (1, 1), -- ketua_departemen has create_procurements
+ (2, 2) -- wakildekan has approve_procurements
+ `);
+
+ const hashedPassword = await bcrypt.hash('password', 10);
+
+ // 7. Insert Diva (Ketua Departemen)
+ await connection.query(`
+ INSERT INTO users (id, name, email, password, created_at, updated_at)
+ VALUES (1, 'Diva', 'diva@facultyware.id', ?, NOW(), NOW())
+ `, [hashedPassword]);
+
+ await connection.query(`
+ INSERT INTO employees
+ (id, employee_number, name, birth_place, birth_date, gender, religion, marital_status, address, phone_number, organization_unit_id, hire_date, employment_status_id, status, created_at, updated_at)
+ VALUES
+ (1, '19901010101010', 'Diva', 'Padang', '1990-10-10', 'female', 'Islam', 'married', 'Jl. Kampus Unand', '08123456789', 1, '2015-03-01', 1, 'active', NOW(), NOW())
+ `);
+
+ await connection.query(`
+ INSERT INTO model_has_roles (role_id, model_type, model_id)
+ VALUES (1, 'User', 1)
+ `);
+
+ // 8. Insert Aldo (Wakil Dekan)
+ await connection.query(`
+ INSERT INTO users (id, name, email, password, created_at, updated_at)
+ VALUES (2, 'Aldo Septia Elvawan', 'aldo@facultyware.id', ?, NOW(), NOW())
+ `, [hashedPassword]);
+
+ await connection.query(`
+ INSERT INTO employees
+ (id, employee_number, name, birth_place, birth_date, gender, religion, marital_status, address, phone_number, organization_unit_id, hire_date, employment_status_id, status, created_at, updated_at)
+ VALUES
+ (2, '19850505101020', 'Aldo Septia Elvawan', 'Padang', '1985-05-05', 'male', 'Islam', 'married', 'Jl. Dekanat Lantai 2', '08987654321', 1, '2010-08-01', 1, 'active', NOW(), NOW())
+ `);
+
+ await connection.query(`
+ INSERT INTO model_has_roles (role_id, model_type, model_id)
+ VALUES (2, 'User', 2)
+ `);
+
+ // 9. Create some sample procurements (from Diva)
+ // Proc 1: Diajukan (submitted)
+ await connection.query(`
+ INSERT INTO equipment_procurements (id, request_number, title, status, created_by, employee_id, created_at, updated_at)
+ VALUES (1, 'REQ-2026-00001', 'Pengadaan Laptop Laboratorium Pemrograman', 'submitted', 1, 1, NOW(), NOW())
+ `);
+ await connection.query(`
+ INSERT INTO equipment_proc_items (equipment_proc_id, name, specification, quantity, estimated_price, asset_equipment_procurement_id, created_at, updated_at)
+ VALUES
+ (1, 'Laptop ASUS ROG', 'Intel i7, 16GB RAM, 512GB SSD', 5, 15000000.00, 1, NOW(), NOW()),
+ (1, 'Mouse Logitech Wireless', 'B170 silent', 5, 150000.00, 1, NOW(), NOW())
+ `);
+
+ // Proc 2: Draft
+ await connection.query(`
+ INSERT INTO equipment_procurements (id, request_number, title, status, created_by, employee_id, created_at, updated_at)
+ VALUES (2, 'REQ-2026-00002', 'Pengadaan PC Server Jurusan', 'draft', 1, 1, NOW(), NOW())
+ `);
+ await connection.query(`
+ INSERT INTO equipment_proc_items (equipment_proc_id, name, specification, quantity, estimated_price, asset_equipment_procurement_id, created_at, updated_at)
+ VALUES
+ (2, 'Dell PowerEdge R750', 'Intel Xeon 32 Core, 64GB RAM, 2TB SSD', 1, 65000000.00, 2, NOW(), NOW())
+ `);
+
+ // Proc 3: Disetujui (approved)
+ await connection.query(`
+ INSERT INTO equipment_procurements (id, request_number, title, status, created_by, employee_id, created_at, updated_at)
+ VALUES (3, 'REQ-2026-00003', 'Pengadaan Proyektor Ruang Kuliah', 'approved', 1, 1, NOW(), NOW())
+ `);
+ await connection.query(`
+ INSERT INTO equipment_proc_items (equipment_proc_id, name, specification, quantity, estimated_price, asset_equipment_procurement_id, created_at, updated_at)
+ VALUES
+ (3, 'Proyektor Epson EB-X500', '3600 Lumens, XGA', 2, 6000000.00, 3, NOW(), NOW())
+ `);
+
+ await connection.commit();
+ console.log("Database seeded successfully!");
+ process.exit(0);
+ } catch (err) {
+ await connection.rollback();
+ console.error("Error seeding database:", err);
+ process.exit(1);
+ } finally {
+ connection.release();
+ }
+}
+run();
diff --git a/test_hash.js b/test_hash.js
new file mode 100644
index 00000000..d6ccfa2b
--- /dev/null
+++ b/test_hash.js
@@ -0,0 +1,17 @@
+const bcrypt = require('bcrypt');
+
+const hash = '$2b$10$/kg7vzGQ347zxSOyVFI75OoDZErMQ8oiSeqgHfs8SgxnOswADPGi6';
+const tests = ['password', '123456', '12345678', 'password123', 'admin', 'rahasia', 'aldo', 'aldo123'];
+
+async function testPasswords() {
+ for (const p of tests) {
+ const match = await bcrypt.compare(p, hash);
+ if (match) {
+ console.log("Matched:", p);
+ return;
+ }
+ }
+ console.log("No match found");
+}
+
+testPasswords();
diff --git a/tests/feature.spec.ts b/tests/feature.spec.ts
new file mode 100644
index 00000000..04f33dcf
--- /dev/null
+++ b/tests/feature.spec.ts
@@ -0,0 +1,476 @@
+import { test, expect, Page } from '@playwright/test';
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Kredensial login per role
+// ─────────────────────────────────────────────────────────────────────────────
+const CREDENTIALS = {
+ ketuaDepartemen: { email: 'ketua.departemen@facultyware.test', password: 'password123' },
+ pengelolaAset: { email: 'pengelola.aset@facultyware.test', password: 'password123' },
+ wakilDekan: { email: 'wakil.dekan@facultyware.test', password: 'password123' },
+};
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Helper: login sebagai role tertentu
+// ─────────────────────────────────────────────────────────────────────────────
+async function login(page: Page, role: keyof typeof CREDENTIALS) {
+ const creds = CREDENTIALS[role];
+ await page.goto('/login');
+ await page.fill('#email', creds.email);
+ await page.fill('#password', creds.password);
+ await page.click('button[type="submit"]');
+ await page.waitForURL(url => !url.toString().includes('/login'), { timeout: 30000 });
+}
+
+async function logout(page: Page) {
+ await page.goto('/logout');
+ await page.waitForURL('**/login**');
+}
+
+// =============================================================================
+// 1. AUTH — Login & Logout
+// =============================================================================
+test.describe('Auth — Login & Logout', () => {
+ test('menampilkan halaman login', async ({ page }) => {
+ await page.goto('/login');
+ await expect(page.locator('h1')).toContainText('Welcome');
+ await expect(page.locator('#email')).toBeVisible();
+ await expect(page.locator('#password')).toBeVisible();
+ });
+
+ test('menolak login dengan password salah', async ({ page }) => {
+ await page.goto('/login');
+ await page.fill('#email', 'ketua.departemen@facultyware.test');
+ await page.fill('#password', 'wrong-password');
+ await page.click('button[type="submit"]');
+ // Harus tetap di halaman login dan ada pesan error
+ await expect(page).toHaveURL(/\/login/, { timeout: 5000 });
+ const errorDiv = page.locator('.bg-destructive, .text-destructive, [class*="destructive"]');
+ await expect(errorDiv.first()).toBeVisible({ timeout: 5000 });
+ });
+
+ test('berhasil logout', async ({ page }) => {
+ await login(page, 'ketuaDepartemen');
+ await logout(page);
+ await expect(page).toHaveURL(/\/login/, { timeout: 10000 });
+ });
+});
+
+// =============================================================================
+// FITUR 1 — Ketua Departemen: Input Usulan Pengadaan
+// =============================================================================
+test.describe('Fitur 1 — Ketua Departemen: Input Usulan', () => {
+ test.beforeEach(async ({ page }) => {
+ await login(page, 'ketuaDepartemen');
+ });
+
+ test('menampilkan form tambah usulan', async ({ page }) => {
+ await page.waitForTimeout(500);
+ await page.goto('/usulan/create');
+ await expect(page.locator('input[name="name"], input[name="title"]').first()).toBeVisible();
+ });
+
+ test('berhasil membuat usulan baru', async ({ page }) => {
+ await page.goto('/usulan/create');
+ // Isi form — field names bervariasi, isi yang ada
+ const nameInput = page.locator('input[name="name"]');
+ if (await nameInput.count() > 0) {
+ await nameInput.fill('Laptop Testing Playwright');
+ }
+ const specInput = page.locator('input[name="specification"], textarea[name="specification"]').first();
+ if (await specInput.count() > 0) {
+ await specInput.fill('Intel i7, 16GB RAM');
+ }
+ const qtyInput = page.locator('input[name="quantity"]');
+ if (await qtyInput.count() > 0) {
+ await qtyInput.fill('2');
+ }
+ await page.click('button[type="submit"]');
+ // Setelah submit, harus redirect ke daftar atau sukses
+ await page.waitForTimeout(1000);
+ const url = page.url();
+ expect(url.includes('/usulan') || url.includes('/home')).toBeTruthy();
+ });
+});
+
+// =============================================================================
+// FITUR 2 — Ketua Departemen: Edit Usulan
+// =============================================================================
+test.describe('Fitur 2 — Ketua Departemen: Edit Usulan', () => {
+ test('menampilkan halaman edit usulan', async ({ page }) => {
+ await login(page, 'ketuaDepartemen');
+ await page.goto('/usulan');
+ // Cari link edit di tabel
+ const editLink = page.locator('a[href*="/edit"]').first();
+ if (await editLink.count() > 0) {
+ await editLink.click();
+ await expect(page.url()).toContain('/edit');
+ }
+ });
+});
+
+// =============================================================================
+// FITUR 3 — Ketua Departemen: Lihat Status Usulan (Daftar + Search + Pagination)
+// =============================================================================
+test.describe('Fitur 3 — Ketua Departemen: Daftar & Status Usulan', () => {
+ test.beforeEach(async ({ page }) => {
+ await login(page, 'ketuaDepartemen');
+ });
+
+ test('menampilkan daftar usulan', async ({ page }) => {
+ await page.goto('/usulan');
+ // Card container selalu ada, berisi table (jika ada data) atau pesan kosong
+ await expect(page.locator('.card').first()).toBeVisible();
+ });
+
+ test('fitur pencarian berfungsi', async ({ page }) => {
+ await page.goto('/usulan');
+ const searchInput = page.locator('input[name="search"]');
+ if (await searchInput.count() > 0) {
+ await searchInput.fill('REQ');
+ await page.click('button[type="submit"]');
+ await expect(page).toHaveURL(/search=REQ/);
+ }
+ });
+
+ test('fitur pagination tersedia', async ({ page }) => {
+ await page.goto('/usulan');
+ // Cek elemen pagination (Prev/Next atau info halaman)
+ const paginationText = page.locator('text=Menampilkan');
+ await expect(paginationText.first()).toBeVisible();
+ });
+});
+
+// =============================================================================
+// FITUR 4 — Ketua Departemen: Hapus Usulan
+// =============================================================================
+test.describe('Fitur 4 — Ketua Departemen: Hapus Usulan', () => {
+ test('tombol hapus tersedia di daftar usulan', async ({ page }) => {
+ await login(page, 'ketuaDepartemen');
+ await page.goto('/usulan');
+ // Tombol hapus hanya muncul pada usulan berstatus draft
+ // Cari form delete atau button dengan tooltip "Hapus" atau icon trash
+ const deleteBtn = page.locator('form[action*="delete"] button, button:has-text("Hapus"), a:has-text("Hapus")');
+ const hasData = await page.locator('table tbody tr').count();
+ if (hasData > 0 && await deleteBtn.count() > 0) {
+ await expect(deleteBtn.first()).toBeVisible();
+ }
+ });
+});
+
+// =============================================================================
+// FITUR 5 — Ketua Departemen: Generate Laporan PDF
+// =============================================================================
+test.describe('Fitur 5 — Ketua Departemen: Laporan PDF', () => {
+ test('endpoint laporan PDF bisa di-download', async ({ page }) => {
+ await login(page, 'ketuaDepartemen');
+ // PDF endpoint memicu download, bukan navigasi biasa
+ const [download] = await Promise.all([
+ page.waitForEvent('download'),
+ page.locator('a[href*="laporan/pdf"], a[href*="report"]').first().click().catch(() => page.goto('/usulan/laporan/pdf')),
+ ]);
+ expect(download).toBeTruthy();
+ });
+});
+
+// =============================================================================
+// FITUR 6 — Ketua Departemen: API Riwayat (JSON)
+// =============================================================================
+test.describe('Fitur 6 — Ketua Departemen: API JSON Riwayat', () => {
+ test('endpoint API mengembalikan JSON', async ({ page }) => {
+ await login(page, 'ketuaDepartemen');
+ const response = await page.goto('/usulan/api/riwayat');
+ expect(response).not.toBeNull();
+ expect(response!.status()).toBe(200);
+ const contentType = response!.headers()['content-type'] || '';
+ expect(contentType).toContain('json');
+ });
+});
+
+// =============================================================================
+// FITUR 7 — Pengelola Aset: Menerima Usulan Pengadaan
+// =============================================================================
+test.describe('Fitur 7 — Pengelola Aset: Daftar Usulan Masuk', () => {
+ test('menampilkan daftar usulan pengadaan (requests)', async ({ page }) => {
+ await login(page, 'pengelolaAset');
+ await page.goto('/procurements/requests');
+ await expect(page.locator('table')).toBeVisible();
+ });
+
+ test('fitur pencarian di daftar usulan berfungsi', async ({ page }) => {
+ await login(page, 'pengelolaAset');
+ await page.goto('/procurements/requests');
+ const searchInput = page.locator('input[name="search"]');
+ if (await searchInput.count() > 0) {
+ await searchInput.fill('REQ');
+ await page.click('button[type="submit"]');
+ await expect(page).toHaveURL(/search=REQ/);
+ }
+ });
+});
+
+// =============================================================================
+// FITUR 8 — Pengelola Aset: Input Permohonan Pengadaan
+// =============================================================================
+test.describe('Fitur 8 — Pengelola Aset: Buat Permohonan', () => {
+ test('menampilkan form buat permohonan', async ({ page }) => {
+ await login(page, 'pengelolaAset');
+ await page.goto('/procurements/create');
+ await expect(page.locator('form')).toBeVisible();
+ });
+});
+
+// =============================================================================
+// FITUR 9 — Pengelola Aset: Tambah Barang dari Pengadaan ke Sistem
+// =============================================================================
+test.describe('Fitur 9 — Pengelola Aset: Tambah Barang ke Sistem', () => {
+ test('halaman add-asset dapat diakses dari detail procurement', async ({ page }) => {
+ await login(page, 'pengelolaAset');
+ await page.goto('/procurements');
+ // Klik detail pertama jika ada
+ const detailLink = page.locator('a[href*="/procurements/"]:has-text("Detail")').first();
+ if (await detailLink.count() > 0) {
+ await detailLink.click();
+ await page.waitForTimeout(500);
+ // Cek apakah ada link/button add-asset
+ const addAssetLink = page.locator('a[href*="add-asset"]');
+ if (await addAssetLink.count() > 0) {
+ await expect(addAssetLink.first()).toBeVisible();
+ }
+ }
+ });
+});
+
+// =============================================================================
+// FITUR 10 — Pengelola Aset: Laporan Rekapan
+// =============================================================================
+test.describe('Fitur 10 — Pengelola Aset: Laporan Rekapan', () => {
+ test('halaman report dapat diakses', async ({ page }) => {
+ await login(page, 'pengelolaAset');
+ await page.goto('/procurements/report');
+ expect([200, 302].includes(page.url().includes('/report') ? 200 : 302)).toBeTruthy();
+ });
+});
+
+// =============================================================================
+// FITUR 11 — Pengelola Aset: Ubah Status Usulan
+// =============================================================================
+test.describe('Fitur 11 — Pengelola Aset: Ubah Status Usulan', () => {
+ test('detail request menampilkan opsi ubah status', async ({ page }) => {
+ await login(page, 'pengelolaAset');
+ await page.goto('/procurements/requests');
+ const detailLink = page.locator('a[href*="/requests/"]:has-text("Detail")').first();
+ if (await detailLink.count() > 0) {
+ await detailLink.click();
+ await page.waitForTimeout(500);
+ // Cek form/button untuk ubah status
+ const statusForm = page.locator('form[action*="status"], select[name="status"], button:has-text("Ubah"), button:has-text("Terima"), button:has-text("Tolak")');
+ if (await statusForm.count() > 0) {
+ await expect(statusForm.first()).toBeVisible();
+ }
+ }
+ });
+});
+
+// =============================================================================
+// FITUR 12 — Pengelola Aset: API Barang Pengadaan
+// =============================================================================
+test.describe('Fitur 12 — Pengelola Aset: API Data Barang', () => {
+ test('endpoint API mengembalikan JSON', async ({ page }) => {
+ await login(page, 'pengelolaAset');
+ const response = await page.goto('/api/procurement-items');
+ expect(response).not.toBeNull();
+ expect(response!.status()).toBe(200);
+ const contentType = response!.headers()['content-type'] || '';
+ expect(contentType).toContain('json');
+ const body = await response!.json();
+ expect(body).toHaveProperty('success', true);
+ expect(body).toHaveProperty('data');
+ });
+});
+
+// =============================================================================
+// FITUR 13 — Wakil Dekan: Daftar Permohonan (+ Search + Pagination)
+// =============================================================================
+test.describe('Fitur 13 — Wakil Dekan: Daftar Permohonan', () => {
+ test.beforeEach(async ({ page }) => {
+ await login(page, 'wakilDekan');
+ });
+
+ test('menampilkan daftar permohonan', async ({ page }) => {
+ await page.goto('/wakildekan/permohonan');
+ // Tabel ada jika ada data, atau pesan kosong jika belum ada
+ const table = page.locator('#dataTable');
+ const emptyMsg = page.getByText('Belum ada permohonan masuk');
+ const content = table.or(emptyMsg);
+ await expect(content.first()).toBeVisible({ timeout: 10000 });
+ });
+
+ test('fitur pencarian berfungsi', async ({ page }) => {
+ await page.goto('/wakildekan/permohonan');
+ const searchInput = page.locator('input[name="search"]');
+ await expect(searchInput).toBeVisible();
+ await searchInput.fill('REQ');
+ await page.locator('button[type="submit"]').click();
+ await expect(page).toHaveURL(/search=REQ/);
+ });
+
+ test('fitur pagination tersedia', async ({ page }) => {
+ await page.goto('/wakildekan/permohonan');
+ const paginationInfo = page.locator('text=Menampilkan');
+ await expect(paginationInfo.first()).toBeVisible();
+ });
+});
+
+// =============================================================================
+// FITUR 14 — Wakil Dekan: Detail Permohonan
+// =============================================================================
+test.describe('Fitur 14 — Wakil Dekan: Detail Permohonan', () => {
+ test('menampilkan detail permohonan dengan info lengkap', async ({ page }) => {
+ await login(page, 'wakilDekan');
+ await page.goto('/wakildekan/permohonan');
+ const detailLink = page.locator('a[href*="/wakildekan/permohonan/"]:not([href*="approve"]):not([href*="reject"])').first();
+ if (await detailLink.count() > 0) {
+ await detailLink.click();
+ await page.waitForTimeout(500);
+ // Harus ada info: nomor request, judul, status, daftar barang
+ await expect(page.locator('text=Nomor Request, text=Detail')).toBeVisible();
+ }
+ });
+});
+
+// =============================================================================
+// FITUR 15 — Wakil Dekan: Approve/Reject Permohonan
+// =============================================================================
+test.describe('Fitur 15 — Wakil Dekan: Keputusan Permohonan', () => {
+ test('tombol setujui dan tolak tersedia di detail', async ({ page }) => {
+ await login(page, 'wakilDekan');
+ await page.goto('/wakildekan/permohonan');
+ const detailLink = page.locator('a[href*="/wakildekan/permohonan/"]:not([href*="approve"]):not([href*="reject"])').first();
+ if (await detailLink.count() > 0) {
+ await detailLink.click();
+ await page.waitForTimeout(500);
+ // Harus ada tombol Setujui dan Tolak
+ const approveBtn = page.locator('button:has-text("Setujui"), input[value="Setujui"]');
+ const rejectBtn = page.locator('button:has-text("Tolak"), input[value="Tolak"]');
+ if (await approveBtn.count() > 0) {
+ await expect(approveBtn.first()).toBeVisible();
+ }
+ if (await rejectBtn.count() > 0) {
+ await expect(rejectBtn.first()).toBeVisible();
+ }
+ }
+ });
+});
+
+// =============================================================================
+// FITUR 16 — Wakil Dekan: Riwayat Keputusan (+ Search + Pagination)
+// =============================================================================
+test.describe('Fitur 16 — Wakil Dekan: Riwayat Keputusan', () => {
+ test.beforeEach(async ({ page }) => {
+ await login(page, 'wakilDekan');
+ });
+
+ test('menampilkan halaman riwayat', async ({ page }) => {
+ await page.goto('/wakildekan/riwayat');
+ await expect(page.locator('h1')).toContainText('Riwayat');
+ });
+
+ test('fitur pencarian riwayat berfungsi', async ({ page }) => {
+ await page.goto('/wakildekan/riwayat');
+ const searchInput = page.locator('input[name="search"]');
+ await expect(searchInput).toBeVisible();
+ await searchInput.fill('REQ');
+ await page.locator('button[type="submit"]').click();
+ await expect(page).toHaveURL(/search=REQ/);
+ });
+
+ test('fitur pagination riwayat tersedia', async ({ page }) => {
+ await page.goto('/wakildekan/riwayat');
+ const paginationInfo = page.locator('text=Menampilkan');
+ await expect(paginationInfo.first()).toBeVisible();
+ });
+});
+
+// =============================================================================
+// FITUR 17 — Wakil Dekan: Laporan PDF
+// =============================================================================
+test.describe('Fitur 17 — Wakil Dekan: Laporan PDF', () => {
+ test('endpoint download PDF bisa di-download', async ({ page }) => {
+ await login(page, 'wakilDekan');
+ // Navigasi ke halaman riwayat dulu supaya ada link download
+ await page.goto('/wakildekan/riwayat');
+ const downloadLink = page.locator('a[href*="riwayat/download"]').first();
+ if (await downloadLink.count() > 0) {
+ const [download] = await Promise.all([
+ page.waitForEvent('download'),
+ downloadLink.click(),
+ ]);
+ expect(download).toBeTruthy();
+ } else {
+ // Fallback: akses langsung via request API
+ const response = await page.request.get('/wakildekan/riwayat/download');
+ expect(response.status()).toBeLessThan(500);
+ }
+ });
+});
+
+// =============================================================================
+// FITUR 18 — Wakil Dekan: API JSON Permohonan
+// =============================================================================
+test.describe('Fitur 18 — Wakil Dekan: API JSON', () => {
+ test('endpoint API mengembalikan JSON', async ({ page }) => {
+ await login(page, 'wakilDekan');
+ const response = await page.goto('/wakildekan/api/permohonan');
+ expect(response).not.toBeNull();
+ expect(response!.status()).toBe(200);
+ const contentType = response!.headers()['content-type'] || '';
+ expect(contentType).toContain('json');
+ const body = await response!.json();
+ expect(body).toHaveProperty('success', true);
+ expect(body).toHaveProperty('data');
+ });
+});
+
+// =============================================================================
+// ACL — Kontrol Akses Berdasarkan Role
+// =============================================================================
+test.describe('ACL — Kontrol Akses', () => {
+ test('ketua departemen tidak bisa akses halaman wakil dekan', async ({ page }) => {
+ await login(page, 'ketuaDepartemen');
+ const response = await page.goto('/wakildekan/permohonan');
+ // Harus di-redirect ke login/home atau mendapat 403
+ const url = page.url();
+ const status = response!.status();
+ expect(
+ url.includes('/login') || url.includes('/home') || status === 403
+ ).toBeTruthy();
+ });
+
+ test('wakil dekan tidak bisa akses halaman pengelola aset', async ({ page }) => {
+ await login(page, 'wakilDekan');
+ const response = await page.goto('/procurements/requests');
+ const url = page.url();
+ const status = response!.status();
+ expect(
+ url.includes('/login') || url.includes('/home') || status === 403 || status === 302
+ ).toBeTruthy();
+ });
+
+ test('user tanpa login di-redirect ke halaman login', async ({ page }) => {
+ await page.goto('/wakildekan/permohonan');
+ await expect(page).toHaveURL(/\/login/);
+ });
+});
+
+// =============================================================================
+// Dashboard — Wakil Dekan
+// =============================================================================
+test.describe('Dashboard — Wakil Dekan', () => {
+ test('menampilkan statistik di dashboard', async ({ page }) => {
+ await login(page, 'wakilDekan');
+ await page.goto('/wakildekan/dashboard');
+ // Harus ada stat cards — cek teks yang muncul di dashboard
+ await expect(page.getByText('Total Permohonan').first()).toBeVisible({ timeout: 10000 });
+ });
+});
+
diff --git a/views/login.ejs b/views/auth/login.ejs
similarity index 80%
rename from views/login.ejs
rename to views/auth/login.ejs
index 8ffae27d..f0173d5c 100644
--- a/views/login.ejs
+++ b/views/auth/login.ejs
@@ -139,6 +139,12 @@
<% } %>
+ <% if (success) { %>
+
+ <%= success %>
+
+ <% } %>
+
Username
+ data-slot="field-label"
+ class="items-center text-sm font-medium select-none"
+ for="email"
+ >
+ Email
+
+
+
Password
Forgot your password? Hubungi admin untuk reset password
-
- Don't have an account? Sign up
-
diff --git a/views/errors/403.ejs b/views/errors/403.ejs
new file mode 100644
index 00000000..83aebe9b
--- /dev/null
+++ b/views/errors/403.ejs
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
403 Akses Ditolak — Facultyware
+
+
+
+
+
+
+
+
+
+
403
+
Akses Ditolak
+
+ Maaf, Anda tidak memiliki peran (role) yang dibutuhkan untuk mengakses halaman ini. Jika menurut Anda ini adalah kesalahan, silakan hubungi Administrator.
+
+
+
+
+ Kembali ke Halaman Sebelumnya
+
+
+
+
diff --git a/views/home.ejs b/views/home.ejs
index b0f86047..a766b247 100644
--- a/views/home.ejs
+++ b/views/home.ejs
@@ -1,5 +1,5 @@
-
+
-
-
-
+
-
Basecoat
-
-
-
+
Dashboard — Facultyware
-
-
-
-
-
-
-
-
-
-
+
+
-
+ <%- include('partials/ketua_departemen_sidebar', { user }) %>
+
+
-
+
-
- Default
- Claude
- Doom 64
- Supabase
-
-
-
+
+
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-