diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 00000000..39d6bcc2 Binary files /dev/null and b/.DS_Store differ diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..ba17a4df --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +PORT=3000 +DB_HOST=localhost +DB_USER=root +DB_PASSWORD= +DB_NAME=tbb5_pweb +DB_PORT=3306 +SESSION_SECRET=facultyware_secret diff --git a/.gitignore b/.gitignore index d1bed128..deb759ff 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,8 @@ typings/ # next.js build output .next + +# Playwright +playwright-report/ +test-results/ + diff --git a/README.md b/README.md new file mode 100644 index 00000000..e7a385ec --- /dev/null +++ b/README.md @@ -0,0 +1,139 @@ +# FacultyWare — Sistem Perjalanan Dinas Fakultas + +FacultyWare adalah aplikasi web berbasis Node.js, Express.js, MySQL, dan EJS yang dirancang untuk mengelola permohonan perjalanan dinas fakultas secara efisien dan transparan. Aplikasi ini memiliki alur kerja lengkap mulai dari pengajuan perjalanan dinas, manajemen jadwal (itinerary), anggota perjalanan, unggah berkas surat tugas, penulisan laporan hasil perjalanan (outcome), hingga pengajuan reimburse biaya perjalanan dinas. + +--- + +## 1. Tech Stack + +* **Backend**: Node.js & Express.js +* **Database**: MySQL (menggunakan query SQL mentah melalui driver `mysql2` tanpa ORM) +* **View Engine**: EJS (Embedded JavaScript templates) +* **Styling**: Tailwind CSS & Basecoat UI Library +* **Interaktivitas**: Vanilla JavaScript & HTMX (untuk pembaruan parsial dinamis) +* **Autentikasi & Sesi**: `express-session` & `express-mysql-session` +* **Keamanan**: Enkripsi password menggunakan `bcryptjs` +* **Unggah Berkas**: `multer` +* **Ekspor Dokumen**: PDFKit (PDF) & SheetJS/xlsx (Excel) + +--- + +## 2. Cara Instalasi & Konfigurasi + +### Langkah 1: Kloning & Install Dependencies +Pastikan Node.js dan npm telah terinstal di komputer Anda. Jalankan perintah berikut di terminal: +```bash +npm install +``` + +### Langkah 2: Konfigurasi Environment Variables (`.env`) +Salin file `.env.example` menjadi `.env` di direktori utama: +```bash +cp .env.example .env +``` +Sesuaikan konfigurasi database sesuai dengan server MySQL lokal Anda: +```env +PORT=3000 +DB_HOST=127.0.0.1 +DB_USER=root +DB_PASSWORD= +DB_NAME=tb_b5 +DB_PORT=3306 +SESSION_SECRET=facultyware_secret +``` + +### Langkah 3: Inisialisasi Database +Buat database baru di server MySQL Anda dengan nama sesuai konfigurasi `.env` (misal: `tb_b5`). Impor file dump SQL database Anda ke dalamnya. + +Jalankan script inisialisasi lokal untuk membuat tabel relasi pendukung perjalanan dinas jika belum tersedia: +```bash +node scripts/create_local_tables.js +``` + +--- + +## 3. Cara Menjalankan Aplikasi + +### Mode Pengembangan (Development) +Untuk menjalankan server dengan auto-reload menggunakan `nodemon`: +```bash +npm run dev +``` +Aplikasi akan aktif di alamat: [http://localhost:3000](http://localhost:3000). + +### Mode Produksi (Production) +Untuk menjalankan aplikasi dalam mode produksi: +```bash +npm start +``` + +### Pengujian E2E (Playwright) +Untuk menjalankan pengetesan E2E otomatis: +```bash +# Jalankan seluruh rangkaian test suite +npx playwright test --project=chromium + +# Jalankan test suite dengan UI interaktif +npx playwright test --ui +``` + +--- + +## 4. Akun Demo & Peran (Role) + +Aplikasi memiliki dua jenis peran (role) utama untuk masuk ke sistem: + +### Pegawai +* **Username**: `pegawai` +* **Password**: `password` +* **Fitur**: Input/CRUD perjalanan dinas, kelola itinerary/dokumen/anggota, unggah laporan hasil perjalanan, pengajuan reimburse, ekspor/impor Excel, dan unduh PDF Surat Tugas. + +### Pimpinan +* **Username**: `pimpinan` +* **Password**: `password` +* **Fitur**: Dashboard pemantauan statistik, persetujuan (Approve/Reject) perjalanan dinas & klaim reimburse pegawai, peninjauan laporan, dan ekspor Excel global. + +--- + +## Pembagian Tugas Anggota + +### 1. Vanesa Gociardi + +**Modul Perjalanan Dinas Pegawai** + +* Pengembangan fitur pengajuan perjalanan dinas. +* Pengelolaan data perjalanan dinas pegawai. +* Menampilkan status pengajuan perjalanan. +* Pengelolaan hasil perjalanan dan rencana tindak lanjut. +* Integrasi modul pegawai dengan database. +* Deployment aplikasi dan dokumentasi proyek. + +### 2. Farrah Aulia + +**Modul Reimburse Perjalanan Dinas** + +* Pengembangan fitur pengajuan reimburse. +* Pengelolaan komponen biaya perjalanan. +* Upload bukti pembayaran dan kwitansi. +* Pengelolaan data pengeluaran perjalanan dinas. +* Export data reimburse. + +### 3. Fadel + +**Modul Dokumen, Itinerary, dan Anggota Perjalanan** + +* Pengelolaan itinerary perjalanan dinas. +* Pengelolaan anggota/delegasi perjalanan. +* Upload dan manajemen dokumen pendukung. +* Integrasi dokumen dengan data perjalanan dinas. +* Validasi data perjalanan. + +### 4. Zaki + +**Modul Persetujuan dan Dashboard Pimpinan** + +* Pengembangan dashboard pimpinan. +* Persetujuan (Approve/Reject) perjalanan dinas. +* Persetujuan pengajuan reimburse. +* Monitoring seluruh data perjalanan dinas. +* Rekapitulasi dan pelaporan data perjalanan. diff --git a/app.js b/app.js index f91917a2..b076925c 100644 --- a/app.js +++ b/app.js @@ -1,16 +1,27 @@ require('dotenv').config(); -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'); +const express = require('express'); +const path = require('path'); +const cookieParser = require('cookie-parser'); +const logger = require('morgan'); +const session = require('express-session'); +const MySQLStore = require('express-mysql-session')(session); +const fs = require('fs'); + +const indexRouter = require('./routes/index'); +const authRouter = require('./routes/auth'); +const pegawaiRouter = require('./routes/pegawai'); +const pimpinanRouter = require('./routes/pimpinan'); +const apiRouter = require('./routes/api'); + const { notFoundHandler, errorHandler } = require('./middlewares/error'); -var app = express(); +const app = express(); + +// Ensure upload directories exist +const uploadDocs = path.join(__dirname, 'public/uploads/documents'); +const uploadReceipts = path.join(__dirname, 'public/uploads/receipts'); +if (!fs.existsSync(uploadDocs)) fs.mkdirSync(uploadDocs, { recursive: true }); +if (!fs.existsSync(uploadReceipts)) fs.mkdirSync(uploadReceipts, { recursive: true }); // view engine setup app.set('views', path.join(__dirname, 'views')); @@ -28,6 +39,7 @@ const sessionStore = new MySQLStore({ user: process.env.DB_USER, password: process.env.DB_PASSWORD, database: process.env.DB_NAME, + port: process.env.DB_PORT || 3306, }); app.use(session({ @@ -41,8 +53,32 @@ app.use(session({ } })); +// Session message/user variables globally exposed to EJS +app.use((req, res, next) => { + res.locals.success = req.session.success || null; + res.locals.error = req.session.error || null; + res.locals.warning = req.session.warning || null; + + delete req.session.success; + delete req.session.error; + delete req.session.warning; + + res.locals.user = req.session.userId ? { + id: req.session.userId, + username: req.session.username, + role: req.session.role, + employeeId: req.session.employeeId, + name: req.session.name + } : null; + + next(); +}); + app.use('/', indexRouter); -app.use('/users', usersRouter); +app.use('/', authRouter); +app.use('/pegawai', pegawaiRouter); +app.use('/pimpinan', pimpinanRouter); +app.use('/api', apiRouter); // catch 404 and forward to error handler app.use(notFoundHandler); diff --git a/controllers/apiController.js b/controllers/apiController.js new file mode 100644 index 00000000..722d5050 --- /dev/null +++ b/controllers/apiController.js @@ -0,0 +1,103 @@ +const OfficialTravel = require('../models/OfficialTravel'); +const TravelItinerary = require('../models/TravelItinerary'); +const TravelDocument = require('../models/TravelDocument'); +const TravelMember = require('../models/TravelMember'); +const TravelExpense = require('../models/TravelExpense'); +const TravelApproval = require('../models/TravelApproval'); + +// Pegawai API Endpoints +const getMyTravel = async (req, res, next) => { + try { + const travels = await OfficialTravel.findBySubmittedBy(req.session.userId); + res.json({ success: true, data: travels }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}; + +const getMyExpenses = async (req, res, next) => { + try { + const expenses = await TravelExpense.findBySubmittedBy(req.session.employeeId || req.session.userId); + res.json({ success: true, data: expenses }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}; + +const getMyDocuments = async (req, res, next) => { + try { + const documents = await TravelDocument.findByUserId(req.session.userId); + res.json({ success: true, data: documents }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}; + +// Pimpinan API Endpoints +const getTravel = async (req, res, next) => { + try { + const travels = await OfficialTravel.findAll(); + res.json({ success: true, data: travels }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}; + +const getTravelById = async (req, res, next) => { + try { + const { id } = req.params; + const travel = await OfficialTravel.findById(id); + if (!travel) { + return res.status(404).json({ success: false, error: 'Travel request not found.' }); + } + + const itineraries = await TravelItinerary.findByTravelId(id); + const documents = await TravelDocument.findByTravelId(id); + const members = await TravelMember.findByTravelId(id); + const expenses = await TravelExpense.findByTravelId(id); + const approvals = await TravelApproval.findByTravelId(id); + + res.json({ + success: true, + data: { + travel, + itineraries, + documents, + members, + expenses, + approvals + } + }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}; + +const getExpenses = async (req, res, next) => { + try { + const expenses = await TravelExpense.findAll(); + res.json({ success: true, data: expenses }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}; + +const getReports = async (req, res, next) => { + try { + const travels = await OfficialTravel.findAll(); + const completedTravels = travels.filter(t => t.status === 'completed'); + res.json({ success: true, data: completedTravels }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}; + +module.exports = { + getMyTravel, + getMyExpenses, + getMyDocuments, + getTravel, + getTravelById, + getExpenses, + getReports +}; diff --git a/controllers/authController.js b/controllers/authController.js new file mode 100644 index 00000000..b23e21bd --- /dev/null +++ b/controllers/authController.js @@ -0,0 +1,67 @@ +const bcrypt = require('bcryptjs'); +const User = require('../models/User'); + +const loginPage = (req, res) => { + if (req.session && req.session.userId) { + if (req.session.role === 'pimpinan') { + return res.redirect('/pimpinan/dashboard'); + } + return res.redirect('/pegawai/dashboard'); + } + res.render('auth/login', { title: 'Login', error: req.query.error || null }); +}; + +const login = async (req, res, next) => { + const { username, password } = req.body; + + try { + const user = await User.findByUsernameOrEmail(username); + + if (!user) { + return res.render('auth/login', { + title: 'Login', + error: 'Username/Email atau Password salah.' + }); + } + + const isMatch = await bcrypt.compare(password, user.password); + if (!isMatch) { + return res.render('auth/login', { + title: 'Login', + error: 'Username/Email atau Password salah.' + }); + } + + // Set session variables + req.session.userId = user.id; + req.session.username = user.username; + req.session.role = user.role; + req.session.employeeId = user.employee_id; + req.session.name = user.name; + + // Redirect based on role + if (user.role === 'pimpinan') { + res.redirect('/pimpinan/dashboard'); + } else { + res.redirect('/pegawai/dashboard'); + } + } catch (err) { + next(err); + } +}; + +const logout = (req, res, next) => { + req.session.destroy((err) => { + if (err) { + return next(err); + } + res.clearCookie('session_cookie_name'); + res.redirect('/login'); + }); +}; + +module.exports = { + loginPage, + login, + logout +}; diff --git a/controllers/pegawaiController.js b/controllers/pegawaiController.js new file mode 100644 index 00000000..19e7a80c --- /dev/null +++ b/controllers/pegawaiController.js @@ -0,0 +1,835 @@ +const fs = require('fs'); +const path = require('path'); +const xlsx = require('xlsx'); +const PDFDocument = require('pdfkit'); +const db = require('../lib/db'); +const OfficialTravel = require('../models/OfficialTravel'); +const TravelItinerary = require('../models/TravelItinerary'); +const TravelDocument = require('../models/TravelDocument'); +const TravelMember = require('../models/TravelMember'); +const TravelExpense = require('../models/TravelExpense'); +const TravelCostComponent = require('../models/TravelCostComponent'); + +// Ensure upload directories exist +const uploadDocDir = path.join(__dirname, '../public/uploads/documents'); +const uploadRcptDir = path.join(__dirname, '../public/uploads/receipts'); +if (!fs.existsSync(uploadDocDir)) fs.mkdirSync(uploadDocDir, { recursive: true }); +if (!fs.existsSync(uploadRcptDir)) fs.mkdirSync(uploadRcptDir, { recursive: true }); + +const dashboard = async (req, res, next) => { + try { + const stats = await OfficialTravel.getStatsPegawai(req.session.userId); + const expenseStats = await TravelExpense.getStatsPegawai(req.session.userId); + + // Get recent travel requests (limit 5) + const travels = await OfficialTravel.findBySubmittedBy(req.session.userId); + const recentTravels = travels.slice(0, 5); + + res.render('pegawai/dashboard', { + title: 'Dashboard Pegawai', + stats, + expenseStats, + recentTravels + }); + } catch (err) { + next(err); + } +}; +const listTravels = async (req, res, next) => { + try { + const { search = '', status = '' } = req.query; + const travels = await OfficialTravel.findBySubmittedBy( + req.session.userId, + search.trim(), + status.trim() + ); + res.render('pegawai/travels', { + title: 'Daftar Perjalanan Dinas', + travels, + query: { search, status } + }); + } catch (err) { + next(err); + } +}; + +const travelForm = async (req, res, next) => { + try { + const { id } = req.params; + let travel = null; + if (id) { + travel = await OfficialTravel.findById(id); + if (!travel || travel.submitted_by_id !== req.session.userId) { + req.session.error = 'Perjalanan dinas tidak ditemukan atau Anda tidak memiliki akses.'; + return res.redirect('/pegawai/travels'); + } + if (travel.status !== 'draft' && travel.status !== 'pending') { + req.session.warning = 'Perjalanan dinas tidak dapat diedit karena status bukan Draft atau Pending.'; + return res.redirect('/pegawai/travels'); + } + } + + res.render('pegawai/travel-form', { + title: travel ? 'Edit Perjalanan Dinas' : 'Tambah Perjalanan Dinas', + travel + }); + } catch (err) { + next(err); + } +}; + +const saveTravel = async (req, res, next) => { + try { + const { id } = req.params; + const { purpose, destination, start_date, end_date } = req.body; + + const file = req.file; + let invitation_file = null; + if (file) { + invitation_file = `/uploads/documents/${file.filename}`; + } + + if (id) { + // Edit + const travel = await OfficialTravel.findById(id); + if (!travel || travel.submitted_by_id !== req.session.userId) { + req.session.error = 'Perjalanan dinas tidak ditemukan.'; + return res.redirect('/pegawai/travels'); + } + if (travel.status !== 'draft' && travel.status !== 'pending') { + req.session.error = 'Perjalanan dinas tidak dapat diedit karena status bukan Draft atau Pending.'; + return res.redirect('/pegawai/travels'); + } + + await OfficialTravel.update(id, { + purpose, + destination, + start_date, + end_date, + invitation_file + }); + + req.session.success = 'Perjalanan dinas berhasil diperbarui.'; + res.redirect(`/pegawai/travels/detail/${id}`); + } else { + // Create + // Auto-generate request_number + const dateStr = new Date().toISOString().slice(0, 10).replace(/-/g, ''); + const randomNum = Math.floor(1000 + Math.random() * 9000); + const request_number = `SPD/${dateStr}/${randomNum}`; + + const newId = await OfficialTravel.create({ + request_number, + purpose, + destination, + start_date, + end_date, + invitation_file, + submitted_by: req.session.employeeId || req.session.userId, + submitted_by_id: req.session.userId + }); + + req.session.success = 'Perjalanan dinas berhasil ditambahkan sebagai Draft.'; + res.redirect(`/pegawai/travels/detail/${newId}`); + } + } catch (err) { + next(err); + } +}; + +const travelDetail = async (req, res, next) => { + try { + const { id } = req.params; + const travel = await OfficialTravel.findById(id); + + if (!travel || travel.submitted_by_id !== req.session.userId) { + req.session.error = 'Perjalanan dinas tidak ditemukan atau Anda tidak memiliki akses.'; + return res.redirect('/pegawai/travels'); + } + + const itineraries = await TravelItinerary.findByTravelId(id); + const documents = await TravelDocument.findByTravelId(id); + const members = await TravelMember.findByTravelId(id); + const expenses = await TravelExpense.findByTravelId(id); + const costComponents = await TravelCostComponent.findAll(); + const availableEmployees = await TravelMember.getAvailableEmployees(); + + res.render('pegawai/travel-detail', { + title: `Detail Perjalanan: ${travel.request_number}`, + travel, + itineraries, + documents, + members, + expenses, + costComponents, + availableEmployees + }); + } catch (err) { + next(err); + } +}; + +const deleteTravel = async (req, res, next) => { + try { + const { id } = req.params; + const travel = await OfficialTravel.findById(id); + + if (!travel || travel.submitted_by_id !== req.session.userId) { + req.session.error = 'Perjalanan dinas tidak ditemukan.'; + return res.redirect('/pegawai/travels'); + } + + if (travel.status !== 'draft' && travel.status !== 'pending') { + req.session.error = 'Perjalanan dinas tidak dapat dihapus karena status bukan Draft atau Pending.'; + return res.redirect('/pegawai/travels'); + } + + await OfficialTravel.delete(id); + req.session.success = 'Perjalanan dinas berhasil dihapus.'; + res.redirect('/pegawai/travels'); + } catch (err) { + next(err); + } +}; + +const submitTravel = async (req, res, next) => { + try { + const { id } = req.params; + const travel = await OfficialTravel.findById(id); + + if (!travel || travel.submitted_by_id !== req.session.userId) { + req.session.error = 'Perjalanan dinas tidak ditemukan.'; + return res.redirect('/pegawai/travels'); + } + + if (travel.status !== 'draft') { + req.session.warning = 'Perjalanan dinas sudah diajukan sebelumnya.'; + return res.redirect(`/pegawai/travels/detail/${id}`); + } + + await OfficialTravel.updateStatus(id, 'pending'); + req.session.success = 'Perjalanan dinas berhasil diajukan untuk approval.'; + res.redirect(`/pegawai/travels/detail/${id}`); + } catch (err) { + next(err); + } +}; + +// Document methods +const uploadDocument = async (req, res, next) => { + try { + const { official_travel_id, title } = req.body; + const file = req.file; + + if (!file) { + req.session.error = 'File tidak boleh kosong.'; + return res.redirect(`/pegawai/travels/detail/${official_travel_id}`); + } + + await TravelDocument.create({ + official_travel_id, + title, + file_path: `/uploads/documents/${file.filename}`, + file_type: file.mimetype + }); + + req.session.success = 'Dokumen perjalanan berhasil diunggah.'; + res.redirect(`/pegawai/travels/detail/${official_travel_id}`); + } catch (err) { + next(err); + } +}; + +const deleteDocument = async (req, res, next) => { + try { + const { id } = req.params; + const doc = await TravelDocument.findById(id); + if (!doc) { + req.session.error = 'Dokumen tidak ditemukan.'; + return res.redirect('/pegawai/travels'); + } + + const travel = await OfficialTravel.findById(doc.official_travel_id); + if (!travel || travel.submitted_by_id !== req.session.userId) { + req.session.error = 'Anda tidak memiliki akses.'; + return res.redirect('/pegawai/travels'); + } + + // Optional: Delete physical file + const physicalPath = path.join(__dirname, '../public', doc.file_path); + if (fs.existsSync(physicalPath)) { + try { fs.unlinkSync(physicalPath); } catch (e) {} + } + + await TravelDocument.delete(id); + req.session.success = 'Dokumen perjalanan berhasil dihapus.'; + res.redirect(`/pegawai/travels/detail/${doc.official_travel_id}`); + } catch (err) { + next(err); + } +}; + +// Itinerary methods +const saveItinerary = async (req, res, next) => { + try { + const { official_travel_id, date, location, activity, description } = req.body; + await TravelItinerary.create({ + official_travel_id, + date, + location, + activity, + description + }); + req.session.success = 'Jadwal perjalanan berhasil ditambahkan.'; + res.redirect(`/pegawai/travels/detail/${official_travel_id}`); + } catch (err) { + next(err); + } +}; + +const deleteItinerary = async (req, res, next) => { + try { + const { id } = req.params; + const itinerary = await TravelItinerary.findById(id); + if (!itinerary) { + req.session.error = 'Jadwal tidak ditemukan.'; + return res.redirect('/pegawai/travels'); + } + + await TravelItinerary.delete(id); + req.session.success = 'Jadwal perjalanan berhasil dihapus.'; + res.redirect(`/pegawai/travels/detail/${itinerary.official_travel_id}`); + } catch (err) { + next(err); + } +}; + +// Member methods +const saveMember = async (req, res, next) => { + try { + const { official_travel_id, employee_id, role, report_date, summary } = req.body; + + const file = req.file; + let attachment = null; + if (file) { + attachment = `/uploads/documents/${file.filename}`; + } + + await TravelMember.create({ + official_travel_id, + employee_id, + role, + report_date, + summary, + attachment + }); + + req.session.success = 'Anggota perjalanan berhasil ditambahkan.'; + res.redirect(`/pegawai/travels/detail/${official_travel_id}`); + } catch (err) { + next(err); + } +}; + +const deleteMember = async (req, res, next) => { + try { + const { id } = req.params; + const member = await TravelMember.findById(id); + if (!member) { + req.session.error = 'Anggota tidak ditemukan.'; + return res.redirect('/pegawai/travels'); + } + + await TravelMember.delete(id); + req.session.success = 'Anggota perjalanan berhasil dihapus.'; + res.redirect(`/pegawai/travels/detail/${member.official_travel_id}`); + } catch (err) { + next(err); + } +}; + +// Outcome / Report +const saveOutcome = async (req, res, next) => { + try { + const { id } = req.params; + const { travel_outcome, outcome_followup } = req.body; + + const travel = await OfficialTravel.findById(id); + if (!travel || travel.submitted_by_id !== req.session.userId) { + req.session.error = 'Perjalanan dinas tidak ditemukan.'; + return res.redirect('/pegawai/travels'); + } + + if (travel.status !== 'approved' && travel.status !== 'completed') { + req.session.error = 'Laporan hanya bisa diisi jika perjalanan sudah disetujui atau selesai.'; + return res.redirect(`/pegawai/travels/detail/${id}`); + } + + await OfficialTravel.updateOutcome(id, travel_outcome, outcome_followup); + req.session.success = 'Laporan hasil perjalanan dinas disimpan. Status perjalanan diubah menjadi Completed.'; + res.redirect(`/pegawai/travels/detail/${id}`); + } catch (err) { + next(err); + } +}; + +// Expenses CRUD +const listExpenses = async (req, res, next) => { + try { + const expenses = await TravelExpense.findBySubmittedBy(req.session.employeeId || req.session.userId); + res.render('pegawai/expenses', { + title: 'Daftar Reimburse', + expenses + }); + } catch (err) { + next(err); + } +}; + +const expenseForm = async (req, res, next) => { + try { + const { id } = req.params; + let expense = null; + if (id) { + expense = await TravelExpense.findById(id); + if (!expense || expense.employee_id !== (req.session.employeeId || req.session.userId)) { + req.session.error = 'Data reimburse tidak ditemukan.'; + return res.redirect('/pegawai/expenses'); + } + if (expense.status !== 'submitted') { + req.session.error = 'Reimburse tidak dapat diedit karena sudah diproses oleh Pimpinan.'; + return res.redirect('/pegawai/expenses'); + } + } + + const travels = await OfficialTravel.findBySubmittedBy(req.session.userId); + const approvedTravels = travels.filter(t => t.status === 'approved' || t.status === 'completed'); + const costComponents = await TravelCostComponent.findAll(); + + res.render('pegawai/expense-form', { + title: expense ? 'Edit Pengajuan Reimburse' : 'Tambah Pengajuan Reimburse', + expense, + travels: approvedTravels, + costComponents + }); + } catch (err) { + next(err); + } +}; + +const saveExpense = async (req, res, next) => { + try { + const { id } = req.params; + const { official_travel_id, travel_cost_component_id, amount, description } = req.body; + const file = req.file; + + let receipt_file = null; + if (file) { + receipt_file = `/uploads/receipts/${file.filename}`; + } + + if (id) { + const expense = await TravelExpense.findById(id); + if (!expense || expense.employee_id !== (req.session.employeeId || req.session.userId)) { + req.session.error = 'Data reimburse tidak ditemukan.'; + return res.redirect('/pegawai/expenses'); + } + if (expense.status !== 'submitted') { + req.session.error = 'Reimburse tidak dapat diedit karena sudah diproses.'; + return res.redirect('/pegawai/expenses'); + } + + await TravelExpense.update(id, { + travel_cost_component_id, + amount, + description, + receipt_file + }); + + req.session.success = 'Data reimburse berhasil diperbarui.'; + } else { + await TravelExpense.create({ + official_travel_id, + employee_id: req.session.employeeId || req.session.userId, + travel_cost_component_id, + amount, + description, + receipt_file + }); + + req.session.success = 'Pengajuan reimburse berhasil dikirim.'; + } + + res.redirect('/pegawai/expenses'); + } catch (err) { + next(err); + } +}; + +const deleteExpense = async (req, res, next) => { + try { + const { id } = req.params; + const expense = await TravelExpense.findById(id); + if (!expense || expense.employee_id !== (req.session.employeeId || req.session.userId)) { + req.session.error = 'Data reimburse tidak ditemukan.'; + return res.redirect('/pegawai/expenses'); + } + if (expense.status !== 'submitted') { + req.session.error = 'Reimburse tidak dapat dihapus karena sudah diproses.'; + return res.redirect('/pegawai/expenses'); + } + + // Optional physical deletion + if (expense.receipt_file) { + const physicalPath = path.join(__dirname, '../public', expense.receipt_file); + if (fs.existsSync(physicalPath)) { + try { fs.unlinkSync(physicalPath); } catch (e) {} + } + } + + await TravelExpense.delete(id); + req.session.success = 'Data reimburse berhasil dihapus.'; + res.redirect('/pegawai/expenses'); + } catch (err) { + next(err); + } +}; + +// Export Travels list to Excel +const exportTravels = async (req, res, next) => { + try { + const travels = await OfficialTravel.findBySubmittedBy(req.session.userId); + const data = travels.map(t => ({ + 'Nomor Permohonan': t.request_number, + 'Keperluan / Tujuan': t.purpose, + 'Tujuan Destinasi': t.destination, + 'Tanggal Mulai': t.start_date, + 'Tanggal Selesai': t.end_date, + 'Status': t.status.toUpperCase(), + 'Diajukan Tanggal': t.submitted_at ? new Date(t.submitted_at).toLocaleDateString() : '', + 'Disetujui Oleh': t.approved_by || '-', + 'Disetujui Tanggal': t.approved_at ? new Date(t.approved_at).toLocaleDateString() : '-' + })); + + const worksheet = xlsx.utils.json_to_sheet(data); + const workbook = xlsx.utils.book_new(); + xlsx.utils.book_append_sheet(workbook, worksheet, 'Perjalanan Dinas'); + + const buffer = xlsx.write(workbook, { type: 'buffer', bookType: 'xlsx' }); + res.setHeader('Content-Disposition', 'attachment; filename=perjalanan-dinas-pegawai.xlsx'); + res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + res.send(buffer); + } catch (err) { + next(err); + } +}; + +// Import Travels from Excel +const importTravels = async (req, res, next) => { + try { + const file = req.file; + if (!file) { + req.session.error = 'File Excel tidak ditemukan.'; + return res.redirect('/pegawai/travels'); + } + + const workbook = xlsx.readFile(file.path); + const firstSheetName = workbook.SheetNames[0]; + const worksheet = workbook.Sheets[firstSheetName]; + const rows = xlsx.utils.sheet_to_json(worksheet); + + let count = 0; + for (const row of rows) { + // Parse dates safely + let start_date = row['Tanggal Mulai'] || row['Start Date']; + let end_date = row['Tanggal Selesai'] || row['End Date']; + const purpose = row['Keperluan / Tujuan'] || row['Purpose'] || 'Impor Perjalanan'; + const destination = row['Tujuan Destinasi'] || row['Destination'] || 'Impor'; + + if (!start_date || !end_date) continue; + + const dateStr = new Date().toISOString().slice(0, 10).replace(/-/g, ''); + const randomNum = Math.floor(1000 + Math.random() * 9000); + const request_number = `SPD/${dateStr}/${randomNum}`; + + await OfficialTravel.create({ + request_number, + purpose, + destination, + start_date: new Date(start_date).toISOString().slice(0, 10), + end_date: new Date(end_date).toISOString().slice(0, 10), + invitation_file: null, + status: 'draft', + travel_outcome: null, + outcome_followup: null, + submitted_by: req.session.employeeId || req.session.userId, + submitted_by_id: req.session.userId + }); + count++; + } + + // Delete temp file + if (fs.existsSync(file.path)) fs.unlinkSync(file.path); + + req.session.success = `Berhasil mengimpor ${count} data perjalanan dinas dari Excel.`; + res.redirect('/pegawai/travels'); + } catch (err) { + next(err); + } +}; + +// Export Reports list to Excel +const exportReports = async (req, res, next) => { + try { + const travels = await OfficialTravel.findBySubmittedBy(req.session.userId); + const completedTravels = travels.filter(t => t.status === 'completed' || t.travel_outcome); + const data = completedTravels.map(t => ({ + 'Nomor Permohonan': t.request_number, + 'Keperluan / Tujuan': t.purpose, + 'Tujuan Destinasi': t.destination, + 'Hasil Perjalanan': t.travel_outcome || '', + 'Rencana Tindak Lanjut': t.outcome_followup || '' + })); + + const worksheet = xlsx.utils.json_to_sheet(data); + const workbook = xlsx.utils.book_new(); + xlsx.utils.book_append_sheet(workbook, worksheet, 'Laporan Perjalanan'); + + const buffer = xlsx.write(workbook, { type: 'buffer', bookType: 'xlsx' }); + res.setHeader('Content-Disposition', 'attachment; filename=laporan-perjalanan-pegawai.xlsx'); + res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + res.send(buffer); + } catch (err) { + next(err); + } +}; + +// Import Reports from Excel +const importReports = async (req, res, next) => { + try { + const file = req.file; + if (!file) { + req.session.error = 'File Excel tidak ditemukan.'; + return res.redirect('/pegawai/travels'); + } + + const workbook = xlsx.readFile(file.path); + const firstSheetName = workbook.SheetNames[0]; + const worksheet = workbook.Sheets[firstSheetName]; + const rows = xlsx.utils.sheet_to_json(worksheet); + + let count = 0; + for (const row of rows) { + const requestNumber = row['Nomor Permohonan'] || row['Request Number']; + const travel_outcome = row['Hasil Perjalanan'] || row['Outcome']; + const outcome_followup = row['Rencana Tindak Lanjut'] || row['Followup'] || ''; + + if (!requestNumber || !travel_outcome) continue; + + const travelList = await OfficialTravel.findBySubmittedBy(req.session.userId); + const matchedTravel = travelList.find( + t => t.request_number === requestNumber.trim() && (t.status === 'approved' || t.status === 'completed') + ); + + if (!matchedTravel) continue; + + await OfficialTravel.updateOutcome(matchedTravel.id, travel_outcome, outcome_followup); + count++; + } + + // Delete temp file + if (fs.existsSync(file.path)) fs.unlinkSync(file.path); + + req.session.success = `Berhasil mengimpor ${count} laporan perjalanan dari Excel.`; + res.redirect('/pegawai/travels'); + } catch (err) { + next(err); + } +}; + +// Export Expenses to Excel +const exportExpenses = async (req, res, next) => { + try { + const expenses = await TravelExpense.findBySubmittedBy(req.session.employeeId || req.session.userId); + const data = expenses.map(e => ({ + 'Nomor Permohonan': e.request_number, + 'Komponen Biaya': e.component_name, + 'Jumlah': e.amount, + 'Keterangan': e.description || '-', + 'Diajukan Tanggal': e.submitted_at ? new Date(e.submitted_at).toLocaleDateString() : '', + 'Status': e.status.toUpperCase() + })); + + const worksheet = xlsx.utils.json_to_sheet(data); + const workbook = xlsx.utils.book_new(); + xlsx.utils.book_append_sheet(workbook, worksheet, 'Reimburse'); + + const buffer = xlsx.write(workbook, { type: 'buffer', bookType: 'xlsx' }); + res.setHeader('Content-Disposition', 'attachment; filename=reimburse-pegawai.xlsx'); + res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + res.send(buffer); + } catch (err) { + next(err); + } +}; + +// Import Expenses from Excel +const importExpenses = async (req, res, next) => { + try { + const file = req.file; + if (!file) { + req.session.error = 'File Excel tidak ditemukan.'; + return res.redirect('/pegawai/expenses'); + } + + const workbook = xlsx.readFile(file.path); + const firstSheetName = workbook.SheetNames[0]; + const worksheet = workbook.Sheets[firstSheetName]; + const rows = xlsx.utils.sheet_to_json(worksheet); + + const costComponents = await TravelCostComponent.findAll(); + + let count = 0; + for (const row of rows) { + const requestNumber = row['Nomor Permohonan'] || row['Request Number']; + const componentInput = row['Komponen Biaya'] || row['Cost Component']; + const amount = row['Jumlah'] || row['Amount']; + const description = row['Keterangan'] || row['Description'] || ''; + + if (!requestNumber || !componentInput || !amount) continue; + + // 1. Look up Travel by request number and user ID + const [travelRows] = await db.query( + 'SELECT id FROM official_travel WHERE request_number = ? AND submitted_by_id = ?', + [requestNumber.trim(), req.session.userId] + ); + if (travelRows.length === 0) continue; + + const official_travel_id = travelRows[0].id; + + // 2. Find matching cost component + const componentStr = componentInput.toString().trim().toLowerCase(); + let matchedComponent = costComponents.find( + c => c.name.toLowerCase() === componentStr || c.code.toLowerCase() === componentStr + ); + + if (!matchedComponent) { + matchedComponent = costComponents.find( + c => c.name.toLowerCase().includes(componentStr) || c.code.toLowerCase().includes(componentStr) + ); + } + + const travel_cost_component_id = matchedComponent ? matchedComponent.id : (costComponents[0] ? costComponents[0].id : 1); + + // 3. Create Travel Expense + await TravelExpense.create({ + official_travel_id, + employee_id: req.session.employeeId || req.session.userId, + travel_cost_component_id, + amount: parseFloat(amount), + description: description === '-' ? null : description, + receipt_file: null + }); + + count++; + } + + // Delete temp file + if (fs.existsSync(file.path)) fs.unlinkSync(file.path); + + req.session.success = `Berhasil mengimpor ${count} klaim reimburse dari Excel.`; + res.redirect('/pegawai/expenses'); + } catch (err) { + next(err); + } +}; + +// PDF Surat Tugas Printing +const printTravelPDF = async (req, res, next) => { + try { + const { id } = req.params; + const travel = await OfficialTravel.findById(id); + + if (!travel || travel.submitted_by_id !== req.session.userId) { + req.session.error = 'Perjalanan dinas tidak ditemukan.'; + return res.redirect('/pegawai/travels'); + } + + const doc = new PDFDocument({ margin: 50 }); + res.setHeader('Content-Disposition', `attachment; filename=Surat-Tugas-${travel.request_number.replace(/\//g, '-')}.pdf`); + res.setHeader('Content-Type', 'application/pdf'); + doc.pipe(res); + + // Write PDF Content + doc.fontSize(20).text('SURAT TUGAS PERJALANAN DINAS', { align: 'center' }); + doc.moveDown(0.5); + doc.fontSize(12).text(`Nomor Dokumen: ${travel.request_number}`, { align: 'center' }); + doc.moveDown(1.5); + + const startX = 50; + let currentY = 150; + + doc.fontSize(12).text('Yang bertanda tangan di bawah ini menerangkan bahwa:', startX, currentY); + doc.moveDown(1); + + const labels = [ + { key: 'Nama Pegawai', val: travel.submitter_name || req.session.name }, + { key: 'Keperluan Tugas', val: travel.purpose }, + { key: 'Destinasi Tujuan', val: travel.destination }, + { key: 'Tanggal Keberangkatan', val: new Date(travel.start_date).toLocaleDateString('id-ID') }, + { key: 'Tanggal Kepulangan', val: new Date(travel.end_date).toLocaleDateString('id-ID') }, + { key: 'Status Permohonan', val: travel.status.toUpperCase() } + ]; + + labels.forEach(item => { + doc.font('Helvetica-Bold').text(`${item.key}:`, startX, doc.y); + doc.font('Helvetica').text(item.val, startX + 160, doc.y - 12); + doc.moveDown(0.8); + }); + + doc.moveDown(2); + doc.text('Demikian surat tugas ini diterbitkan agar dapat dilaksanakan dengan penuh tanggung jawab.', startX, doc.y); + doc.moveDown(3); + + // Signatures + const dateToday = new Date().toLocaleDateString('id-ID'); + doc.text(`Jakarta, ${dateToday}`, 350, doc.y); + doc.moveDown(0.5); + doc.text('Mengetahui,', 350, doc.y); + doc.text('Pimpinan Fakultas', 350, doc.y); + doc.moveDown(2.5); + doc.font('Helvetica-Bold').text(travel.approved_by || 'Administrasi Fakultas', 350, doc.y); + + doc.end(); + } catch (err) { + next(err); + } +}; + +module.exports = { + dashboard, + listTravels, + travelForm, + saveTravel, + travelDetail, + deleteTravel, + submitTravel, + uploadDocument, + deleteDocument, + saveItinerary, + deleteItinerary, + saveMember, + deleteMember, + saveOutcome, + listExpenses, + expenseForm, + saveExpense, + deleteExpense, + exportTravels, + importTravels, + exportReports, + importReports, + exportExpenses, + importExpenses, + printTravelPDF +}; diff --git a/controllers/pimpinanController.js b/controllers/pimpinanController.js new file mode 100644 index 00000000..e987d830 --- /dev/null +++ b/controllers/pimpinanController.js @@ -0,0 +1,405 @@ +const fs = require('fs'); +const path = require('path'); +const xlsx = require('xlsx'); +const db = require('../lib/db'); +const OfficialTravel = require('../models/OfficialTravel'); +const TravelApproval = require('../models/TravelApproval'); +const TravelItinerary = require('../models/TravelItinerary'); +const TravelDocument = require('../models/TravelDocument'); +const TravelMember = require('../models/TravelMember'); +const TravelExpense = require('../models/TravelExpense'); + +const dashboard = async (req, res, next) => { + try { + const stats = await OfficialTravel.getStatsPimpinan(); + const expenseStats = await TravelExpense.getStatsPimpinan(); + + // Get all travels + const travels = await OfficialTravel.findAll(); + const pendingTravels = travels.filter(t => t.status === 'pending'); + + // Get recent approvals/history (limit 5) + const recentTravels = travels.slice(0, 5); + + res.render('pimpinan/dashboard', { + title: 'Dashboard Pimpinan', + stats, + expenseStats, + pendingTravels, + recentTravels + }); + } catch (err) { + next(err); + } +}; + +const listTravels = async (req, res, next) => { + try { + const travels = await OfficialTravel.findAll(); + res.render('pimpinan/travels', { + title: 'Semua Perjalanan Dinas', + travels + }); + } catch (err) { + next(err); + } +}; + +const travelDetail = async (req, res, next) => { + try { + const { id } = req.params; + const travel = await OfficialTravel.findById(id); + + if (!travel) { + req.session.error = 'Perjalanan dinas tidak ditemukan.'; + return res.redirect('/pimpinan/travels'); + } + + const itineraries = await TravelItinerary.findByTravelId(id); + const documents = await TravelDocument.findByTravelId(id); + const members = await TravelMember.findByTravelId(id); + const expenses = await TravelExpense.findByTravelId(id); + const approvals = await TravelApproval.findByTravelId(id); + + res.render('pimpinan/travel-detail', { + title: `Approval: ${travel.request_number}`, + travel, + itineraries, + documents, + members, + expenses, + approvals + }); + } catch (err) { + next(err); + } +}; + +const approveTravel = async (req, res, next) => { + try { + const { id } = req.params; + const { notes } = req.body; + + const travel = await OfficialTravel.findById(id); + if (!travel) { + req.session.error = 'Perjalanan dinas tidak ditemukan.'; + return res.redirect('/pimpinan/travels'); + } + + // Update status in official_travel + await OfficialTravel.updateStatus(id, 'approved', req.session.username || req.session.name, req.session.userId); + + // Insert history to official_travel_approvals + await TravelApproval.create({ + official_travel_id: id, + approver_id: req.session.userId, + status: 'approved', + notes, + employee_id: travel.submitted_by_id || travel.submitted_by + }); + + req.session.success = `Permohonan ${travel.request_number} berhasil DISETUJUI.`; + res.redirect(`/pimpinan/travels/detail/${id}`); + } catch (err) { + next(err); + } +}; + +const rejectTravel = async (req, res, next) => { + try { + const { id } = req.params; + const { notes } = req.body; + + if (!notes || notes.trim() === '') { + req.session.error = 'Catatan penolakan harus diisi.'; + return res.redirect(`/pimpinan/travels/detail/${id}`); + } + + const travel = await OfficialTravel.findById(id); + if (!travel) { + req.session.error = 'Perjalanan dinas tidak ditemukan.'; + return res.redirect('/pimpinan/travels'); + } + + // Update status in official_travel + await OfficialTravel.updateStatus(id, 'rejected', req.session.username || req.session.name, req.session.userId); + + // Insert history to official_travel_approvals + await TravelApproval.create({ + official_travel_id: id, + approver_id: req.session.userId, + status: 'rejected', + notes, + employee_id: travel.submitted_by_id || travel.submitted_by + }); + + req.session.warning = `Permohonan ${travel.request_number} telah DITOLAK dengan catatan.`; + res.redirect(`/pimpinan/travels/detail/${id}`); + } catch (err) { + next(err); + } +}; + +const listExpenses = async (req, res, next) => { + try { + const expenses = await TravelExpense.findAll(); + res.render('pimpinan/expenses', { + title: 'Approval Reimburse', + expenses + }); + } catch (err) { + next(err); + } +}; + +const approveExpense = async (req, res, next) => { + try { + const { id } = req.params; + const expense = await TravelExpense.findById(id); + + if (!expense) { + req.session.error = 'Reimburse tidak ditemukan.'; + return res.redirect('/pimpinan/expenses'); + } + + await TravelExpense.verify(id, 'approved'); + req.session.success = `Reimburse sebesar Rp ${Number(expense.amount).toLocaleString('id-ID')} disetujui.`; + res.redirect('/pimpinan/expenses'); + } catch (err) { + next(err); + } +}; + +const rejectExpense = async (req, res, next) => { + try { + const { id } = req.params; + const expense = await TravelExpense.findById(id); + + if (!expense) { + req.session.error = 'Reimburse tidak ditemukan.'; + return res.redirect('/pimpinan/expenses'); + } + + await TravelExpense.verify(id, 'rejected'); + req.session.warning = `Reimburse sebesar Rp ${Number(expense.amount).toLocaleString('id-ID')} ditolak.`; + res.redirect('/pimpinan/expenses'); + } catch (err) { + next(err); + } +}; + +const listReports = async (req, res, next) => { + try { + const travels = await OfficialTravel.findAll(); + const completedTravels = travels.filter(t => t.status === 'completed'); + res.render('pimpinan/reports', { + title: 'Laporan Perjalanan Dinas', + travels: completedTravels + }); + } catch (err) { + next(err); + } +}; + +// Export all travels +const exportAllTravels = async (req, res, next) => { + try { + const travels = await OfficialTravel.findAll(); + const data = travels.map(t => ({ + 'Nomor Permohonan': t.request_number, + 'Diajukan Oleh': t.submitter_name || t.submitter_username || `ID ${t.submitted_by_id}`, + 'Keperluan': t.purpose, + 'Tujuan': t.destination, + 'Tanggal Mulai': t.start_date, + 'Tanggal Selesai': t.end_date, + 'Status': t.status.toUpperCase(), + 'Diajukan Tanggal': t.submitted_at ? new Date(t.submitted_at).toLocaleDateString() : '', + 'Disetujui Oleh': t.approved_by || '-', + 'Disetujui Tanggal': t.approved_at ? new Date(t.approved_at).toLocaleDateString() : '-' + })); + + const worksheet = xlsx.utils.json_to_sheet(data); + const workbook = xlsx.utils.book_new(); + xlsx.utils.book_append_sheet(workbook, worksheet, 'Semua Perjalanan'); + + const buffer = xlsx.write(workbook, { type: 'buffer', bookType: 'xlsx' }); + res.setHeader('Content-Disposition', 'attachment; filename=semua-perjalanan-dinas.xlsx'); + res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + res.send(buffer); + } catch (err) { + next(err); + } +}; + +// Export all expenses +const exportAllExpenses = async (req, res, next) => { + try { + const expenses = await TravelExpense.findAll(); + const data = expenses.map(e => ({ + 'Nomor Permohonan': e.request_number, + 'Pegawai': e.name, + 'Komponen Biaya': e.component_name, + 'Jumlah': e.amount, + 'Keterangan': e.description || '-', + 'Diajukan Tanggal': e.submitted_at ? new Date(e.submitted_at).toLocaleDateString() : '', + 'Status': e.status.toUpperCase(), + 'Diverifikasi Tanggal': e.verified_at ? new Date(e.verified_at).toLocaleDateString() : '-' + })); + + const worksheet = xlsx.utils.json_to_sheet(data); + const workbook = xlsx.utils.book_new(); + xlsx.utils.book_append_sheet(workbook, worksheet, 'Semua Reimburse'); + + const buffer = xlsx.write(workbook, { type: 'buffer', bookType: 'xlsx' }); + res.setHeader('Content-Disposition', 'attachment; filename=semua-reimburse.xlsx'); + res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + res.send(buffer); + } catch (err) { + next(err); + } +}; +// Fitur 12: Mengembalikan data seluruh perjalanan dinas dalam format JSON +const getAllTravelsJSON = async (req, res, next) => { + try { + const travels = await OfficialTravel.findAll(); + res.json(travels); + } catch (err) { + next(err); + } +}; + +// Import all travels for Pimpinan +const importAllTravels = async (req, res, next) => { + try { + const file = req.file; + if (!file) { + req.session.error = 'File Excel tidak ditemukan.'; + return res.redirect('/pimpinan/travels'); + } + + const workbook = xlsx.readFile(file.path); + const firstSheetName = workbook.SheetNames[0]; + const worksheet = workbook.Sheets[firstSheetName]; + const rows = xlsx.utils.sheet_to_json(worksheet); + + let count = 0; + for (const row of rows) { + let start_date = row['Tanggal Mulai'] || row['Start Date']; + let end_date = row['Tanggal Selesai'] || row['End Date']; + const purpose = row['Keperluan'] || row['Keperluan / Tujuan'] || row['Purpose'] || 'Impor Perjalanan'; + const destination = row['Tujuan'] || row['Tujuan Destinasi'] || row['Destination'] || 'Impor'; + const statusInput = row['Status'] || 'DRAFT'; + const status = statusInput.toString().toLowerCase(); + + const diajukanOleh = row['Diajukan Oleh'] || row['Pegawai'] || ''; + + if (!start_date || !end_date) continue; + + // Determine who submitted the travel + let submitted_by_id = req.session.userId; + let submitted_by = req.session.employeeId || req.session.userId; + + if (diajukanOleh) { + // Try parsing ID e.g., "ID 12" + const idMatch = diajukanOleh.toString().match(/ID\s+(\d+)/i); + if (idMatch) { + const parsedUserId = parseInt(idMatch[1]); + const [userRows] = await db.query(` + SELECT u.id as user_id, e.id as employee_id + FROM users u + LEFT JOIN employees e ON u.id = e.user_id + WHERE u.id = ? + `, [parsedUserId]); + if (userRows.length > 0) { + submitted_by_id = userRows[0].user_id; + submitted_by = userRows[0].employee_id || userRows[0].user_id; + } + } else { + // Look up by username or employee name + const diajukanOlehStr = diajukanOleh.toString().trim(); + const [userRows] = await db.query(` + SELECT u.id as user_id, e.id as employee_id + FROM users u + LEFT JOIN employees e ON u.id = e.user_id + WHERE u.username = ? OR e.name = ? + `, [diajukanOlehStr, diajukanOlehStr]); + if (userRows.length > 0) { + submitted_by_id = userRows[0].user_id; + submitted_by = userRows[0].employee_id || userRows[0].user_id; + } + } + } + + const dateStr = new Date().toISOString().slice(0, 10).replace(/-/g, ''); + const randomNum = Math.floor(1000 + Math.random() * 9000); + const request_number = `SPD/${dateStr}/${randomNum}`; + + await OfficialTravel.create({ + request_number, + purpose, + destination, + start_date: new Date(start_date).toISOString().slice(0, 10), + end_date: new Date(end_date).toISOString().slice(0, 10), + invitation_file: null, + status: ['draft', 'pending', 'approved', 'rejected'].includes(status) ? status : 'draft', + travel_outcome: null, + outcome_followup: null, + submitted_by, + submitted_by_id + }); + count++; + } + + // Delete temp file + if (fs.existsSync(file.path)) fs.unlinkSync(file.path); + + req.session.success = `Berhasil mengimpor ${count} data perjalanan dinas dari Excel.`; + res.redirect('/pimpinan/travels'); + } catch (err) { + next(err); + } +}; + +// Export all reports for Pimpinan +const exportAllReports = async (req, res, next) => { + try { + const travels = await OfficialTravel.findAll(); + const completedTravels = travels.filter(t => t.status === 'completed' || t.travel_outcome); + const data = completedTravels.map(t => ({ + 'Nomor Permohonan': t.request_number, + 'Diajukan Oleh': t.submitter_name || t.submitter_username || `ID ${t.submitted_by_id}`, + 'Keperluan': t.purpose, + 'Tujuan': t.destination, + 'Hasil Perjalanan': t.travel_outcome || '', + 'Tindak Lanjut': t.outcome_followup || '' + })); + + const worksheet = xlsx.utils.json_to_sheet(data); + const workbook = xlsx.utils.book_new(); + xlsx.utils.book_append_sheet(workbook, worksheet, 'Laporan Perjalanan'); + + const buffer = xlsx.write(workbook, { type: 'buffer', bookType: 'xlsx' }); + res.setHeader('Content-Disposition', 'attachment; filename=laporan-perjalanan-seluruh-pegawai.xlsx'); + res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + res.send(buffer); + } catch (err) { + next(err); + } +}; + +module.exports = { + dashboard, + listTravels, + travelDetail, + approveTravel, + rejectTravel, + listExpenses, + approveExpense, + rejectExpense, + listReports, + exportAllTravels, + exportAllExpenses, + getAllTravelsJSON, + importAllTravels, + exportAllReports +}; diff --git a/lib/db.js b/lib/db.js index 76a7d5b8..68d16e17 100644 --- a/lib/db.js +++ b/lib/db.js @@ -6,6 +6,7 @@ const pool = mysql.createPool({ user: process.env.DB_USER, password: process.env.DB_PASSWORD, database: process.env.DB_NAME, + port: process.env.DB_PORT || 3306, waitForConnections: true, connectionLimit: 10, queueLimit: 0 diff --git a/middlewares/role.js b/middlewares/role.js new file mode 100644 index 00000000..ff477e93 --- /dev/null +++ b/middlewares/role.js @@ -0,0 +1,24 @@ +function isPegawai(req, res, next) { + if (req.session && req.session.role === 'pegawai') { + return next(); + } + res.status(403).render("error", { + message: "Forbidden: Halaman ini hanya untuk Pegawai.", + error: { status: 403, stack: "" } + }); +} + +function isPimpinan(req, res, next) { + if (req.session && req.session.role === 'pimpinan') { + return next(); + } + res.status(403).render("error", { + message: "Forbidden: Halaman ini hanya untuk Pimpinan.", + error: { status: 403, stack: "" } + }); +} + +module.exports = { + isPegawai, + isPimpinan +}; diff --git a/models/OfficialTravel.js b/models/OfficialTravel.js new file mode 100644 index 00000000..1841670f --- /dev/null +++ b/models/OfficialTravel.js @@ -0,0 +1,141 @@ +const db = require('../lib/db'); + +class OfficialTravel { + static async create(data) { + const status = data.status || 'draft'; + const [result] = await db.query(` + INSERT INTO official_travel ( + request_number, purpose, destination, start_date, end_date, + invitation_file, status, submitted_by, submitted_by_id, submitted_at, + travel_outcome, outcome_followup + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?, ?) + `, [ + data.request_number, data.purpose, data.destination, data.start_date, data.end_date, + data.invitation_file || null, status, data.submitted_by, data.submitted_by_id, + data.travel_outcome || null, data.outcome_followup || null + ]); + return result.insertId; + } + + static async findById(id) { + const [rows] = await db.query(` + SELECT ot.*, + u.username as submitter_username, + e.name as submitter_name + FROM official_travel ot + LEFT JOIN users u ON ot.submitted_by_id = u.id + LEFT JOIN employees e ON u.id = e.user_id + WHERE ot.id = ? + `, [id]); + + if (rows.length === 0) return null; + return rows[0]; + } + + static async findBySubmittedBy(userId, search = '', status = '') { + let sql = ` + SELECT * FROM official_travel + WHERE submitted_by_id = ? + `; + const params = [userId]; + + if (search) { + sql += ` AND (destination LIKE ? OR purpose LIKE ? OR request_number LIKE ?)`; + params.push(`%${search}%`, `%${search}%`, `%${search}%`); + } + + if (status) { + sql += ` AND status = ?`; + params.push(status); + } + + sql += ` ORDER BY created_at DESC`; + + const [rows] = await db.query(sql, params); + return rows; + } + + static async findAll() { + const [rows] = await db.query(` + SELECT ot.*, + u.username as submitter_username, + e.name as submitter_name + FROM official_travel ot + LEFT JOIN users u ON ot.submitted_by_id = u.id + LEFT JOIN employees e ON u.id = e.user_id + ORDER BY ot.created_at DESC + `); + return rows; + } + + static async update(id, data) { + await db.query(` + UPDATE official_travel + SET purpose = ?, destination = ?, start_date = ?, end_date = ?, invitation_file = IFNULL(?, invitation_file) + WHERE id = ? AND (status = 'draft' OR status = 'pending') + `, [ + data.purpose, data.destination, data.start_date, data.end_date, data.invitation_file || null, id + ]); + } + + static async updateStatus(id, status, approvedBy = null, approvedById = null) { + if (status === 'approved' || status === 'rejected') { + await db.query(` + UPDATE official_travel + SET status = ?, approved_by = ?, approved_by_id = ?, approved_at = CURRENT_TIMESTAMP + WHERE id = ? + `, [status, approvedBy, approvedById, id]); + } else { + await db.query(` + UPDATE official_travel + SET status = ? + WHERE id = ? + `, [status, id]); + } + } + + static async delete(id) { + await db.query(` + DELETE FROM official_travel + WHERE id = ? AND (status = 'draft' OR status = 'pending') + `, [id]); + } + + static async updateOutcome(id, outcome, followup) { + await db.query(` + UPDATE official_travel + SET travel_outcome = ?, outcome_followup = ?, status = 'completed' + WHERE id = ? AND (status = 'approved' OR status = 'completed') + `, [outcome, followup, id]); + } + + static async getStatsPegawai(userId) { + const [rows] = await db.query(` + SELECT + COUNT(*) as total, + SUM(CASE WHEN status = 'draft' THEN 1 ELSE 0 END) as draft, + SUM(CASE WHEN status = 'pending' 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, + SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed + FROM official_travel + WHERE submitted_by_id = ? + `, [userId]); + return rows[0]; + } + + static async getStatsPimpinan() { + const [rows] = await db.query(` + SELECT + COUNT(*) as total, + SUM(CASE WHEN status = 'pending' 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, + SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed + FROM official_travel + `); + return rows[0]; + } +} + +module.exports = OfficialTravel; diff --git a/models/TravelApproval.js b/models/TravelApproval.js new file mode 100644 index 00000000..0c033661 --- /dev/null +++ b/models/TravelApproval.js @@ -0,0 +1,27 @@ +const db = require('../lib/db'); + +class TravelApproval { + static async create(data) { + const [result] = await db.query(` + INSERT INTO official_travel_approvals ( + official_travel_id, approver_id, status, notes, action_date, employee_id + ) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, ?) + `, [ + data.official_travel_id, data.approver_id, data.status, data.notes || null, data.employee_id + ]); + return result.insertId; + } + + static async findByTravelId(travelId) { + const [rows] = await db.query(` + SELECT ota.*, u.username as approver_username + FROM official_travel_approvals ota + LEFT JOIN users u ON ota.approver_id = u.id + WHERE ota.official_travel_id = ? + ORDER BY ota.action_date DESC + `, [travelId]); + return rows; + } +} + +module.exports = TravelApproval; diff --git a/models/TravelCostComponent.js b/models/TravelCostComponent.js new file mode 100644 index 00000000..a4cf0d92 --- /dev/null +++ b/models/TravelCostComponent.js @@ -0,0 +1,16 @@ +const db = require('../lib/db'); + +class TravelCostComponent { + static async findAll() { + const [rows] = await db.query('SELECT * FROM travel_cost_components ORDER BY name ASC'); + return rows; + } + + static async findById(id) { + const [rows] = await db.query('SELECT * FROM travel_cost_components WHERE id = ?', [id]); + if (rows.length === 0) return null; + return rows[0]; + } +} + +module.exports = TravelCostComponent; diff --git a/models/TravelDocument.js b/models/TravelDocument.js new file mode 100644 index 00000000..f181a341 --- /dev/null +++ b/models/TravelDocument.js @@ -0,0 +1,46 @@ +const db = require('../lib/db'); + +class TravelDocument { + static async create(data) { + const [result] = await db.query(` + INSERT INTO official_travel_documents ( + official_travel_id, title, file_path, file_type, uploaded_at + ) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) + `, [ + data.official_travel_id, data.title, data.file_path, data.file_type + ]); + return result.insertId; + } + + static async findByTravelId(travelId) { + const [rows] = await db.query(` + SELECT * FROM official_travel_documents + WHERE official_travel_id = ? + ORDER BY uploaded_at DESC + `, [travelId]); + return rows; + } + + static async findById(id) { + const [rows] = await db.query('SELECT * FROM official_travel_documents WHERE id = ?', [id]); + if (rows.length === 0) return null; + return rows[0]; + } + + static async delete(id) { + await db.query('DELETE FROM official_travel_documents WHERE id = ?', [id]); + } + + static async findByUserId(userId) { + const [rows] = await db.query(` + SELECT otd.* + FROM official_travel_documents otd + JOIN official_travel ot ON otd.official_travel_id = ot.id + WHERE ot.submitted_by_id = ? + ORDER BY otd.uploaded_at DESC + `, [userId]); + return rows; + } +} + +module.exports = TravelDocument; diff --git a/models/TravelExpense.js b/models/TravelExpense.js new file mode 100644 index 00000000..aed6e3ad --- /dev/null +++ b/models/TravelExpense.js @@ -0,0 +1,133 @@ +const db = require('../lib/db'); + +class TravelExpense { + static async create(data) { + const [result] = await db.query(` + INSERT INTO travel_expenses ( + official_travel_id, employee_id, travel_cost_component_id, amount, + description, receipt_file, submitted_at, status + ) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, 'submitted') + `, [ + data.official_travel_id, data.employee_id, data.travel_cost_component_id, data.amount, + data.description || null, data.receipt_file || null + ]); + return result.insertId; + } + + static async findById(id) { + const [rows] = await db.query(` + SELECT te.*, + tcc.name as component_name, tcc.code as component_code, + ot.request_number, ot.purpose as travel_purpose, + e.name as employee_name, + u.username as fallback_username + FROM travel_expenses te + JOIN travel_cost_components tcc ON te.travel_cost_component_id = tcc.id + JOIN official_travel ot ON te.official_travel_id = ot.id + LEFT JOIN employees e ON te.employee_id = e.id + LEFT JOIN users u ON te.employee_id = u.id + WHERE te.id = ? + `, [id]); + + if (rows.length === 0) return null; + const row = rows[0]; + row.name = row.employee_name || row.fallback_username || `Employee #${row.employee_id}`; + return row; + } + + static async findBySubmittedBy(employeeId) { + const [rows] = await db.query(` + SELECT te.*, + tcc.name as component_name, tcc.code as component_code, + ot.request_number + FROM travel_expenses te + JOIN travel_cost_components tcc ON te.travel_cost_component_id = tcc.id + JOIN official_travel ot ON te.official_travel_id = ot.id + WHERE te.employee_id = ? + ORDER BY te.created_at DESC + `, [employeeId]); + return rows; + } + + static async findAll() { + const [rows] = await db.query(` + SELECT te.*, + tcc.name as component_name, tcc.code as component_code, + ot.request_number, + e.name as employee_name, + u.username as fallback_username + FROM travel_expenses te + JOIN travel_cost_components tcc ON te.travel_cost_component_id = tcc.id + JOIN official_travel ot ON te.official_travel_id = ot.id + LEFT JOIN employees e ON te.employee_id = e.id + LEFT JOIN users u ON te.employee_id = u.id + ORDER BY te.created_at DESC + `); + + return rows.map(row => { + row.name = row.employee_name || row.fallback_username || `Employee #${row.employee_id}`; + return row; + }); + } + + static async update(id, data) { + await db.query(` + UPDATE travel_expenses + SET travel_cost_component_id = ?, amount = ?, description = ?, receipt_file = IFNULL(?, receipt_file) + WHERE id = ? AND status = 'submitted' + `, [ + data.travel_cost_component_id, data.amount, data.description || null, data.receipt_file || null, id + ]); + } + + static async delete(id) { + await db.query(` + DELETE FROM travel_expenses + WHERE id = ? AND status = 'submitted' + `, [id]); + } + + static async verify(id, status) { + await db.query(` + UPDATE travel_expenses + SET status = ?, verified_at = CURRENT_TIMESTAMP + WHERE id = ? + `, [status, id]); + } + + static async findByTravelId(travelId) { + const [rows] = await db.query(` + SELECT te.*, + tcc.name as component_name, tcc.code as component_code + FROM travel_expenses te + JOIN travel_cost_components tcc ON te.travel_cost_component_id = tcc.id + WHERE te.official_travel_id = ? + ORDER BY te.created_at DESC + `, [travelId]); + return rows; + } + + static async getStatsPegawai(employeeId) { + const [rows] = await db.query(` + SELECT + IFNULL(SUM(amount), 0) as total_amount, + COUNT(*) as total_count + FROM travel_expenses + WHERE employee_id = ? + `, [employeeId]); + return rows[0]; + } + + static async getStatsPimpinan() { + const [rows] = await db.query(` + SELECT + IFNULL(SUM(amount), 0) as total_amount, + COUNT(*) as total_count + FROM travel_expenses + WHERE status = 'submitted' + `, []); + return rows[0]; + } +} + +module.exports = TravelExpense; diff --git a/models/TravelItinerary.js b/models/TravelItinerary.js new file mode 100644 index 00000000..01878f7e --- /dev/null +++ b/models/TravelItinerary.js @@ -0,0 +1,45 @@ +const db = require('../lib/db'); + +class TravelItinerary { + static async create(data) { + const [result] = await db.query(` + INSERT INTO official_travel_itineraries ( + official_travel_id, date, location, activity, description + ) VALUES (?, ?, ?, ?, ?) + `, [ + data.official_travel_id, data.date, data.location, data.activity, data.description || null + ]); + return result.insertId; + } + + static async findByTravelId(travelId) { + const [rows] = await db.query(` + SELECT * FROM official_travel_itineraries + WHERE official_travel_id = ? + ORDER BY date ASC + `, [travelId]); + return rows; + } + + static async findById(id) { + const [rows] = await db.query('SELECT * FROM official_travel_itineraries WHERE id = ?', [id]); + if (rows.length === 0) return null; + return rows[0]; + } + + static async update(id, data) { + await db.query(` + UPDATE official_travel_itineraries + SET date = ?, location = ?, activity = ?, description = ? + WHERE id = ? + `, [ + data.date, data.location, data.activity, data.description || null, id + ]); + } + + static async delete(id) { + await db.query('DELETE FROM official_travel_itineraries WHERE id = ?', [id]); + } +} + +module.exports = TravelItinerary; diff --git a/models/TravelMember.js b/models/TravelMember.js new file mode 100644 index 00000000..b243d7b0 --- /dev/null +++ b/models/TravelMember.js @@ -0,0 +1,59 @@ +const db = require('../lib/db'); + +class TravelMember { + static async create(data) { + const [result] = await db.query(` + INSERT INTO official_travel_members ( + official_travel_id, employee_id, role, report_date, summary, attachment + ) VALUES (?, ?, ?, ?, ?, ?) + `, [ + data.official_travel_id, data.employee_id, data.role || 'Member', + data.report_date || null, data.summary || null, data.attachment || null + ]); + return result.insertId; + } + + static async findByTravelId(travelId) { + const [rows] = await db.query(` + SELECT otm.*, + e.name AS employee_name, + u.username AS fallback_username + FROM official_travel_members otm + LEFT JOIN employees e ON otm.employee_id = e.id + LEFT JOIN users u ON otm.employee_id = u.id + WHERE otm.official_travel_id = ? + `, [travelId]); + + return rows.map(row => { + row.name = row.employee_name || row.fallback_username || `Employee #${row.employee_id}`; + return row; + }); + } + + static async findById(id) { + const [rows] = await db.query('SELECT * FROM official_travel_members WHERE id = ?', [id]); + if (rows.length === 0) return null; + return rows[0]; + } + + static async update(id, data) { + await db.query(` + UPDATE official_travel_members + SET employee_id = ?, role = ?, report_date = ?, summary = ?, attachment = IFNULL(?, attachment) + WHERE id = ? + `, [ + data.employee_id, data.role || 'Member', data.report_date || null, data.summary || null, data.attachment || null, id + ]); + } + + static async delete(id) { + await db.query('DELETE FROM official_travel_members WHERE id = ?', [id]); + } + + static async getAvailableEmployees() { + const [rows] = await db.query('SELECT * FROM employees ORDER BY name ASC'); + return rows; + } +} + +module.exports = TravelMember; diff --git a/models/User.js b/models/User.js new file mode 100644 index 00000000..15d2c2d2 --- /dev/null +++ b/models/User.js @@ -0,0 +1,60 @@ +const db = require('../lib/db'); + +class User { + static async findByUsernameOrEmail(identifier) { + // Standard query for username + const [rows] = await db.query('SELECT * FROM users WHERE username = ?', [identifier]); + if (rows.length === 0) return null; + + const user = rows[0]; + + // Get user role from user_has_roles & roles + const [roleRows] = await db.query(` + SELECT r.name FROM roles r + JOIN user_has_roles uhr ON r.id = uhr.role_id + WHERE uhr.user_id = ? + `, [user.id]); + + user.role = roleRows.length > 0 ? roleRows[0].name : 'pegawai'; + + // Get associated employee data + const [empRows] = await db.query('SELECT * FROM employees WHERE user_id = ?', [user.id]); + if (empRows.length > 0) { + user.employee_id = empRows[0].id; + user.name = empRows[0].name; + } else { + user.employee_id = user.id; // fallback + user.name = user.username; + } + + return user; + } + + static async findById(id) { + const [rows] = await db.query('SELECT * FROM users WHERE id = ?', [id]); + if (rows.length === 0) return null; + + const user = rows[0]; + + const [roleRows] = await db.query(` + SELECT r.name FROM roles r + JOIN user_has_roles uhr ON r.id = uhr.role_id + WHERE uhr.user_id = ? + `, [user.id]); + + user.role = roleRows.length > 0 ? roleRows[0].name : 'pegawai'; + + const [empRows] = await db.query('SELECT * FROM employees WHERE user_id = ?', [user.id]); + if (empRows.length > 0) { + user.employee_id = empRows[0].id; + user.name = empRows[0].name; + } else { + user.employee_id = user.id; + user.name = user.username; + } + + return user; + } +} + +module.exports = User; diff --git a/models/travelCostComponent.js b/models/travelCostComponent.js new file mode 100644 index 00000000..a4cf0d92 --- /dev/null +++ b/models/travelCostComponent.js @@ -0,0 +1,16 @@ +const db = require('../lib/db'); + +class TravelCostComponent { + static async findAll() { + const [rows] = await db.query('SELECT * FROM travel_cost_components ORDER BY name ASC'); + return rows; + } + + static async findById(id) { + const [rows] = await db.query('SELECT * FROM travel_cost_components WHERE id = ?', [id]); + if (rows.length === 0) return null; + return rows[0]; + } +} + +module.exports = TravelCostComponent; diff --git a/models/travelExpense.js b/models/travelExpense.js new file mode 100644 index 00000000..aed6e3ad --- /dev/null +++ b/models/travelExpense.js @@ -0,0 +1,133 @@ +const db = require('../lib/db'); + +class TravelExpense { + static async create(data) { + const [result] = await db.query(` + INSERT INTO travel_expenses ( + official_travel_id, employee_id, travel_cost_component_id, amount, + description, receipt_file, submitted_at, status + ) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, 'submitted') + `, [ + data.official_travel_id, data.employee_id, data.travel_cost_component_id, data.amount, + data.description || null, data.receipt_file || null + ]); + return result.insertId; + } + + static async findById(id) { + const [rows] = await db.query(` + SELECT te.*, + tcc.name as component_name, tcc.code as component_code, + ot.request_number, ot.purpose as travel_purpose, + e.name as employee_name, + u.username as fallback_username + FROM travel_expenses te + JOIN travel_cost_components tcc ON te.travel_cost_component_id = tcc.id + JOIN official_travel ot ON te.official_travel_id = ot.id + LEFT JOIN employees e ON te.employee_id = e.id + LEFT JOIN users u ON te.employee_id = u.id + WHERE te.id = ? + `, [id]); + + if (rows.length === 0) return null; + const row = rows[0]; + row.name = row.employee_name || row.fallback_username || `Employee #${row.employee_id}`; + return row; + } + + static async findBySubmittedBy(employeeId) { + const [rows] = await db.query(` + SELECT te.*, + tcc.name as component_name, tcc.code as component_code, + ot.request_number + FROM travel_expenses te + JOIN travel_cost_components tcc ON te.travel_cost_component_id = tcc.id + JOIN official_travel ot ON te.official_travel_id = ot.id + WHERE te.employee_id = ? + ORDER BY te.created_at DESC + `, [employeeId]); + return rows; + } + + static async findAll() { + const [rows] = await db.query(` + SELECT te.*, + tcc.name as component_name, tcc.code as component_code, + ot.request_number, + e.name as employee_name, + u.username as fallback_username + FROM travel_expenses te + JOIN travel_cost_components tcc ON te.travel_cost_component_id = tcc.id + JOIN official_travel ot ON te.official_travel_id = ot.id + LEFT JOIN employees e ON te.employee_id = e.id + LEFT JOIN users u ON te.employee_id = u.id + ORDER BY te.created_at DESC + `); + + return rows.map(row => { + row.name = row.employee_name || row.fallback_username || `Employee #${row.employee_id}`; + return row; + }); + } + + static async update(id, data) { + await db.query(` + UPDATE travel_expenses + SET travel_cost_component_id = ?, amount = ?, description = ?, receipt_file = IFNULL(?, receipt_file) + WHERE id = ? AND status = 'submitted' + `, [ + data.travel_cost_component_id, data.amount, data.description || null, data.receipt_file || null, id + ]); + } + + static async delete(id) { + await db.query(` + DELETE FROM travel_expenses + WHERE id = ? AND status = 'submitted' + `, [id]); + } + + static async verify(id, status) { + await db.query(` + UPDATE travel_expenses + SET status = ?, verified_at = CURRENT_TIMESTAMP + WHERE id = ? + `, [status, id]); + } + + static async findByTravelId(travelId) { + const [rows] = await db.query(` + SELECT te.*, + tcc.name as component_name, tcc.code as component_code + FROM travel_expenses te + JOIN travel_cost_components tcc ON te.travel_cost_component_id = tcc.id + WHERE te.official_travel_id = ? + ORDER BY te.created_at DESC + `, [travelId]); + return rows; + } + + static async getStatsPegawai(employeeId) { + const [rows] = await db.query(` + SELECT + IFNULL(SUM(amount), 0) as total_amount, + COUNT(*) as total_count + FROM travel_expenses + WHERE employee_id = ? + `, [employeeId]); + return rows[0]; + } + + static async getStatsPimpinan() { + const [rows] = await db.query(` + SELECT + IFNULL(SUM(amount), 0) as total_amount, + COUNT(*) as total_count + FROM travel_expenses + WHERE status = 'submitted' + `, []); + return rows[0]; + } +} + +module.exports = TravelExpense; diff --git a/package-lock.json b/package-lock.json index 59b6794c..b84e938e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,63 @@ "express-session": "^1.19.0", "http-errors": "~1.6.3", "morgan": "~1.9.1", - "mysql2": "^3.22.3" + "multer": "^2.2.0", + "mysql2": "^3.22.3", + "pdfkit": "^0.19.1", + "xlsx": "^0.18.5" + }, + "devDependencies": { + "@playwright/test": "^1.61.0", + "nodemon": "^3.1.14" + } + }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "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.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" } }, "node_modules/@types/node": { @@ -44,6 +100,35 @@ "node": ">= 0.6" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "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/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -59,6 +144,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 +195,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 +229,67 @@ "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/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", @@ -110,6 +299,77 @@ "node": ">= 0.8" } }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, + "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/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/content-disposition": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz", @@ -156,6 +416,18 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "license": "MIT" }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -189,6 +461,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", @@ -421,6 +699,25 @@ "node": ">= 0.6" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "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 +736,23 @@ "node": ">= 0.8" } }, + "node_modules/fontkit": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz", + "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.12", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "dfa": "^1.2.0", + "fast-deep-equal": "^3.1.3", + "restructure": "^3.0.0", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.4.0", + "unicode-trie": "^2.0.0" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -448,6 +762,15 @@ "node": ">= 0.6" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -457,6 +780,21 @@ "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/generate-function": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", @@ -466,6 +804,29 @@ "is-property": "^1.0.2" } }, + "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/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/http-errors": { "version": "1.6.3", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", @@ -493,6 +854,13 @@ "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", @@ -508,12 +876,83 @@ "node": ">= 0.10" } }, + "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-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-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-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/js-md5": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz", + "integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==", + "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/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", @@ -598,6 +1037,22 @@ "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": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.9.1.tgz", @@ -620,6 +1075,25 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/multer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/mysql2": { "version": "3.22.3", "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.22.3.tgz", @@ -679,6 +1153,70 @@ "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/on-finished": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", @@ -700,6 +1238,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 +1259,88 @@ "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==", "license": "MIT" }, + "node_modules/pdfkit": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.19.1.tgz", + "integrity": "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA==", + "license": "MIT", + "dependencies": { + "@noble/ciphers": "^1.0.0", + "@noble/hashes": "^1.6.0", + "fontkit": "^2.0.4", + "js-md5": "^0.8.3", + "linebreak": "^1.1.0", + "png-js": "^1.1.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/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -728,6 +1354,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,6 +1403,39 @@ "node": ">= 0.8" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "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/restructure": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", + "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==", + "license": "MIT" + }, "node_modules/safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", @@ -782,6 +1448,19 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "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", @@ -832,6 +1511,19 @@ "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==", "license": "ISC" }, + "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", @@ -856,6 +1548,18 @@ "node": ">= 0.6" } }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/statuses": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.4.0.tgz", @@ -865,6 +1569,91 @@ "node": ">= 0.6" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "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/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", @@ -878,6 +1667,12 @@ "node": ">= 0.6" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/uid-safe": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", @@ -890,6 +1685,13 @@ "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", @@ -897,6 +1699,32 @@ "license": "MIT", "peer": true }, + "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", + "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", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -906,6 +1734,12 @@ "node": ">= 0.8" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -923,6 +1757,45 @@ "engines": { "node": ">= 0.8" } + }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } } } } diff --git a/package.json b/package.json index bf3659a5..c15cc9a0 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,10 @@ "private": true, "scripts": { "start": "node ./bin/www", - "dev": "nodemon ./bin/www" + "dev": "nodemon ./bin/www", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:report": "playwright show-report" }, "dependencies": { "bcryptjs": "^3.0.3", @@ -17,6 +20,13 @@ "express-session": "^1.19.0", "http-errors": "~1.6.3", "morgan": "~1.9.1", - "mysql2": "^3.22.3" + "multer": "^2.2.0", + "mysql2": "^3.22.3", + "pdfkit": "^0.19.1", + "xlsx": "^0.18.5" + }, + "devDependencies": { + "@playwright/test": "^1.61.0", + "nodemon": "^3.1.14" } } diff --git a/pimpinan/expenses.ejs b/pimpinan/expenses.ejs new file mode 100644 index 00000000..fb387002 --- /dev/null +++ b/pimpinan/expenses.ejs @@ -0,0 +1,76 @@ +<%- include('../layouts/header') %> +<%- include('../layouts/sidebar') %> + +
Tinjau kwitansi nota dan verifikasi klaim reimbursement pengeluaran perjalanan dinas.
+| Pegawai | +No. Permohonan | +Komponen Biaya | +Jumlah | +Keterangan | +Bukti Nota | +Status | +Aksi | +
|---|---|---|---|---|---|---|---|
| Belum ada pengajuan klaim reimburse biaya. | +|||||||
| <%= e.name %> | +<%= e.request_number %> | ++ <%= e.component_name %> + (<%= e.component_code %>) + | +Rp <%= Number(e.amount).toLocaleString('id-ID') %> | +<%= e.description || '-' %> | ++ <% if (e.receipt_file) { %> + Lihat Kwitansi + <% } else { %> + - + <% } %> + | ++ + <%= e.status %> + + | ++ <% if (e.status === 'submitted') { %> + Approve + Reject + <% } else { %> + Sudah Diproses + <% } %> + | +
+ By clicking continue, you agree to our + Terms of Service + and Privacy Policy. +
+