fisrt commit

This commit is contained in:
akbarguci.pkl 2026-05-22 10:58:05 +07:00
commit dda77fee48
128 changed files with 18570 additions and 0 deletions

5
belajar_express/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules
# Keep environment variables out of version control
.env
/generated/prisma

10
belajar_express/db.js Normal file
View File

@ -0,0 +1,10 @@
import mysql from "mysql2/promise";
const db = mysql.createPool({
host: "mysql-21e0c1de-jambrong9876-15fe.c.aivencloud.com",
user: "avnadmin",
password: "AVNS_CniytSqxWcGq3_Tjg7T",
database: "defaultdb"
});
export default db;

647
belajar_express/index.js Normal file
View File

@ -0,0 +1,647 @@
import express from 'express';
import mysql from 'mysql2/promise';
import bcrypt from 'bcrypt';
import cors from 'cors';
import QRCode from 'qrcode';
// --- IMPORT UNTUK FILE UPLOAD ---
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
app.use(express.json());
app.use(cors()); // CORS aktif, React aman!
// --- BUAT FOLDER UPLOADS SECARA OTOMATIS JIKALAU BELUM ADA ---
const uploadDir = path.join(__dirname, 'uploads');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir);
}
// --- AKSI STATISKAN FOLDER UPLOADS AGAR GAMBAR BISA DIAKSES FRONTEND ---
app.use('/uploads', express.static(uploadDir));
// --- SETTING MULTER UNTUK MENANGKAP FOTO DARI FRONTEND ---
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, uploadDir);
},
filename: (req, file, cb) => {
const namaUnik = `foto-${Date.now()}${path.extname(file.originalname)}`;
cb(null, namaUnik);
}
});
const upload = multer({ storage: storage });
// --- KONFIGURASI DATABASE CIFO ---
const db = mysql.createPool({
host: "sql.cifo.co.id",
port: 3307,
user: "pkl_bast",
password: "PklCifo2026",
database: "pkl_bast",
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0
});
// Cek Koneksi Database
db.getConnection()
.then(conn => {
console.log("Koneksi CIFO Berhasil. Siap Tempur!");
conn.release();
})
.catch(err => {
console.error("GAGAL KONEK:", err.message);
});
// =======================================================
// 1. MODUL AUTHENTICATION (REGISTER & LOGIN)
// =======================================================
// API REGISTER
app.post("/api/register", async (req, res) => {
const { nama_lengkap, username, password, role, email, lembaga } = req.body;
if (!username || !password || !nama_lengkap) {
return res.status(400).json({ success: false, message: "Nama, Username, dan Password wajib diisi Bang!" });
}
try {
const [cekUser] = await db.query("SELECT * FROM User WHERE username = ?", [username]);
if (cekUser.length > 0) {
return res.status(400).json({ success: false, message: "Username sudah ada, cari yang lain!" });
}
const hashedPassword = await bcrypt.hash(password, 10);
const sql = `INSERT INTO User (nama_lengkap, username, password, role, email, lembaga)
VALUES (?, ?, ?, ?, ?, ?)`;
await db.query(sql, [
nama_lengkap,
username,
hashedPassword,
role || 'User',
email || null,
lembaga || null
]);
res.json({ success: true, message: "User Berhasil Terdaftar! Silakan Login." });
} catch (error) {
console.error(error);
res.status(500).json({ success: false, error: error.message });
}
});
// API LOGIN RESMI
app.post('/api/login', async (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ success: false, message: "Username dan Password wajib diisi, Bang!" });
}
try {
const [rows] = await db.query("SELECT * FROM User WHERE username = ?", [username]);
if (rows.length === 0) {
return res.status(401).json({ success: false, message: "Username tidak ditemukan, Bang!" });
}
const user = rows[0];
const passwordCocok = await bcrypt.compare(password, user.password);
if (!passwordCocok) {
return res.status(401).json({ success: false, message: "Password salah, Bang!" });
}
const tokenKontrak = `SECRET_TOKEN_UKK_${user.user_id}_${Date.now()}`;
res.json({
success: true,
message: "Login Berhasil! Selamat Datang Kembali.",
token: tokenKontrak,
data: {
user_id: user.user_id,
username: user.username,
role: user.role,
nama_lengkap: user.nama_lengkap,
email: user.email
}
});
} catch (error) {
console.error(error);
res.status(500).json({ success: false, error: error.message });
}
});
// API GET PROFILE USER LOGIN
app.get('/api/user/:id', async (req, res) => {
const { id } = req.params;
try {
const [rows] = await db.query(
`
SELECT
user_id,
nama_lengkap,
username,
role,
email
FROM User
WHERE user_id = ?
`,
[id]
);
if (rows.length === 0) {
return res.status(404).json({
success: false,
message: "User tidak ditemukan!"
});
}
res.json({
success: true,
data: rows[0]
});
} catch (error) {
console.error(error);
res.status(500).json({
success: false,
error: error.message
});
}
});
// =======================================================
// 2. MODUL MANAJEMEN USER (CRUD USER)
// =======================================================
app.get("/api/users", async (req, res) => {
try {
const [rows] = await db.query("SELECT user_id, nama_lengkap, username, role, email, lembaga FROM User");
res.json({ success: true, data: rows });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
app.put("/api/users/:id", async (req, res) => {
const { id } = req.params;
const { nama_lengkap, username, role, email, lembaga } = req.body;
try {
await db.query(
"UPDATE User SET nama_lengkap = ?, username = ?, role = ?, email = ?, lembaga = ? WHERE user_id = ?",
[nama_lengkap, username, role, email, lembaga, id]
);
res.json({ success: true, message: "User Berhasil Diupdate!" });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
app.delete("/api/users/:id", async (req, res) => {
const { id } = req.params;
try {
await db.query("DELETE FROM User WHERE user_id = ?", [id]);
res.json({ success: true, message: "User Berhasil Dihapus!" });
} catch (error) {
res.status(500).json({
success: false,
message: "Gagal hapus! User mungkin terikat dengan data transaksi lain."
});
}
});
// =======================================================
// 3. MODUL MASTER DATA BARANG (CRUD BARANG)
// =======================================================
app.get("/api/barang", async (req, res) => {
try {
const sql = `
SELECT b.barang_id, b.kode_barang, b.nama_barang, b.stok, b.status_barang, b.qr_image, b.foto_barang,
k.nama_kategori, l.nama_lokasi
FROM Barang b
LEFT JOIN Kategori k ON b.kategori_id = k.kategori_id
LEFT JOIN Lokasi l ON b.lokasi_id = l.lokasi_id
ORDER BY b.barang_id DESC`;
const [rows] = await db.query(sql);
res.json({ success: true, data: rows });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// GET DETAIL BARANG KHUSUS UNTUK SCAN QR CODE
app.get("/api/barang/detail/:kode", async (req, res) => {
const { kode } = req.params;
try {
const sqlBarang = `
SELECT b.barang_id, b.kode_barang, b.nama_barang, b.stok, b.status_barang, b.qr_image, b.foto_barang, b.dibuat_pada,
k.nama_kategori, l.nama_lokasi
FROM Barang b
LEFT JOIN Kategori k ON b.kategori_id = k.kategori_id
LEFT JOIN Lokasi l ON b.lokasi_id = l.lokasi_id
WHERE b.kode_barang = ?
LIMIT 1`;
const [rowsBarang] = await db.query(sqlBarang, [kode]);
if (rowsBarang.length === 0) {
return res.status(404).json({ success: false, message: "Barang tidak ditemukan!" });
}
const barang = rowsBarang[0];
const sqlBast = `
SELECT b.bast_id, b.status_serah, b.status_terima, b.dibuat_pada,
u1.nama_lengkap AS nama_penyerah, u2.nama_lengkap AS nama_penerima
FROM BAST b
LEFT JOIN User u1 ON b.user_serah_id = u1.user_id
LEFT JOIN User u2 ON b.user_terima_id = u2.user_id
WHERE b.barang_id = ?
ORDER BY b.dibuat_pada DESC`;
const [riwayatBast] = await db.query(sqlBast, [barang.barang_id]);
res.json({
success: true,
data: {
...barang,
riwayat_bast: riwayatBast
}
});
} catch (error) {
console.error("Error Detail Barang:", error);
res.status(500).json({ success: false, error: error.message });
}
});
// API POST: Tambah Barang Baru + Auto Create Kategori & Lokasi
app.post("/api/barang", upload.single('foto_barang'), async (req, res) => {
const { kode_barang, nama_barang, nama_kategori, nama_lokasi, status_barang, stok } = req.body;
try {
if (!kode_barang || !nama_barang || !nama_kategori || !nama_lokasi) {
return res.status(400).json({ success: false, message: "Data nama, kategori, and lokasi wajib diisi lengkap, Bang!" });
}
let urlFotoBaru = "";
if (req.file) {
urlFotoBaru = `http://localhost:5000/uploads/${req.file.filename}`;
} else if (req.body.foto_barang && req.body.foto_barang.startsWith('http')) {
urlFotoBaru = req.body.foto_barang;
} else {
return res.status(400).json({ success: false, message: "File gambar foto_barang belum diupload, Bang!" });
}
// --- AUTO-CREATE KATEGORI ---
let finalKategoriId;
const [cekKategori] = await db.query("SELECT kategori_id FROM Kategori WHERE LOWER(nama_kategori) = LOWER(?)", [nama_kategori.trim()]);
if (cekKategori.length > 0) {
finalKategoriId = cekKategori[0].kategori_id;
} else {
const namaKategoriBersih = nama_kategori.trim().replace(/[^a-zA-Z0-9 ]/g, "").toUpperCase();
const kataKategori = namaKategoriBersih.split(" ").filter(k => k.length > 0);
let kodeKategoriOtomatis = "";
if (kataKategori.length >= 2) {
kodeKategoriOtomatis = (kataKategori[0].slice(0, 2) + kataKategori[1].slice(0, 1)).slice(0, 3);
} else {
kodeKategoriOtomatis = namaKategoriBersih.slice(0, 3);
}
if (kodeKategoriOtomatis.length < 3) {
kodeKategoriOtomatis = (kodeKategoriOtomatis + "XXX").slice(0, 3);
}
const [buatKategori] = await db.query(
"INSERT INTO Kategori (kode_kategori, nama_kategori) VALUES (?, ?)",
[kodeKategoriOtomatis, nama_kategori.trim()]
);
finalKategoriId = buatKategori.insertId;
}
// --- AUTO-CREATE LOKASI ---
let finalLokasiId;
const [cekLokasi] = await db.query("SELECT lokasi_id FROM Lokasi WHERE LOWER(nama_lokasi) = LOWER(?)", [nama_lokasi.trim()]);
if (cekLokasi.length > 0) {
finalLokasiId = cekLokasi[0].lokasi_id;
} else {
const namaLokasiBersih = nama_lokasi.trim().replace(/[^a-zA-Z0-9 ]/g, "").toUpperCase();
const kataLokasi = namaLokasiBersih.split(" ").filter(l => l.length > 0);
let kodeLokasiOtomatis = "";
if (kataLokasi.length >= 2) {
kodeLokasiOtomatis = (kataLokasi[0].slice(0, 2) + kataLokasi[1].slice(0, 1)).slice(0, 3);
} else {
kodeLokasiOtomatis = namaLokasiBersih.slice(0, 3);
}
if (kodeLokasiOtomatis.length < 3) {
kodeLokasiOtomatis = (kodeLokasiOtomatis + "XXX").slice(0, 3);
}
const [buatLokasi] = await db.query(
"INSERT INTO Lokasi (kode_lokasi, nama_lokasi) VALUES (?, ?)",
[kodeLokasiOtomatis, nama_lokasi.trim()]
);
finalLokasiId = buatLokasi.insertId;
}
// 5. Generate QR Code Otomatis menggunakan IP Lokal agar bisa discan HP
const qrUrl = `http://1.9.77.179:5173/detail/${encodeURIComponent(kode_barang)}`;
const qrImage = await QRCode.toDataURL(qrUrl);
// --- INSERT KE TABEL BARANG ---
const sql = `INSERT INTO Barang
(kode_barang, nama_barang, kategori_id, lokasi_id, status_barang, stok, qr_image, foto_barang)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`;
await db.query(sql, [
kode_barang,
nama_barang.trim(),
Number(finalKategoriId),
Number(finalLokasiId),
status_barang || 'Baik',
Number(stok) || 0,
qrImage,
urlFotoBaru
]);
res.json({
success: true,
message: "Mantap Bang! Data masuk beserta Kategori dan Lokasi otomatis, Foto Online, dan QR Code ke database CIFO.",
kode_barang: kode_barang
});
} catch (error) {
console.error("Eror Backend Simpan Barang:", error);
res.status(500).json({ success: false, message: "Gagal memproses data ke database!", error: error.message });
}
});
// API PUT: Edit Data Barang
app.put("/api/barang/:id", upload.single('foto_barang'), async (req, res) => {
const barangId = req.params.id;
const { nama_barang, stok, status_barang } = req.body;
try {
let sql = "UPDATE Barang SET nama_barang = ?, stok = ?, status_barang = ? WHERE barang_id = ?";
let params = [nama_barang, Number(stok), status_barang, barangId];
if (req.file) {
const urlFotoBaru = `http://localhost:5000/uploads/${req.file.filename}`;
sql = "UPDATE Barang SET nama_barang = ?, stok = ?, status_barang = ?, foto_barang = ? WHERE barang_id = ?";
params = [nama_barang, Number(stok), status_barang, urlFotoBaru, barangId];
} else if (req.body.foto_barang && req.body.foto_barang.startsWith('http')) {
sql = "UPDATE Barang SET nama_barang = ?, stok = ?, status_barang = ?, foto_barang = ? WHERE barang_id = ?";
params = [nama_barang, Number(stok), status_barang, req.body.foto_barang, barangId];
}
await db.query(sql, params);
const [rows] = await db.query("SELECT * FROM Barang WHERE barang_id = ?", [barangId]);
res.json({
success: true,
message: "Perubahan data inventaris berhasil disimpan dengan sukses!",
data: rows[0]
});
} catch (error) {
console.error("Error backend edit barang:", error);
res.status(500).json({ success: false, message: "Gagal memproses data ke database!", error: error.message });
}
});
// API DELETE: Hapus Barang
app.delete("/api/barang/:id", async (req, res) => {
const { id } = req.params;
try {
await db.query("DELETE FROM Barang WHERE barang_id = ?", [id]);
res.json({ success: true, message: "Barang berhasil dihapus dari database!" });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// GET SEMUA KATEGORI
app.get("/api/kategori", async (req, res) => {
try {
const [rows] = await db.query("SELECT * FROM Kategori");
res.json(rows);
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// CREATE Kategori
app.post("/api/kategori", async (req, res) => {
const { nama_kategori } = req.body;
if (!nama_kategori) return res.status(400).json({ success: false, message: "Nama kategori wajib" });
try {
const [result] = await db.query("INSERT INTO Kategori (nama_kategori) VALUES (?)", [nama_kategori]);
res.json({ success: true, message: "Kategori berhasil ditambah", id: result.insertId });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// UPDATE Kategori
app.put("/api/kategori/:id", async (req, res) => {
const { id } = req.params;
const { nama_kategori } = req.body;
if (!nama_kategori) return res.status(400).json({ success: false, message: "Nama kategori wajib" });
try {
await db.query("UPDATE Kategori SET nama_kategori = ? WHERE kategori_id = ?", [nama_kategori, id]);
res.json({ success: true, message: "Kategori berhasil diupdate" });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// DELETE Kategori
app.delete("/api/kategori/:id", async (req, res) => {
const { id } = req.params;
try {
await db.query("DELETE FROM Kategori WHERE kategori_id = ?", [id]);
res.json({ success: true, message: "Kategori berhasil dihapus" });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// GET SEMUA LOKASI
app.get("/api/lokasi", async (req, res) => {
try {
const [rows] = await db.query("SELECT * FROM Lokasi");
res.json({ success: true, data: rows });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// CREATE Lokasi
app.post("/api/lokasi", async (req, res) => {
const { nama_lokasi } = req.body;
if (!nama_lokasi) return res.status(400).json({ success: false, message: "Nama lokasi wajib" });
try {
const [result] = await db.query("INSERT INTO Lokasi (nama_lokasi) VALUES (?)", [nama_lokasi]);
res.json({ success: true, message: "Lokasi berhasil ditambah", id: result.insertId });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// UPDATE Lokasi
app.put("/api/lokasi/:id", async (req, res) => {
const { id } = req.params;
const { nama_lokasi } = req.body;
if (!nama_lokasi) return res.status(400).json({ success: false, message: "Nama lokasi wajib" });
try {
await db.query("UPDATE Lokasi SET nama_lokasi = ? WHERE lokasi_id = ?", [nama_lokasi, id]);
res.json({ success: true, message: "Lokasi berhasil diupdate" });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// DELETE Lokasi
app.delete("/api/lokasi/:id", async (req, res) => {
const { id } = req.params;
try {
await db.query("DELETE FROM Lokasi WHERE lokasi_id = ?", [id]);
res.json({ success: true, message: "Lokasi berhasil dihapus" });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// =======================================================
// 4. MODUL TRANSAKSI BAST RESMI (SOAL UKK PPLG)
// =======================================================
// --- SINKRONISASI 1: MENYEDIAKAN USER_SERAH_ID DAN USER_TERIMA_ID KE FRONTEND ---
app.get("/api/bast", async (req, res) => {
try {
const sql = `
SELECT b.bast_id, b.barang_id, b.user_serah_id, b.user_terima_id,
b.status_serah, b.status_terima, b.dibuat_pada,
brg.nama_barang, brg.kode_barang, brg.foto_barang,
k.nama_kategori,
l.nama_lokasi,
u1.nama_lengkap AS nama_penyerah,
u2.nama_lengkap AS nama_penerima
FROM BAST b
LEFT JOIN Barang brg ON b.barang_id = brg.barang_id
LEFT JOIN Kategori k ON brg.kategori_id = k.kategori_id
LEFT JOIN Lokasi l ON brg.lokasi_id = l.lokasi_id
LEFT JOIN User u1 ON b.user_serah_id = u1.user_id
LEFT JOIN User u2 ON b.user_terima_id = u2.user_id
ORDER BY b.bast_id DESC`;
const [rows] = await db.query(sql);
res.json({ success: true, data: rows });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// API POST: Membuat Transaksi BAST baru oleh Admin
app.post("/api/bast", async (req, res) => {
const { barang_id, user_serah_id, user_terima_id } = req.body;
if (!barang_id || !user_serah_id || !user_terima_id) {
return res.status(400).json({
success: false,
message: "Barang, User Serah, dan User Terima wajib diisi sesuai standar UKK!"
});
}
try {
const sql = `INSERT INTO BAST (barang_id, user_serah_id, user_terima_id, status_serah, status_terima, dibuat_pada)
VALUES (?, ?, ?, 'Menunggu', 'Menunggu', NOW())`;
await db.query(sql, [barang_id, user_serah_id, user_terima_id]);
res.json({ success: true, message: "Dokumen BAST berhasil dibuat oleh Admin! Menunggu Approval." });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// --- SINKRONISASI 2: DYNAMIC ROUTE APPROVAL (DAPAT UPDATE REJECTED MAUPUN APPROVED SISI PENYERAH/PENERIMA) ---
app.put("/api/bast/:id/status", async (req, res) => {
const { id } = req.params;
const { peran, status } = req.body; // peran = 'serah' / 'terima', status = 'Approved' / 'Rejected'
if (!peran || !status) {
return res.status(400).json({ success: false, message: "Parameter keputusan data tidak lengkap." });
}
try {
let sql = "";
if (peran === "serah") {
sql = "UPDATE BAST SET status_serah = ? WHERE bast_id = ?";
} else if (peran === "terima") {
sql = "UPDATE BAST SET status_terima = ? WHERE bast_id = ?";
} else {
return res.status(400).json({ success: false, message: "Peran user tidak terdefinisi secara sah!" });
}
await db.query(sql, [status, id]);
res.json({ success: true, message: `Berhasil melakukan update persetujuan sebagai pihak ${peran}!` });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
app.get("/api/bast/cetak/:id", async (req, res) => {
const { id } = req.params;
try {
const sql = `
SELECT b.bast_id, b.status_serah, b.status_terima, b.dibuat_pada,
brg.nama_barang, brg.kode_barang,
u1.nama_lengkap AS nama_penyerah,
u2.nama_lengkap AS nama_penerima
FROM BAST b
LEFT JOIN Barang brg ON b.barang_id = brg.barang_id
LEFT JOIN User u1 ON b.user_serah_id = u1.user_id
LEFT JOIN User u2 ON b.user_terima_id = u2.user_id
WHERE b.bast_id = ?`;
const [rows] = await db.query(sql, [id]);
if (rows.length === 0) {
return res.status(404).json({ success: false, message: "Data BAST tidak ditemukan, Bang!" });
}
res.json({
success: true,
message: "Data cetak dokumen berhasil disiapkan!",
data: rows[0]
});
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
app.listen(5000, () => {
console.log("Server backend berjalan di port 5000...");
});

2060
belajar_express/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,30 @@
{
"name": "belajar_express",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "module",
"dependencies": {
"@prisma/client": "^6.19.3",
"@prisma/config": "^7.7.0",
"bcrypt": "^6.0.0",
"bcryptjs": "^3.0.3",
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"lucide-react": "^1.14.0",
"multer": "^2.1.1",
"mysql2": "^3.22.2",
"qrcode": "^1.5.4"
},
"devDependencies": {
"prisma": "^6.19.3"
}
}

View File

@ -0,0 +1,10 @@
-- CreateTable
CREATE TABLE `users` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`nama` VARCHAR(191) NOT NULL,
`username` VARCHAR(191) NOT NULL,
`password` VARCHAR(191) NOT NULL,
UNIQUE INDEX `users_username_key`(`username`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "mysql"

View File

@ -0,0 +1,16 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL") // PASTIKAN BARIS INI ADA LAGI
}
model users {
id Int @id @default(autoincrement())
nama String
username String @unique
password String
role String @default("Client")
}

24
kasir_admin/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
kasir_admin/README.md Normal file
View File

@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@ -0,0 +1,22 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
},
])

13
kasir_admin/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>kasir</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4227
kasir_admin/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

40
kasir_admin/package.json Normal file
View File

@ -0,0 +1,40 @@
{
"name": "kasir",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.16.0",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.7",
"lucide-react": "^1.14.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-icons": "^5.6.0",
"react-router-dom": "^7.14.2"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react": "^6.0.1",
"autoprefixer": "^10.5.0",
"eslint": "^10.2.1",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0",
"postcss": "^8.5.12",
"tailwindcss": "^3.4.19",
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.2",
"vite": "^8.0.10"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

184
kasir_admin/src/App.css Normal file
View File

@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}

34
kasir_admin/src/App.tsx Normal file
View File

@ -0,0 +1,34 @@
import { useEffect, useState } from "react";
import { RouterProvider } from "react-router-dom";
import { router } from "./Routes";
function App() {
const [darkMode, setDarkMode] = useState(false);
useEffect(() => {
const savedTheme = localStorage.getItem("theme");
if (savedTheme === "dark") {
setDarkMode(true);
document.documentElement.classList.add("dark");
}
}, []);
useEffect(() => {
if (darkMode) {
document.documentElement.classList.add("dark");
localStorage.setItem("theme", "dark");
} else {
document.documentElement.classList.remove("dark");
localStorage.setItem("theme", "light");
}
}, [darkMode]);
return (
<div className="min-h-screen bg-white text-black dark:bg-gray-900 dark:text-white transition-all duration-300">
<RouterProvider router={router} />
</div>
);
}
export default App;

View File

@ -0,0 +1,73 @@
// src/Routes.tsx
import { createBrowserRouter } from "react-router-dom";
import DashboardLayout from "./layout/DashboardLayout";
// ROUTES
import Dashboard from "./routes/Dashboard";
import ManajemenUsers from "./routes/ManajementUsers";
import MasterData from "./routes/MasterData";
import LoginKasir from "./routes/LoginKasir";
// PAGES
import TransaksiBAST from "./pages/TransaksiBAST";
import Laporan from "./pages/Laporan";
import SettingsPage from "./pages/SettingsPage";
import DetailBarang from "./pages/DetailBarang";
export const router = createBrowserRouter([
/* LOGIN */
{
path: "/",
element: <LoginKasir />,
},
/* DETAIL BARANG SCAN QR */
{
path: "/detail/:kode",
element: <DetailBarang />,
},
/* DASHBOARD */
{
path: "/dashboard",
element: <DashboardLayout />,
children: [
/* HOME DASHBOARD */
{
index: true,
element: <Dashboard />,
},
/* MANAJEMEN USERS */
{
path: "manajemen-users",
element: <ManajemenUsers />,
},
/* MASTER DATA */
{
path: "master-data",
element: <MasterData />,
},
/* TRANSAKSI BAST */
{
path: "transaksi-bast",
element: <TransaksiBAST />,
},
/* LAPORAN */
{
path: "laporan",
element: <Laporan />,
},
/* SETTINGS */
{
path: "settings",
element: <SettingsPage />,
},
],
},
]);

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 757 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 750 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 821 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

View File

@ -0,0 +1,13 @@
export default function About() {
return (
<div className="bg-white rounded-2xl shadow p-6 mb-8">
<h2 className="text-2xl font-bold mb-3">
Tentang Sistem
</h2>
<p className="text-gray-600">
Aplikasi kasir dan peminjaman barang sekolah ini dirancang untuk mempermudah pengelolaan transaksi penjualan, pencatatan data barang, serta proses peminjaman perlengkapan sekolah seperti buku, alat tulis, dan perangkat pendukung lainnya. Dengan sistem yang modern dan terintegrasi, pengguna dapat mengelola stok barang, melakukan transaksi, memantau data peminjaman, serta melihat laporan dengan lebih cepat, akurat, dan aman. Dashboard utama memberikan akses mudah ke seluruh fitur penting sehingga operasional sekolah menjadi lebih terorganisir, efisien, dan profesional.
</p>
</div>
);
}

View File

@ -0,0 +1,61 @@
import novel from "../../assets/novel.png";
import elektronik from "../../assets/elektronik.png";
import alat from "../../assets/alat.png";
export default function Content() {
const items = [
{
name: "novel",
image: novel,
function:
"rekreatif (hiburan), didaktif (mendidik), moralitas (penanaman nilai baik-buruk), religius (ajaran agama), dan estetis (keindahan bahasa/cerita).",
},
{
name: "elektronik",
image: elektronik,
function:
"Elektronik digunakan untuk mempermudah pekerjaan manusia, memberikan hiburan, membantu komunikasi, serta meningkatkan efisiensi dan keamanan dalam kehidupan sehari-hari.",
},
{
name: "Alat Tulis",
image: alat ,
function:
"mencatat informasi, mendukung administrasi perkantoran, menunjang kegiatan belajar-mengajar, serta memfasilitasi ekspresi kreatif seperti menggambar dan merancang.",
},
];
return (
<div className="space-y-8 bg-white p-6 rounded-2xl">
<h2 className="text-3xl font-bold text-black mb-6 border-b-4 border-blue-600 pb-2">
Barang Barang
</h2>
{items.map((item, index) => (
<div
key={index}
className="bg-black text-white rounded-2xl shadow-lg p-6 flex items-center gap-8 border-l-4 border-blue-600"
>
{/* KIRI - PENJELASAN */}
<div className="flex-1">
<h3 className="text-2xl font-bold text-blue-400 mb-3">
{item.name}
</h3>
<p className="text-gray-300 leading-relaxed">
{item.function}
</p>
</div>
{/* KANAN - FOTO */}
<div className="w-72">
<img
src={item.image}
alt={item.name}
className="w-full h-56 object-cover rounded-xl shadow border-2 border-blue-500"
/>
</div>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,164 @@
import { useState, useEffect } from 'react';
import axios from 'axios';
interface DashboardStats {
permintaanHariIni: number;
menungguPersetujuan: number;
totalBarang: number;
penggunaAktif: number;
stokHampirHabis: number;
}
export default function Hero() {
// 1. Ambil data profil dari LocalStorage hasil login sebelumnya
const userName = localStorage.getItem('user_name') || 'User';
const userRole = localStorage.getItem('user_role') || 'Siswa';
// State untuk menampung angka statistik dari database
const [stats, setStats] = useState<DashboardStats>({
permintaanHariIni: 0,
menungguPersetujuan: 0,
totalBarang: 0,
penggunaAktif: 0,
stokHampirHabis: 0
});
// 2. Fungsi membuat inisial nama (Contoh: "Bu Sari" -> "BS", "Admin" -> "AD")
const dapatkanInisial = (nama: string) => {
const kata = nama.split(' ');
if (kata.length >= 2) {
return (kata[0][0] + kata[1][0]).toUpperCase();
}
return nama.substring(0, 2).toUpperCase();
};
// 3. Efek untuk mengambil data counter statistik dari backend pas halaman dibuka
useEffect(() => {
const ambilStatistikDashboard = async () => {
try {
// Silahkan buat rute /api/dashboard-stats di backend jika ingin memisahkan kueri count-nya
// Sebagai alternatif awal, kita ambil total barang & total bast dari rute yang sudah ada
const resBarang = await axios.get('http://localhost:5000/api/barang');
const resBast = await axios.get('http://localhost:5000/api/bast');
const resUser = await axios.get('http://localhost:5000/api/users');
// Hitung total data berdasarkan respon database
const totalBarang = resBarang.data.success ? resBarang.data.data.length : 0;
const listBast = resBast.data.success ? resBast.data.data : [];
const totalUser = resUser.data.success ? resUser.data.data.length : 0;
// Filter data untuk counter spesifik dokumen BAST
const menunggu = listBast.filter((b: any) => b.status_serah === 'Menunggu' || b.status_terima === 'Menunggu').length;
// Simulasi hitung permintaan hari ini dari tanggal dibuat_pada (UKK basic)
const hariIniFormat = new Date().toISOString().split('T')[0];
const hariIni = listBast.filter((b: any) => b.dibuat_pada && b.dibuat_pada.startsWith(hariIniFormat)).length;
// Hitung barang dengan stok kritis (di bawah 5 buah)
const kritis = resBarang.data.success
? resBarang.data.data.filter((brg: any) => brg.stok <= 3).length
: 0;
setStats({
permintaanHariIni: hariIni,
menungguPersetujuan: menunggu,
totalBarang: totalBarang,
penggunaAktif: totalUser,
stokHampirHabis: kritis
});
} catch (error) {
console.error("Gagal memuat statistik dashboard:", error);
}
};
ambilStatistikDashboard();
}, []);
return (
<div className="bg-blue-50 rounded-3xl p-8 shadow-sm border border-blue-100 mb-8">
{/* HEADER */}
<div className="flex justify-between items-start mb-8">
<div>
<p className="text-gray-500 text-lg">
Selamat datang kembali 👋
</p>
<h1 className="text-5xl font-extrabold text-blue-700 mt-2">
Dashboard
</h1>
</div>
{/* PROFILE */}
<div className="flex items-center gap-3 bg-white rounded-full px-4 py-2 shadow border">
<div className="w-12 h-12 bg-blue-500 rounded-full flex items-center justify-center text-white font-bold">
{dapatkanInisial(userName)}
</div>
<div>
<h3 className="font-semibold text-black">
{userName}
</h3>
<p className="text-gray-500 text-sm capitalize">
{userRole}
</p>
</div>
</div>
</div>
{/* STAT CARDS */}
<div className="grid grid-cols-4 gap-6">
{/* CARD 1 */}
<div className="bg-white rounded-2xl p-6 shadow border">
<p className="text-gray-500 mb-2">
Permintaan Hari Ini
</p>
<h2 className="text-4xl font-bold text-blue-700">
{stats.permintaanHariIni}
</h2>
<p className="text-green-600 mt-2 text-sm">
Dokumen baru masuk
</p>
</div>
{/* CARD 2 */}
<div className="bg-white rounded-2xl p-6 shadow border">
<p className="text-gray-500 mb-2">
Menunggu Persetujuan
</p>
<h2 className="text-4xl font-bold text-blue-700">
{stats.menungguPersetujuan}
</h2>
<p className="text-yellow-600 mt-2 text-sm">
Perlu tindakan segera
</p>
</div>
{/* CARD 3 */}
<div className="bg-white rounded-2xl p-6 shadow border">
<p className="text-gray-500 mb-2">
Total Ragam Barang
</p>
<h2 className="text-4xl font-bold text-blue-700">
{stats.totalBarang}
</h2>
<p className="text-red-500 mt-2 text-sm">
{stats.stokHampirHabis} stok hampir habis
</p>
</div>
{/* CARD 4 */}
<div className="bg-white rounded-2xl p-6 shadow border">
<p className="text-gray-500 mb-2">
Total Terdaftar
</p>
<h2 className="text-4xl font-bold text-blue-700">
{stats.penggunaAktif}
</h2>
<p className="text-green-600 mt-2 text-sm">
Siswa & petugas aktif
</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,45 @@
import type { LucideIcon } from 'lucide-react';
interface Props {
label: string;
name: string;
type: string;
icon: LucideIcon;
placeholder: string;
showEye?: boolean;
toggleEye?: () => void;
eyeIcon?: LucideIcon;
// --- TAMBAHKAN DUA INI ---
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
export default function LoginInput({
label, name, type, icon: Icon, placeholder, showEye, toggleEye, eyeIcon: EyeIcon,
value, onChange // <--- Terima props di sini
}: Props) {
return (
<div className="mb-4">
<label className="block text-gray-400 text-sm mb-1">{label}:</label>
<div className="relative group">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center text-gray-500 group-focus-within:text-[#4A90E2]">
<Icon size={20} />
</div>
<input
name={name}
type={type}
placeholder={placeholder}
// --- PASANG DI SINI ---
value={value}
onChange={onChange}
className="w-full bg-[#3D444D] border border-gray-600 rounded-lg pl-11 pr-10 py-3 text-white focus:outline-none focus:border-[#4A90E2] transition-all placeholder:text-gray-500"
/>
{showEye && toggleEye && EyeIcon && (
<button type="button" onClick={toggleEye} className="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-500 hover:text-white">
<EyeIcon size={20} />
</button>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,10 @@
export default function MyButton({ text, type = "button" }: { text: string; type?: "submit" | "button" }) {
return (
<button
type={type}
className="w-full bg-[#4A90E2] hover:bg-[#357ABD] text-white font-semibold py-3 rounded-lg transition-all active:scale-95 shadow-lg mt-4"
>
{text}
</button>
);
}

View File

@ -0,0 +1,31 @@
// src/components/manajemenUsers/UsersFilter.tsx
import type { Dispatch, SetStateAction } from "react";
// IMPORT ICON SEARCH MODERN DARI LUCIDE REACT
import { Search } from "lucide-react";
interface FilterProps {
kataKunci: string;
setKataKunci: Dispatch<SetStateAction<string>>;
}
export default function UsersFilter({ kataKunci, setKataKunci }: FilterProps) {
return (
<div className="bg-white rounded-2xl p-4 shadow-sm border border-blue-100 mb-6 flex items-center justify-between gap-4">
<div className="flex items-center gap-3 bg-gray-50 rounded-xl px-4 py-2.5 border border-gray-200 w-full max-w-md">
{/* ICON SEARCH MODERN SEBAGAI PENGGANTI EMOJI KACA PEMBESAR */}
<Search className="text-gray-400" size={18} />
<input
type="text"
value={kataKunci}
onChange={(e) => setKataKunci(e.target.value)}
placeholder="Cari pengguna berdasarkan nama, username, atau lembaga..."
className="bg-transparent outline-none w-full text-gray-700 placeholder-gray-400 text-sm"
/>
</div>
<div className="text-xs text-gray-400 font-medium hidden sm:block">
Ketik kata kunci untuk menyaring baris tabel otomatis
</div>
</div>
);
}

View File

@ -0,0 +1,173 @@
// src/components/manajemenUsers/UserHero.tsx
import { useState } from "react";
import axios from "axios";
// IMPORT ICON REACT RESMI BIAR MODERN, BANG!
import { UserPlus, UserCheck, Loader2 } from "lucide-react";
interface HeroProps {
onUserDitambahkan: () => void;
}
export default function UsersHero({ onUserDitambahkan }: HeroProps) {
const [isModalOpen, setIsModalOpen] = useState(false);
const [formData, setFormData] = useState({
nama_lengkap: "",
username: "",
password: "",
role: "User",
email: "",
lembaga: "",
});
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const respon = await axios.post("http://localhost:5000/api/register", formData);
if (respon.data.success) {
alert(respon.data.message);
setIsModalOpen(false);
// Reset Form
setFormData({ nama_lengkap: "", username: "", password: "", role: "User", email: "", lembaga: "" });
// Refresh tabel di halaman utama otomatis
onUserDitambahkan();
}
} catch (error: any) {
console.error(error);
alert(error.response?.data?.message || "Gagal menambah user baru!");
} finally {
setLoading(false);
}
};
return (
<div className="bg-gradient-to-r from-blue-600 to-blue-700 rounded-3xl p-8 shadow-lg text-white mb-8 flex justify-between items-center">
<div>
<h1 className="text-4xl font-extrabold tracking-tight">Manajemen Pengguna</h1>
<p className="text-blue-100 mt-2 text-lg">Kelola hak akses data, penyerah, penerima, dan petugas BAST CIFO.</p>
</div>
{/* TOMBOL DENGAN ICON REACT USERPLUS */}
<button
onClick={() => setIsModalOpen(true)}
className="bg-white text-blue-700 font-bold px-6 py-3 rounded-2xl shadow hover:bg-blue-50 transition-all duration-200 flex items-center gap-2 transform hover:scale-105"
>
<UserPlus size={18} />
Tambah User Baru
</button>
{/* MODAL POP UP FORM TAMBAH USER */}
{isModalOpen && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex justify-center items-center z-50 p-4">
<div className="bg-white rounded-3xl p-8 max-w-md w-full shadow-2xl border border-gray-100 text-gray-800 animate-in fade-in zoom-in-95 duration-200">
{/* HEAD MODAL DENGAN ICON REACT USERCHECK */}
<h2 className="text-2xl font-bold text-gray-900 mb-6 flex items-center gap-2 text-blue-700">
<UserCheck size={24} />
Registrasi Pengguna Baru
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1">Nama Lengkap *</label>
<input
type="text"
required
value={formData.nama_lengkap}
onChange={(e) => setFormData({ ...formData, nama_lengkap: e.target.value })}
className="w-full px-4 py-2.5 rounded-xl border border-gray-300 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition"
placeholder="Contoh: Bu Sari"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1">Username *</label>
<input
type="text"
required
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
className="w-full px-4 py-2.5 rounded-xl border border-gray-300 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition"
placeholder="sari123"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1">Password *</label>
<input
type="password"
required
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="w-full px-4 py-2.5 rounded-xl border border-gray-300 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition"
placeholder="******"
/>
</div>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1">Role / Hak Akses</label>
<select
value={formData.role}
onChange={(e) => setFormData({ ...formData, role: e.target.value })}
className="w-full px-4 py-2.5 rounded-xl border border-gray-300 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition bg-white"
>
<option value="Admin">Admin</option>
<option value="User">User</option>
</select>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1">Email (Opsional)</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full px-4 py-2.5 rounded-xl border border-gray-300 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition"
placeholder="sari@cifo.co.id"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1">Instansi / Lembaga (Opsional)</label>
<input
type="text"
value={formData.lembaga}
onChange={(e) => setFormData({ ...formData, lembaga: e.target.value })}
className="w-full px-4 py-2.5 rounded-xl border border-gray-300 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition"
placeholder="SMKN 4 Bandung / PT CIFO"
/>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-gray-100 mt-6">
<button
type="button"
onClick={() => setIsModalOpen(false)}
className="px-5 py-2.5 rounded-xl border border-gray-300 font-semibold text-gray-600 hover:bg-gray-50 transition"
>
Batal
</button>
{/* BUTTON SUBMIT DENGAN SPINNER LOADER2 SAAT SAVE DATA */}
<button
type="submit"
disabled={loading}
className="px-5 py-2.5 rounded-xl bg-blue-600 font-semibold text-white hover:bg-blue-700 shadow transition disabled:opacity-50 flex items-center gap-2"
>
{loading ? (
<>
<Loader2 className="animate-spin" size={16} />
Menyimpan...
</>
) : (
"Simpan Pengguna"
)}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,228 @@
// src/components/manajemenUsers/UsersTable.tsx
import { useState } from "react";
import axios from "axios";
// IMPORT ICON REACT MODERN UNTUK TABEL & MODAL EDIT
import { Pencil, Trash2, UserCheck, Loader2 } from "lucide-react";
import type { UserData } from "../../routes/ManajementUsers";
interface TableProps {
dataUsers: UserData[];
onHapusUser: (user_id: number) => void;
onUserDiedit: () => void;
}
export default function UsersTable({ dataUsers, onHapusUser, onUserDiedit }: TableProps) {
const [isEditOpen, setIsEditOpen] = useState(false);
const [targetId, setTargetId] = useState<number | null>(null);
const [editFormData, setEditFormData] = useState({
nama_lengkap: "",
username: "",
role: "User",
email: "",
lembaga: "",
});
const [loading, setLoading] = useState(false);
// Buka Modal Edit dan set data default-nya ke form
const bukaModalEdit = (user: UserData) => {
setTargetId(user.user_id);
setEditFormData({
nama_lengkap: user.nama_lengkap,
username: user.username,
role: user.role,
email: user.email || "",
lembaga: user.lembaga || "",
});
setIsEditOpen(true);
};
const handleUpdate = async (e: React.FormEvent) => {
e.preventDefault();
if (!targetId) return;
setLoading(true);
try {
const respon = await axios.put(`http://localhost:5000/api/users/${targetId}`, editFormData);
if (respon.data.success) {
alert(respon.data.message);
setIsEditOpen(false);
onUserDiedit(); // Memicu refresh state di rute induk
}
} catch (error: any) {
console.error(error);
alert(error.response?.data?.message || "Gagal memperbarui data user!");
} finally {
setLoading(false);
}
};
return (
<div className="bg-white rounded-3xl shadow-sm border border-blue-100 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-blue-50 border-b border-blue-100 text-blue-800 font-bold text-sm">
<th className="p-4 pl-6">ID Pengguna</th>
<th className="p-4">Nama Lengkap</th>
<th className="p-4">Username</th>
<th className="p-4">Hak Akses / Role</th>
<th className="p-4">Email</th>
<th className="p-4">Lembaga / Instansi</th>
<th className="p-4 text-center pr-6">Aksi Operasional</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 text-sm text-gray-700">
{dataUsers.length === 0 ? (
<tr>
<td colSpan={7} className="p-8 text-center text-gray-400 font-medium">
Belum ada data pengguna terdaftar atau kata kunci salah, Bang! 📭
</td>
</tr>
) : (
dataUsers.map((user) => (
<tr key={user.user_id} className="hover:bg-blue-50/40 transition">
<td className="p-4 pl-6 font-semibold text-gray-500">#{user.user_id}</td>
<td className="p-4 font-bold text-gray-900">{user.nama_lengkap}</td>
<td className="p-4">
<span className="font-mono text-gray-600 bg-gray-50/50 rounded-lg px-2 py-0.5 inline-block">
{user.username}
</span>
</td>
<td className="p-4">
<span
className={`px-3 py-1 rounded-full text-xs font-bold capitalize ${
user.role === "Admin"
? "bg-purple-100 text-purple-700"
: user.role === "Petugas"
? "bg-blue-100 text-blue-700"
: "bg-green-100 text-green-700"
}`}
>
{user.role}
</span>
</td>
<td className="p-4 text-gray-500">{user.email || "-"}</td>
<td className="p-4 text-gray-600 font-medium">{user.lembaga || "-"}</td>
<td className="p-4 pr-6 text-center">
<div className="flex justify-center items-center gap-2">
{/* TOMBOL EDIT DENGAN ICON PENCIL */}
<button
onClick={() => bukaModalEdit(user)}
className="bg-amber-50 hover:bg-amber-100 text-amber-700 border border-amber-200 px-3 py-1.5 rounded-xl font-semibold transition flex items-center gap-1.5 text-xs"
>
<Pencil size={14} />
Edit
</button>
{/* TOMBOL HAPUS DENGAN ICON TRASH2 */}
<button
onClick={() => onHapusUser(user.user_id)}
className="bg-red-50 hover:bg-red-100 text-red-600 border border-red-200 px-3 py-1.5 rounded-xl font-semibold transition flex items-center gap-1.5 text-xs"
>
<Trash2 size={14} />
Hapus
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* MODAL POP UP FORM EDIT USER */}
{isEditOpen && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex justify-center items-center z-50 p-4">
<div className="bg-white rounded-3xl p-8 max-w-md w-full shadow-2xl border border-gray-100 text-gray-800 animate-in fade-in zoom-in-95 duration-200">
{/* HEAD MODAL EDIT DENGAN ICON USERCHECK WARNA AMBER */}
<h2 className="text-2xl font-bold text-gray-900 mb-6 flex items-center gap-2 text-amber-600">
<UserCheck size={24} />
Sunting Data Pengguna
</h2>
<form onSubmit={handleUpdate} className="space-y-4">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1">Nama Lengkap *</label>
<input
type="text"
required
value={editFormData.nama_lengkap}
onChange={(e) => setEditFormData({ ...editFormData, nama_lengkap: e.target.value })}
className="w-full px-4 py-2.5 rounded-xl border border-gray-300 focus:ring-2 focus:ring-amber-500 focus:border-amber-500 outline-none transition"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1">Username *</label>
<input
type="text"
required
value={editFormData.username}
onChange={(e) => setEditFormData({ ...editFormData, username: e.target.value })}
className="w-full px-4 py-2.5 rounded-xl border border-gray-300 focus:ring-2 focus:ring-amber-500 focus:border-amber-500 outline-none transition bg-gray-50"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1">Role / Hak Akses</label>
<select
value={editFormData.role}
onChange={(e) => setEditFormData({ ...editFormData, role: e.target.value })}
className="w-full px-4 py-2.5 rounded-xl border border-gray-300 focus:ring-2 focus:ring-amber-500 focus:border-amber-500 outline-none transition bg-white"
>
<option value="Admin">Admin</option>
<option value="User">User</option>
</select>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1">Email</label>
<input
type="email"
value={editFormData.email}
onChange={(e) => setEditFormData({ ...editFormData, email: e.target.value })}
className="w-full px-4 py-2.5 rounded-xl border border-gray-300 focus:ring-2 focus:ring-amber-500 focus:border-amber-500 outline-none transition"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1">Instansi / Lembaga</label>
<input
type="text"
value={editFormData.lembaga}
onChange={(e) => setEditFormData({ ...editFormData, lembaga: e.target.value })}
className="w-full px-4 py-2.5 rounded-xl border border-gray-300 focus:ring-2 focus:ring-amber-500 focus:border-amber-500 outline-none transition"
/>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-gray-100 mt-6">
<button
type="button"
onClick={() => setIsEditOpen(false)}
className="px-5 py-2.5 rounded-xl border border-gray-300 font-semibold text-gray-600 hover:bg-gray-50 transition"
>
Batal
</button>
{/* SUBMIT BUTTON EDIT DENGAN ICON SPINNER MUTAR */}
<button
type="submit"
disabled={loading}
className="px-5 py-2.5 rounded-xl bg-amber-600 font-semibold text-white hover:bg-amber-700 shadow transition disabled:opacity-50 flex items-center gap-2"
>
{loading ? (
<>
<Loader2 className="animate-spin" size={16} />
Memproses...
</>
) : (
"Perbarui Pengguna"
)}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,31 @@
// src/components/masterData/BarangFilter.tsx
type Props = {
selectedKategori: string;
setSelectedKategori: (kategori: string) => void;
kategoriList: string[];
};
export default function BarangFilter({
selectedKategori,
setSelectedKategori,
kategoriList,
}: Props) {
return (
<div className="flex gap-2 overflow-x-auto pb-2 pt-2 mb-8">
{kategoriList.map((kategori) => (
<button
key={kategori}
onClick={() => setSelectedKategori(kategori)}
className={`px-5 py-2.5 rounded-xl font-semibold transition text-sm whitespace-nowrap ${
selectedKategori === kategori
? "bg-blue-600 text-white shadow-sm"
: "bg-white text-gray-600 hover:bg-gray-50 border border-gray-200"
}`}
>
{kategori}
</button>
))}
</div>
);
}

View File

@ -0,0 +1,13 @@
// src/components/masterData/BarangHero.tsx
export default function BarangHero() {
return (
<div className="bg-gradient-to-r from-black via-blue-900 to-blue-600 rounded-3xl p-8 text-white w-full">
<h1 className="text-4xl font-extrabold tracking-tight mb-2">
Kelola Barang Sekolah
</h1>
<p className="text-blue-100 text-sm md:text-base">
Pantau sirkulasi stok, kodifikasi QR-Code otomatis, kondisi fisik, dan lokasi penempatan aset inventaris CIFO.
</p>
</div>
);
}

View File

@ -0,0 +1,178 @@
// src/components/EditBarangModal.tsx
import React, { useState, useEffect } from "react";
import axios from "axios";
import { Save, X } from "lucide-react";
interface EditBarangModalProps {
itemSelected: any; // Data barang lama yang diklik untuk diedit
onClose: () => void;
onRefresh: () => void;
}
export default function EditBarangModal({ itemSelected, onClose, onRefresh }: EditBarangModalProps) {
// STATE FORM INPUT EDIT
const [namaBarang, setNamaBarang] = useState("");
const [kategoriInput, setKategoriInput] = useState("");
const [lokasiInput, setLokasiInput] = useState("");
const [kondisiFisik, setKondisiFisik] = useState("");
const [stokInput, setStokInput] = useState("");
const [loading, setLoading] = useState(false);
// POPULASI DATA AWAL BARANG SAAT MODAL DIBUKA
useEffect(() => {
if (itemSelected) {
setNamaBarang(itemSelected.nama_barang || "");
setKondisiFisik(itemSelected.status_barang || "");
setStokInput(itemSelected.stok?.toString() || "0");
// Reverse Mapping (ID ke Text Dropdown jika backend mengirimkan nama relasinya)
setKategoriInput(itemSelected.nama_kategori || "");
setLokasiInput(itemSelected.nama_lokasi || "");
}
}, [itemSelected]);
// HANDLER UPDATE KE DATABASE
const handleUpdateDB = async (e: React.FormEvent) => {
e.preventDefault();
// MAPPING TEXT KE ID INTEGER (Sama persis agar foreign key valid)
const kategoriMapping: { [key: string]: number } = {
"Elektronik": 1, "Alat Tulis": 7, "Kebersihan": 14, "Mebel dan Furnitur": 35,
"Perangkat Jaringan": 42, "Peralatan Operasional": 49, "Peralatan Bengkel": 56, "Buku dan Modul": 63
};
const lokasiMapping: { [key: string]: number } = {
"Gudang Pusat": 1, "Tata Usaha (TU)": 2, "Perpustakaan": 3, "Lab PPLG (RPL)": 289, "Lab TKJ": 296
};
// PAYLOAD DATA BARU
const dataUpdate = {
kode_barang: itemSelected.kode_barang, // Tetap gunakan kode barang lama
nama_barang: namaBarang,
kategori_id: kategoriMapping[kategoriInput] || itemSelected.kategori_id,
lokasi_id: lokasiMapping[lokasiInput] || itemSelected.lokasi_id,
status_barang: kondisiFisik,
stok: parseInt(stokInput) || 0
};
setLoading(false);
try {
setLoading(true);
// Menggunakan itemSelected.barang_id sebagai PRIMARY KEY (Sesuai Gambar 6)
const res = await axios.put(`http://localhost:5000/api/barang/${itemSelected.barang_id}`, dataUpdate);
if (res.data.success || res.status === 200) {
alert("Perubahan Inventaris Berhasil Diperbarui! ✏️");
onRefresh();
onClose();
}
} catch (error: any) {
console.error("Eror Simpan Perubahan DB:", error);
alert(error.response?.data?.message || "Gagal menyimpan perubahan ke DB! Periksa log terminal backend.");
} finally {
setLoading(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-3xl shadow-2xl max-w-lg w-full p-6 text-black dark:text-white relative">
<button onClick={onClose} className="absolute top-4 right-4 text-gray-500 hover:text-gray-700">
<X size={24} />
</button>
<h3 className="text-xl font-bold mb-4">Edit Data Barang: {itemSelected?.kode_barang}</h3>
<form onSubmit={handleUpdateDB} className="space-y-4">
<div>
<label className="block text-sm font-semibold mb-1">Nama Barang</label>
<input
type="text"
value={namaBarang}
onChange={(e) => setNamaBarang(e.target.value)}
className="w-full p-3 rounded-xl border border-gray-300 dark:border-gray-600 dark:bg-gray-700 outline-none"
/>
</div>
<div>
<label className="block text-sm font-semibold mb-1">Kategori</label>
<select
value={kategoriInput}
onChange={(e) => setKategoriInput(e.target.value)}
className="w-full p-3 rounded-xl border border-gray-300 dark:border-gray-600 dark:bg-gray-700 outline-none"
>
<option value="Elektronik">Elektronik</option>
<option value="Alat Tulis">Alat Tulis</option>
<option value="Kebersihan">Kebersihan</option>
<option value="Mebel dan Furnitur">Mebel dan Furnitur</option>
<option value="Perangkat Jaringan">Perangkat Jaringan</option>
<option value="Peralatan Operasional">Peralatan Operasional</option>
<option value="Peralatan Bengkel">Peralatan Bengkel</option>
<option value="Buku dan Modul">Buku dan Modul</option>
</select>
</div>
<div>
<label className="block text-sm font-semibold mb-1">Lokasi Utama</label>
<select
value={lokasiInput}
onChange={(e) => setLokasiInput(e.target.value)}
className="w-full p-3 rounded-xl border border-gray-300 dark:border-gray-600 dark:bg-gray-700 outline-none"
>
<option value="Gudang Pusat">Gudang Pusat</option>
<option value="Tata Usaha (TU)">Tata Usaha (TU)</option>
<option value="Perpustakaan">Perpustakaan</option>
<option value="Lab PPLG (RPL)">Lab PPLG (RPL)</option>
<option value="Lab TKJ">Lab TKJ</option>
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold mb-1">Kondisi Fisik</label>
<select
value={kondisiFisik}
onChange={(e) => setKondisiFisik(e.target.value)}
className="w-full p-3 rounded-xl border border-gray-300 dark:border-gray-600 dark:bg-gray-700 outline-none"
>
<option value="Baik">Baik</option>
<option value="Rusak Ringan">Rusak Ringan</option>
<option value="Rusak Berat">Rusak Berat</option>
<option value="Hilang">Hilang</option>
</select>
</div>
<div>
<label className="block text-sm font-semibold mb-1">Stok</label>
<input
type="number"
value={stokInput}
onChange={(e) => setStokInput(e.target.value)}
className="w-full p-3 rounded-xl border border-gray-300 dark:border-gray-600 dark:bg-gray-700 outline-none"
/>
</div>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="px-5 py-2.5 bg-gray-200 dark:bg-gray-700 rounded-xl font-semibold"
>
Batal
</button>
<button
type="submit"
disabled={loading}
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white px-5 py-2.5 rounded-xl font-semibold shadow disabled:opacity-50"
>
<Save size={18} />
{loading ? "Menyimpan..." : "Simpan Perubahan"}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,183 @@
// src/components/masterData/KategoriLokasiManagement.tsx
import { useState, useEffect } from "react";
import axios from "axios";
import { Plus, Pencil, Trash2, Loader2 } from "lucide-react";
interface Kategori {
kategori_id: number;
nama_kategori: string;
}
interface Lokasi {
lokasi_id: number;
nama_lokasi: string;
}
export default function KategoriLokasiManagement() {
const [activeTab, setActiveTab] = useState<"kategori" | "lokasi">("kategori");
const [kategoriList, setKategoriList] = useState<Kategori[]>([]);
const [lokasiList, setLokasiList] = useState<Lokasi[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [newName, setNewName] = useState<string>("");
const [editingId, setEditingId] = useState<number | null>(null);
const [showModal, setShowModal] = useState<boolean>(false);
const apiBase = `http://${window.location.hostname}:5000`;
const fetchData = async () => {
setLoading(true);
try {
if (activeTab === "kategori") {
const res = await axios.get(`${apiBase}/api/kategori`);
if (res.data.success) setKategoriList(res.data.data);
else setKategoriList(res.data);
} else {
const res = await axios.get(`${apiBase}/api/lokasi`);
if (res.data.success) setLokasiList(res.data.data);
else setLokasiList(res.data);
}
} catch (e) {
console.error("Gagal ambil data", e);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, [activeTab]);
const handleAdd = async () => {
if (!newName.trim()) return;
try {
const endpoint = activeTab === "kategori" ? "/api/kategori" : "/api/lokasi";
await axios.post(`${apiBase}${endpoint}`, {
...(activeTab === "kategori" ? { nama_kategori: newName } : { nama_lokasi: newName })
});
setNewName("");
fetchData();
} catch (e) {
console.error("Gagal tambah", e);
}
};
const handleUpdate = async (id: number) => {
if (!newName.trim()) return;
try {
const endpoint = activeTab === "kategori" ? `/api/kategori/${id}` : `/api/lokasi/${id}`;
await axios.put(`${apiBase}${endpoint}`, {
...(activeTab === "kategori" ? { nama_kategori: newName } : { nama_lokasi: newName })
});
setEditingId(null);
setNewName("");
fetchData();
} catch (e) {
console.error("Gagal update", e);
}
};
const handleDelete = async (id: number) => {
if (!confirm("Yakin hapus data ini?")) return;
try {
const endpoint = activeTab === "kategori" ? `/api/kategori/${id}` : `/api/lokasi/${id}`;
await axios.delete(`${apiBase}${endpoint}`);
fetchData();
} catch (e) {
console.error("Gagal hapus", e);
}
};
const renderList = () => {
const list = activeTab === "kategori" ? kategoriList : lokasiList;
const nameKey = activeTab === "kategori" ? "nama_kategori" : "nama_lokasi";
const idKey = activeTab === "kategori" ? "kategori_id" : "lokasi_id";
return (
<div className="overflow-x-auto">
<table className="min-w-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl">
<thead className="bg-gray-100 dark:bg-gray-700">
<tr>
<th className="px-4 py-2 text-left font-medium">ID</th>
<th className="px-4 py-2 text-left font-medium">Nama</th>
<th className="px-4 py-2 text-center font-medium">Aksi</th>
</tr>
</thead>
<tbody>
{list.map((item: any) => (
<tr key={item[idKey]} className="border-t border-gray-200 dark:border-gray-600">
<td className="px-4 py-2">{item[idKey]}</td>
<td className="px-4 py-2">{item[nameKey]}</td>
<td className="px-4 py-2 flex justify-center gap-2">
<button
onClick={() => { setEditingId(item[idKey]); setNewName(item[nameKey]); }}
className="text-blue-600 hover:text-blue-800">
<Pencil size={16} />
</button>
<button
onClick={() => handleDelete(item[idKey])}
className="text-red-600 hover:text-red-800">
<Trash2 size={16} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
};
return (
<div className="mt-8 p-6 bg-gray-50 dark:bg-gray-900 rounded-2xl shadow-md">
{/* Tab selector */}
<div className="flex gap-4 mb-4">
<button
onClick={() => setActiveTab("kategori")}
className={`px-4 py-2 rounded ${activeTab === "kategori" ? "bg-blue-600 text-white" : "bg-white text-gray-700"}`}
>
Kategori
</button>
<button
onClick={() => setActiveTab("lokasi")}
className={`px-4 py-2 rounded ${activeTab === "lokasi" ? "bg-blue-600 text-white" : "bg-white text-gray-700"}`}
>
Lokasi
</button>
</div>
{/* Add / Edit form */}
<div className="flex items-center gap-2 mb-4">
<input
type="text"
placeholder={activeTab === "kategori" ? "Nama Kategori" : "Nama Lokasi"}
value={newName}
onChange={(e) => setNewName(e.target.value)}
className="flex-1 px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{editingId !== null ? (
<button
onClick={() => handleUpdate(editingId)}
className="flex items-center gap-1 px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
>
<Pencil size={14} /> Update
</button>
) : (
<button
onClick={handleAdd}
className="flex items-center gap-1 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
<Plus size={14} /> Tambah
</button>
)}
</div>
{/* List */}
{loading ? (
<div className="flex justify-center py-6">
<Loader2 className="animate-spin text-blue-600" size={32} />
</div>
) : (
renderList()
)}
</div>
);
}

View File

@ -0,0 +1,310 @@
// src/components/masterData/TambahBarang.tsx
import React, { useState, useEffect } from "react";
import axios from "axios";
import { Image, Archive, CheckCircle, Loader2 } from "lucide-react";
interface TambahBarangProps {
onRefresh?: () => void;
}
export default function TambahBarang({ onRefresh }: TambahBarangProps) {
// STATE INPUT FORM
const [kodeBarang, setKodeBarang] = useState("");
const [namaBarang, setNamaBarang] = useState("");
const [kategoriSelected, setKategoriSelected] = useState("");
const [lokasiSelected, setLokasiSelected] = useState("");
const [kondisiFisik, setKondisiFisik] = useState("");
const [stokAwal, setStokAwal] = useState("");
const [fotoBarang, setFotoBarang] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [tipeUpload, setTipeUpload] = useState<"file" | "url">("file");
const [fotoUrl, setFotoUrl] = useState("");
const [loading, setLoading] = useState(false);
// STATE UNTUK MENAMPUNG LIST DINAMIS DARI DATABASE
const [dbKategori, setDbKategori] = useState<string[]>([]);
const [dbLokasi, setDbLokasi] = useState<string[]>([]);
// 1. AMBIL DAFTAR KATEGORI & LOKASI LANGSUNG DARI DB SAAT MOUNTING
useEffect(() => {
const ambilDataPendukung = async () => {
try {
// Silakan sesuaikan endpoint ini dengan rute API backend Abang nantinya
const [resKategori, resLokasi] = await Promise.all([
axios.get("http://localhost:5000/api/kategori"),
axios.get("http://localhost:5000/api/lokasi")
]);
if (Array.isArray(resKategori.data)) setDbKategori(resKategori.data);
if (Array.isArray(resLokasi.data)) setDbLokasi(resLokasi.data);
} catch (err) {
console.error("Gagal memuat daftar kategori/lokasi dari server:", err);
// Fallback data default bawaan Abang jika backend belum siap/gagal diakses
setDbKategori(["Elektronik", "Alat Tulis", "Kebersihan", "Mebel dan Furnitur", "Perangkat Jaringan", "Peralatan Operasional", "Peralatan Bengkel", "Buku dan Modul"]);
setDbLokasi(["Gudang Pusat", "Lab PPLG", "Lab TKJ", "X PPLG", "XI PPLG", "XII PPLG", "X TKJ", "XI TKJ", "XII TKJ"]);
}
};
ambilDataPendukung();
}, []);
// 2. LOGIKA GENERATE KODE OTOMATIS BERDASARKAN INPUT USER
useEffect(() => {
if (kategoriSelected.trim() && lokasiSelected.trim()) {
// Fungsi helper mengambil 3 huruf inisial kapital secara dinamis
const getInisial = (teks: string) => {
const bersih = teks.replace(/[^a-zA-Z0-9 ]/g, "").toUpperCase();
const kata = bersih.split(" ").filter(k => k.length > 0);
if (kata.length >= 2) {
return (kata[0].slice(0, 2) + kata[1].slice(0, 1)).slice(0, 3);
}
return bersih.slice(0, 3);
};
const kat = getInisial(kategoriSelected);
const lok = getInisial(lokasiSelected);
const nomorUrut = String(Math.floor(100 + Math.random() * 900));
setKodeBarang(`${kat}/${lok}/${nomorUrut}`);
} else {
setKodeBarang("");
}
}, [kategoriSelected, lokasiSelected]);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0];
setFotoBarang(file);
setPreviewUrl(URL.createObjectURL(file));
}
};
const handleSimpanKeDB = async (e: React.FormEvent) => {
e.preventDefault();
if (!kodeBarang) { alert("Pilih/ketik Kategori dan Lokasi dulu agar kode barang tercipta otomatis, Bang!"); return; }
if (!namaBarang.trim()) { alert("Nama Barang belum diisi, Bang!"); return; }
if (!kondisiFisik) { alert("Silakan pilih Kondisi Fisik dulu, Bang!"); return; }
if (!stokAwal) { alert("Stok awal jangan dikosongkan, Bang!"); return; }
if (tipeUpload === "file" && !fotoBarang) { alert("Foto barang wajib dipilih ya, Bang!"); return; }
if (tipeUpload === "url" && !fotoUrl.trim()) { alert("Link foto wajib diisi ya, Bang!"); return; }
const formData = new FormData();
formData.append("kode_barang", kodeBarang);
formData.append("nama_barang", namaBarang.trim());
formData.append("nama_kategori", kategoriSelected.trim());
formData.append("nama_lokasi", lokasiSelected.trim());
formData.append("status_barang", kondisiFisik);
formData.append("stok", stokAwal);
if (tipeUpload === "file" && fotoBarang) {
formData.append("foto_barang", fotoBarang);
} else if (tipeUpload === "url" && fotoUrl) {
formData.append("foto_barang", fotoUrl);
}
try {
setLoading(true);
const res = await axios.post("http://localhost:5000/api/barang", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
if (res.data.success || res.status === 200 || res.status === 201) {
alert(`Sukses! Barang terdaftar dengan Kode Otomatis: ${kodeBarang}`);
setNamaBarang("");
setKategoriSelected("");
setLokasiSelected("");
setKondisiFisik("");
setStokAwal("");
setFotoBarang(null);
setPreviewUrl(null);
setFotoUrl("");
if (onRefresh) onRefresh();
}
} catch (err: any) {
console.error(err);
alert((err.response?.data?.message || "Gagal menyimpan barang!") + " \n\nDetail Error: " + (err.response?.data?.error || "Tidak ada detail"));
} finally {
setLoading(false);
}
};
return (
<div className="w-full bg-white dark:bg-gray-800 rounded-3xl p-6 shadow-sm border border-gray-100 dark:border-gray-700 text-black dark:text-white mt-6">
<div className="flex items-center gap-2 mb-4 border-b pb-2 border-gray-100 dark:border-gray-700">
<Archive size={22} className="text-blue-600" />
<h2 className="text-xl font-bold text-gray-900 dark:text-white">Form Input Inventaris Baru (Dinamis)</h2>
</div>
<form onSubmit={handleSimpanKeDB} className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* KOLOM FORM KIRI */}
<div className="space-y-4">
<div>
<label className="block text-xs font-bold tracking-wide uppercase text-gray-500 mb-1">Nama Barang</label>
<input
type="text"
placeholder="Contoh: Proyektor Epson X51"
value={namaBarang}
onChange={(e) => setNamaBarang(e.target.value)}
className="w-full p-3 text-sm rounded-xl border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 outline-none focus:border-blue-500 text-gray-800 dark:text-white"
/>
</div>
<div className="grid grid-cols-2 gap-3">
{/* KATEGORI MENGGUNAKAN INPUT DATALIST (BISA PILIH / KETIK BARU) */}
<div>
<label className="block text-xs font-bold tracking-wide uppercase text-gray-500 mb-1">Kategori</label>
<input
list="kategori-list"
placeholder="Pilih atau Ketik..."
value={kategoriSelected}
onChange={(e) => setKategoriSelected(e.target.value)}
className="w-full p-3 text-sm rounded-xl border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 outline-none focus:border-blue-500 text-gray-800 dark:text-white"
/>
<datalist id="kategori-list">
{dbKategori.map((kat, index) => (
<option key={index} value={kat} />
))}
</datalist>
</div>
{/* LOKASI MENGGUNAKAN INPUT DATALIST (BISA PILIH / KETIK BARU) */}
<div>
<label className="block text-xs font-bold tracking-wide uppercase text-gray-500 mb-1">Lokasi</label>
<input
list="lokasi-list"
placeholder="Pilih atau Ketik..."
value={lokasiSelected}
onChange={(e) => setLokasiSelected(e.target.value)}
className="w-full p-3 text-sm rounded-xl border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 outline-none focus:border-blue-500 text-gray-800 dark:text-white"
/>
<datalist id="lokasi-list">
{dbLokasi.map((lok, index) => (
<option key={index} value={lok} />
))}
</datalist>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-bold tracking-wide uppercase text-gray-500 mb-1">Kondisi Fisik</label>
<select
value={kondisiFisik}
onChange={(e) => setKondisiFisik(e.target.value)}
className="w-full p-3 text-sm rounded-xl border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-800 dark:text-white outline-none focus:border-blue-500"
>
<option value="">-- Pilih --</option>
<option value="Baik">Baik</option>
<option value="Rusak Ringan">Rusak Ringan</option>
<option value="Rusak Berat">Rusak Berat</option>
<option value="Hilang">Hilang</option>
</select>
</div>
<div>
<label className="block text-xs font-bold tracking-wide uppercase text-gray-500 mb-1">Stok Awal</label>
<input
type="number"
placeholder="Jumlah"
value={stokAwal}
onChange={(e) => setStokAwal(e.target.value)}
className="w-full p-3 text-sm rounded-xl border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 outline-none focus:border-blue-500 text-gray-800 dark:text-white"
/>
</div>
</div>
</div>
{/* KOLOM FORM KANAN */}
<div className="space-y-4 flex flex-col justify-between">
<div>
<label className="block text-xs font-bold tracking-wide uppercase text-gray-500 mb-1">Kode Barang Otomatis</label>
<input
type="text"
readOnly
disabled
placeholder="Ketik Kategori & Lokasi..."
value={kodeBarang}
className="w-full p-3 text-sm rounded-xl border border-gray-200 bg-gray-50 dark:bg-gray-900 font-mono font-bold text-blue-600 outline-none cursor-not-allowed"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-xs font-bold tracking-wide uppercase text-gray-500">Foto Barang</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => setTipeUpload("file")}
className={`text-xs font-bold px-2 py-1 rounded ${tipeUpload === "file" ? "bg-blue-100 text-blue-600" : "bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400"}`}
>
Upload File
</button>
<button
type="button"
onClick={() => setTipeUpload("url")}
className={`text-xs font-bold px-2 py-1 rounded ${tipeUpload === "url" ? "bg-blue-100 text-blue-600" : "bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400"}`}
>
Link URL
</button>
</div>
</div>
{tipeUpload === "file" ? (
<div className="flex items-center gap-4 p-3 border border-dashed border-gray-300 dark:border-gray-600 rounded-xl bg-gray-50 dark:bg-gray-700">
<label className="flex items-center gap-2 bg-blue-50 hover:bg-blue-100 text-blue-600 px-4 py-2 rounded-xl text-sm font-semibold cursor-pointer border border-blue-200 transition shrink-0">
<Image size={16} />
Pilih Gambar
<input type="file" accept="image/*" onChange={handleFileChange} className="hidden" />
</label>
<span className="text-xs text-gray-400 truncate max-w-[180px]">
{fotoBarang ? fotoBarang.name : "Belum ada file terpilih"}
</span>
{previewUrl && (
<div className="ml-auto w-12 h-12 rounded-lg overflow-hidden border border-gray-200 shadow-sm shrink-0">
<img src={previewUrl} alt="Preview" className="w-full h-full object-cover" />
</div>
)}
</div>
) : (
<div className="flex items-center gap-4 p-3 border border-dashed border-gray-300 dark:border-gray-600 rounded-xl bg-gray-50 dark:bg-gray-700">
<input
type="url"
placeholder="https://contoh.com/gambar.jpg"
value={fotoUrl}
onChange={(e) => setFotoUrl(e.target.value)}
className="w-full p-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 outline-none focus:border-blue-500 text-gray-800 dark:text-white"
/>
{fotoUrl && (
<div className="ml-auto w-12 h-12 rounded-lg overflow-hidden border border-gray-200 shadow-sm shrink-0">
<img src={fotoUrl} alt="Preview" className="w-full h-full object-cover" onError={(e) => (e.currentTarget.style.display = 'none')} />
</div>
)}
</div>
)}
</div>
<div className="pt-2">
<button
type="submit"
disabled={loading}
className="w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white py-3 rounded-xl text-sm font-bold shadow transition disabled:opacity-50"
>
{loading ? (
<>
<Loader2 className="animate-spin" size={18} />
Menyimpan ke Database...
</>
) : (
<>
<CheckCircle size={18} />
Simpan Barang Baru ke DB
</>
)}
</button>
</div>
</div>
</form>
</div>
);
}

View File

@ -0,0 +1,134 @@
// src/components/sidebar/Sidebar.tsx
import { NavLink } from "react-router-dom";
import {
LayoutDashboard,
Users,
ClipboardList,
FileText,
Settings,
Menu,
LogOut,
} from "lucide-react";
type SidebarProps = {
isOpen: boolean;
setIsOpen: (value: boolean) => void;
};
const menuItems = [
{
name: "Dashboard",
path: "/dashboard",
icon: <LayoutDashboard size={20} />,
},
{
name: "Manajemen Users",
path: "/dashboard/manajemen-users",
icon: <Users size={20} />,
},
{
name: "Master Data (Kelola Barang)",
path: "/dashboard/master-data",
icon: <ClipboardList size={20} />,
},
{
name: "BAST",
path: "/dashboard/transaksi-bast",
icon: <FileText size={20} />,
},
{
name: "Laporan",
path: "/dashboard/laporan",
icon: <FileText size={20} />,
},
{
name: "Settings",
path: "/dashboard/settings",
icon: <Settings size={20} />,
},
];
export default function Sidebar({
isOpen,
setIsOpen,
}: SidebarProps) {
return (
<aside
className={`fixed top-0 left-0 min-h-screen bg-white dark:bg-black text-black dark:text-white shadow-2xl transition-all duration-300 z-40 ${
isOpen ? "w-64" : "w-20"
}`}
>
{/* HEADER */}
<div className="p-4 border-b border-blue-700 flex items-center justify-between">
{isOpen && (
<div>
<h1 className="text-xl font-extrabold text-blue-500">
SCHOOL LOAN
</h1>
<p className="text-xs text-gray-400">
Sistem Peminjaman
</p>
</div>
)}
{/* TOGGLE */}
<button
onClick={() =>
setIsOpen(!isOpen)
}
className="bg-blue-600 p-2 rounded-lg text-white"
>
<Menu size={20} />
</button>
</div>
{/* MENU */}
<nav className="p-4 space-y-3">
{menuItems.map(
(item, index) => (
<NavLink
key={index}
to={item.path}
className={({
isActive,
}) =>
`flex items-center ${
isOpen
? "justify-start"
: "justify-center"
} gap-3 px-4 py-3 rounded-xl transition-all ${
isActive
? "bg-blue-600 text-white"
: "text-gray-700 dark:text-gray-300 hover:bg-blue-900 hover:text-white"
}`
}
>
{item.icon}
{isOpen && (
<span>
{item.name}
</span>
)}
</NavLink>
)
)}
</nav>
{/* FOOTER */}
{isOpen && (
<div className="absolute bottom-4 left-4 text-xs text-gray-400">
© 2026 School Loan
</div>
)}
</aside>
);
}

View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@ -0,0 +1,31 @@
// src/layout/DashboardLayout.tsx
import { useState } from "react";
import { Outlet } from "react-router-dom";
import Sidebar from "../components/sidebar/Sidebar";
export default function DashboardLayout() {
const [isOpen, setIsOpen] =
useState(true);
return (
<div className="flex min-h-screen bg-[#f4f8ff]">
{/* SIDEBAR */}
<Sidebar
isOpen={isOpen}
setIsOpen={setIsOpen}
/>
{/* MAIN CONTENT */}
<main
className={`flex-1 transition-all duration-300 ${
isOpen
? "ml-64"
: "ml-20"
}`}
>
<Outlet />
</main>
</div>
);
}

10
kasir_admin/src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@ -0,0 +1,219 @@
import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import axios from "axios";
import { ArrowLeft, MapPin, Tag, PackageSearch, Activity, FileText, CheckCircle2, Clock } from "lucide-react";
export default function DetailBarang() {
const { kode } = useParams<{ kode: string }>();
const navigate = useNavigate();
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchDetail = async () => {
try {
setLoading(true);
if (!kode) return;
const apiUrl = `http://${window.location.hostname}:5000/api/barang/detail/${encodeURIComponent(kode)}`;
const res = await axios.get(apiUrl);
if (res.data.success) {
setData(res.data.data);
} else {
setError(res.data.message || "Barang tidak ditemukan");
}
} catch (err: any) {
console.error(err);
setError(err.response?.data?.message || "Gagal mengambil data");
} finally {
setLoading(false);
}
};
fetchDetail();
}, [kode]);
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex flex-col items-center justify-center p-4">
<PackageSearch size={64} className="text-blue-500 animate-pulse mb-4" />
<h2 className="text-xl font-semibold text-gray-700 animate-pulse">Memindai Data Barang...</h2>
</div>
);
}
if (error || !data) {
return (
<div className="min-h-screen bg-gray-50 flex flex-col items-center justify-center p-4">
<div className="bg-white p-8 rounded-3xl shadow-xl max-w-md w-full text-center border border-red-100">
<div className="bg-red-50 text-red-500 w-20 h-20 rounded-full flex items-center justify-center mx-auto mb-4">
<PackageSearch size={40} />
</div>
<h2 className="text-2xl font-bold text-gray-800 mb-2">Oops!</h2>
<p className="text-gray-600 mb-6">{error}</p>
<button
onClick={() => navigate("/")}
className="w-full bg-blue-600 text-white font-semibold py-3 rounded-xl hover:bg-blue-700 transition"
>
Kembali ke Beranda
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 pb-12">
{/* HEADER */}
<div className="bg-white border-b border-gray-200 sticky top-0 z-10">
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
<button
onClick={() => navigate(-1)}
className="flex items-center gap-2 text-gray-600 hover:text-blue-600 transition font-medium"
>
<ArrowLeft size={20} /> Kembali
</button>
<h1 className="font-bold text-lg text-gray-800">Detail Scan QR</h1>
<div className="w-8"></div> {/* Spacer for centering */}
</div>
</div>
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 mt-8">
{/* CARD UTAMA */}
<div className="bg-white rounded-3xl shadow-sm border border-gray-200 overflow-hidden mb-8">
{/* FOTO HEADER */}
<div className="h-64 sm:h-80 bg-gray-100 relative">
{data.foto_barang ? (
<img
src={data.foto_barang.startsWith("http") ? data.foto_barang : `http://${window.location.hostname}:5000/${data.foto_barang}`}
alt={data.nama_barang}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex flex-col items-center justify-center text-gray-400">
<PackageSearch size={64} className="mb-2 opacity-50" />
<span className="font-medium">Tidak ada foto</span>
</div>
)}
{/* BADGE STATUS FISIK (MENGAMBANG DI ATAS FOTO) */}
<div className="absolute top-4 right-4">
<span className={`px-4 py-1.5 rounded-full text-sm font-bold shadow-md ${
data.status_barang === 'Baik' ? 'bg-green-500 text-white' :
data.status_barang === 'Hilang' ? 'bg-gray-800 text-white' :
'bg-red-500 text-white'
}`}>
{data.status_barang}
</span>
</div>
</div>
{/* INFORMASI BARANG */}
<div className="p-6 sm:p-8">
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-4 mb-6">
<div>
<span className="inline-block px-3 py-1 bg-blue-50 text-blue-700 text-xs font-bold rounded-lg font-mono mb-3 border border-blue-100">
{data.kode_barang}
</span>
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 leading-tight">
{data.nama_barang}
</h1>
</div>
<div className="text-left sm:text-right">
<p className="text-sm text-gray-500 mb-1 font-medium">Sisa Stok</p>
<p className="text-3xl font-black text-blue-600">{data.stok} <span className="text-lg font-medium text-gray-400">Unit</span></p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="flex items-center gap-3 p-4 bg-gray-50 rounded-2xl border border-gray-100">
<div className="bg-white p-2 rounded-xl shadow-sm text-blue-500">
<Tag size={20} />
</div>
<div>
<p className="text-xs text-gray-500 font-medium">Kategori</p>
<p className="font-bold text-gray-800">{data.nama_kategori || "-"}</p>
</div>
</div>
<div className="flex items-center gap-3 p-4 bg-gray-50 rounded-2xl border border-gray-100">
<div className="bg-white p-2 rounded-xl shadow-sm text-orange-500">
<MapPin size={20} />
</div>
<div>
<p className="text-xs text-gray-500 font-medium">Lokasi Saat Ini</p>
<p className="font-bold text-gray-800">{data.nama_lokasi || "-"}</p>
</div>
</div>
</div>
</div>
</div>
{/* TIMELINE RIWAYAT BAST */}
<h2 className="text-xl font-bold text-gray-900 mb-6 flex items-center gap-2">
<Activity className="text-blue-600" /> Riwayat BAST Terkait
</h2>
<div className="bg-white rounded-3xl shadow-sm border border-gray-200 p-6 sm:p-8">
{data.riwayat_bast && data.riwayat_bast.length > 0 ? (
<div className="relative border-l-2 border-blue-100 ml-4 space-y-8">
{data.riwayat_bast.map((bast: any, idx: number) => (
<div key={idx} className="relative pl-8">
{/* Timeline Dot */}
<div className={`absolute -left-[9px] top-1 w-4 h-4 rounded-full border-2 border-white shadow-sm ${
bast.status_serah === 'Approved' && bast.status_terima === 'Approved'
? 'bg-green-500'
: 'bg-yellow-400'
}`}></div>
{/* Card Konten */}
<div className="bg-gray-50 border border-gray-100 rounded-2xl p-5 hover:shadow-md transition">
<div className="flex justify-between items-start mb-3">
<div className="flex items-center gap-2">
<FileText size={16} className="text-blue-600" />
<span className="font-bold text-gray-800">Dokumen #{bast.bast_id}</span>
</div>
<span className="text-xs font-medium text-gray-500 flex items-center gap-1">
<Clock size={12} />
{new Date(bast.dibuat_pada).toLocaleDateString('id-ID', {
day: 'numeric', month: 'long', year: 'numeric'
})}
</span>
</div>
<div className="grid grid-cols-2 gap-4 text-sm mt-4">
<div>
<p className="text-gray-500 mb-1 text-xs font-medium uppercase tracking-wider">Diserahkan Oleh</p>
<p className="font-semibold text-gray-800">{bast.nama_penyerah}</p>
<span className={`text-xs mt-1 inline-flex items-center gap-1 font-semibold ${bast.status_serah === 'Approved' ? 'text-green-600' : 'text-yellow-600'}`}>
{bast.status_serah === 'Approved' ? <CheckCircle2 size={12}/> : <Clock size={12}/>}
{bast.status_serah}
</span>
</div>
<div>
<p className="text-gray-500 mb-1 text-xs font-medium uppercase tracking-wider">Diterima Oleh</p>
<p className="font-semibold text-gray-800">{bast.nama_penerima}</p>
<span className={`text-xs mt-1 inline-flex items-center gap-1 font-semibold ${bast.status_terima === 'Approved' ? 'text-green-600' : 'text-yellow-600'}`}>
{bast.status_terima === 'Approved' ? <CheckCircle2 size={12}/> : <Clock size={12}/>}
{bast.status_terima}
</span>
</div>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8">
<div className="bg-gray-50 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-3">
<FileText className="text-gray-300" size={24} />
</div>
<p className="text-gray-500 font-medium">Belum ada riwayat BAST untuk barang ini.</p>
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,341 @@
import {
FileText,
Download,
User,
MapPin,
Layers,
BadgeCheck,
Filter,
X,
} from "lucide-react";
import { useEffect, useState, useMemo } from "react";
import axios from "axios";
import jsPDF from "jspdf";
import autoTable from "jspdf-autotable";
export default function Laporan() {
const [dataLaporan, setDataLaporan] = useState<any[]>([]);
const [loading, setLoading] = useState<boolean>(true);
// STATE FILTER
const [filterKategori, setFilterKategori] = useState("Semua");
const [filterLokasi, setFilterLokasi] = useState("Semua");
const [filterStatusSerah, setFilterStatusSerah] = useState("Semua");
const [filterStatusTerima, setFilterStatusTerima] = useState("Semua");
useEffect(() => {
setLoading(true);
axios
.get("http://localhost:5000/api/bast")
.then((res) => {
if (res.data && res.data.success && Array.isArray(res.data.data)) {
setDataLaporan(res.data.data);
} else if (Array.isArray(res.data)) {
setDataLaporan(res.data);
} else {
setDataLaporan([]);
}
})
.catch((err) => {
console.error("Gagal memuat data laporan:", err);
setDataLaporan([]);
})
.finally(() => {
setLoading(false);
});
}, []);
// OPSI FILTER DINAMIS dari data yang tersedia
const opsiKategori = useMemo(() => {
const unik = Array.from(new Set(dataLaporan.map((d) => d.nama_kategori).filter(Boolean)));
return ["Semua", ...unik];
}, [dataLaporan]);
const opsiLokasi = useMemo(() => {
const unik = Array.from(new Set(dataLaporan.map((d) => d.nama_lokasi).filter(Boolean)));
return ["Semua", ...unik];
}, [dataLaporan]);
// LOGIKA FILTER DATA
const dataFiltered = useMemo(() => {
return dataLaporan.filter((item) => {
const matchKategori = filterKategori === "Semua" || item.nama_kategori === filterKategori;
const matchLokasi = filterLokasi === "Semua" || item.nama_lokasi === filterLokasi;
const matchSerah = filterStatusSerah === "Semua" || item.status_serah === filterStatusSerah;
const matchTerima = filterStatusTerima === "Semua" || item.status_terima === filterStatusTerima;
return matchKategori && matchLokasi && matchSerah && matchTerima;
});
}, [dataLaporan, filterKategori, filterLokasi, filterStatusSerah, filterStatusTerima]);
const resetFilter = () => {
setFilterKategori("Semua");
setFilterLokasi("Semua");
setFilterStatusSerah("Semua");
setFilterStatusTerima("Semua");
};
const isFilterAktif =
filterKategori !== "Semua" ||
filterLokasi !== "Semua" ||
filterStatusSerah !== "Semua" ||
filterStatusTerima !== "Semua";
const downloadPDF = () => {
const doc = new jsPDF("l", "mm", "a4");
doc.setFont("helvetica", "bold");
doc.setFontSize(18);
doc.text("LAPORAN DATA TRANSAKSI BAST", 148, 20, { align: "center" });
doc.setFont("helvetica", "normal");
doc.setFontSize(10);
doc.text(`Dicetak pada: ${new Date().toLocaleDateString("id-ID")}`, 20, 28);
// Tampilkan info filter aktif di PDF
let filterText = "";
if (filterKategori !== "Semua") filterText += `Kategori: ${filterKategori} `;
if (filterLokasi !== "Semua") filterText += `Lokasi: ${filterLokasi} `;
if (filterStatusSerah !== "Semua") filterText += `Status Serah: ${filterStatusSerah} `;
if (filterStatusTerima !== "Semua") filterText += `Status Terima: ${filterStatusTerima}`;
if (filterText) {
doc.setFontSize(9);
doc.setTextColor(100, 100, 100);
doc.text(`Filter Aktif: ${filterText.trim()}`, 20, 34);
}
autoTable(doc, {
startY: filterText ? 40 : 33,
styles: {
fontSize: 9,
halign: "center",
valign: "middle",
lineWidth: 0.3,
lineColor: [180, 180, 180],
},
headStyles: {
fillColor: [41, 128, 185],
textColor: [255, 255, 255],
fontStyle: "bold",
},
head: [[
"No", "ID BAST", "Kode Barang", "Nama Barang",
"Kategori", "Lokasi", "Penyerah", "Penerima",
"Tanggal", "Status Serah", "Status Terima"
]],
body: dataFiltered.map((item, index) => [
index + 1,
`#${item.bast_id}`,
item.kode_barang || "-",
item.nama_barang || "-",
item.nama_kategori || "-",
item.nama_lokasi || "-",
item.nama_penyerah || "-",
item.nama_penerima || "-",
item.dibuat_pada ? new Date(item.dibuat_pada).toLocaleDateString("id-ID") : "-",
item.status_serah || "Menunggu",
item.status_terima || "Menunggu",
]),
});
doc.save(`Laporan_BAST_${Date.now()}.pdf`);
};
return (
<div className="p-6 md:p-8 min-h-screen bg-gray-100 dark:bg-gray-900 text-black dark:text-white transition-all duration-300">
{/* HEADER UTAMA */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
<div>
<h1 className="text-4xl font-bold">Laporan</h1>
<p className="text-gray-500 dark:text-gray-300 mt-2">
Rekapitulasi data transaksi BAST. {isFilterAktif && (
<span className="text-blue-500 font-semibold">
Menampilkan {dataFiltered.length} dari {dataLaporan.length} data.
</span>
)}
</p>
</div>
<button
onClick={downloadPDF}
disabled={dataFiltered.length === 0}
className={`flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white px-5 py-3 rounded-2xl shadow-lg transition ${
dataFiltered.length === 0 ? "opacity-50 cursor-not-allowed" : ""
}`}
>
<Download size={20} />
Download PDF {isFilterAktif ? `(${dataFiltered.length} data)` : "Semua"}
</button>
</div>
{/* === PANEL FILTER === */}
<div className="bg-white dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700 p-5 mb-6 shadow-sm">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2 text-gray-700 dark:text-gray-200 font-bold">
<Filter size={18} className="text-blue-600" />
<span>Filter Laporan</span>
{isFilterAktif && (
<span className="bg-blue-600 text-white text-xs font-bold px-2 py-0.5 rounded-full">Aktif</span>
)}
</div>
{isFilterAktif && (
<button
onClick={resetFilter}
className="flex items-center gap-1 text-sm text-red-500 hover:text-red-700 font-semibold transition"
>
<X size={14} /> Reset Filter
</button>
)}
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{/* Filter Kategori */}
<div>
<label className="block text-xs font-semibold text-gray-500 dark:text-gray-400 mb-1.5 uppercase tracking-wider">
Kategori Barang
</label>
<select
value={filterKategori}
onChange={(e) => setFilterKategori(e.target.value)}
className="w-full text-sm border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-xl px-3 py-2.5 outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition"
>
{opsiKategori.map((k) => (
<option key={k} value={k}>{k}</option>
))}
</select>
</div>
{/* Filter Lokasi */}
<div>
<label className="block text-xs font-semibold text-gray-500 dark:text-gray-400 mb-1.5 uppercase tracking-wider">
Lokasi Barang
</label>
<select
value={filterLokasi}
onChange={(e) => setFilterLokasi(e.target.value)}
className="w-full text-sm border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-xl px-3 py-2.5 outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition"
>
{opsiLokasi.map((l) => (
<option key={l} value={l}>{l}</option>
))}
</select>
</div>
{/* Filter Status Serah */}
<div>
<label className="block text-xs font-semibold text-gray-500 dark:text-gray-400 mb-1.5 uppercase tracking-wider">
Status Serah
</label>
<select
value={filterStatusSerah}
onChange={(e) => setFilterStatusSerah(e.target.value)}
className="w-full text-sm border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-xl px-3 py-2.5 outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition"
>
<option value="Semua">Semua</option>
<option value="Menunggu">Menunggu</option>
<option value="Approved">Approved</option>
</select>
</div>
{/* Filter Status Terima */}
<div>
<label className="block text-xs font-semibold text-gray-500 dark:text-gray-400 mb-1.5 uppercase tracking-wider">
Status Terima
</label>
<select
value={filterStatusTerima}
onChange={(e) => setFilterStatusTerima(e.target.value)}
className="w-full text-sm border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-xl px-3 py-2.5 outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition"
>
<option value="Semua">Semua</option>
<option value="Menunggu">Menunggu</option>
<option value="Approved">Approved</option>
</select>
</div>
</div>
</div>
{/* DAFTAR LAPORAN */}
{loading ? (
<div className="p-12 text-center bg-white dark:bg-gray-800 rounded-3xl text-blue-600 font-medium shadow animate-pulse">
Sedang menarik data rekapan laporan dari server... ⏳
</div>
) : dataFiltered.length > 0 ? (
<div className="space-y-5">
{dataFiltered.map((item) => (
<div
key={item.bast_id}
className="bg-white dark:bg-gray-800 p-6 rounded-3xl shadow-md border border-gray-100 dark:border-gray-700 hover:shadow-lg transition-all duration-300"
>
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div className="space-y-2 flex-1">
<div className="flex items-center gap-2">
<FileText size={20} className="text-blue-600 shrink-0" />
<h2 className="text-xl font-extrabold dark:text-white">
ID BAST #{item.bast_id}
</h2>
<span className="text-xs font-mono font-bold ml-2 px-2 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-500 rounded">
{item.kode_barang || "BLANK"}
</span>
</div>
<p className="text-lg font-bold text-blue-600 dark:text-blue-400">
{item.nama_barang}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 text-sm text-gray-600 dark:text-gray-300 pt-1">
<div className="flex items-center gap-1.5">
<Layers size={16} className="text-gray-400" />
<span>Kategori: <strong>{item.nama_kategori || "-"}</strong></span>
</div>
<div className="flex items-center gap-1.5">
<MapPin size={16} className="text-gray-400" />
<span>Lokasi: <strong>{item.nama_lokasi || "-"}</strong></span>
</div>
<div className="flex items-center gap-1.5">
<BadgeCheck size={16} className="text-green-500" />
<span>Penyerah: <strong>{item.nama_penyerah || "-"}</strong></span>
</div>
<div className="flex items-center gap-1.5">
<User size={16} className="text-blue-500" />
<span>Penerima: <strong>{item.nama_penerima || "-"}</strong></span>
</div>
</div>
<p className="text-xs text-gray-400">
Tanggal Transaksi: {item.dibuat_pada ? new Date(item.dibuat_pada).toLocaleString("id-ID") : "-"}
</p>
</div>
<div className="flex gap-2 self-stretch md:self-center justify-end border-t md:border-t-0 pt-3 md:pt-0 border-gray-100 dark:border-gray-700">
<span className={`text-xs font-bold px-3 py-1.5 rounded-full ${
item.status_serah === "Approved" ? "bg-green-100 text-green-700" : "bg-yellow-100 text-yellow-700"
}`}>
Serah: {item.status_serah}
</span>
<span className={`text-xs font-bold px-3 py-1.5 rounded-full ${
item.status_terima === "Approved" ? "bg-green-100 text-green-700" : "bg-yellow-100 text-yellow-700"
}`}>
Terima: {item.status_terima}
</span>
</div>
</div>
</div>
))}
</div>
) : (
<div className="p-12 text-center bg-white dark:bg-gray-800 rounded-3xl border border-gray-200 dark:border-gray-700 text-gray-400 font-medium flex flex-col items-center gap-3">
<Filter size={40} className="text-gray-300" />
<p>{isFilterAktif ? "Tidak ada data yang sesuai dengan filter yang dipilih." : "Belum ada data transaksi BAST."}</p>
{isFilterAktif && (
<button onClick={resetFilter} className="text-blue-600 font-semibold hover:underline text-sm">
Reset Filter
</button>
)}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,81 @@
export default function Settings() {
const settingsMenu = [
"Profil Toko",
"Manajemen User",
"Ganti Password",
"Printer Struk",
"Pengaturan Pajak",
"Kategori Barang",
"Backup Data",
"Tentang Aplikasi",
];
return (
<div className="p-8 bg-[#f4f7fe] min-h-screen">
<div className="mb-8">
<h1 className="text-5xl font-bold text-gray-800">
Settings
</h1>
<p className="text-gray-500 mt-2">
Pengaturan aplikasi kasir
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6">
{settingsMenu.map((item, index) => (
<div
key={index}
className="
bg-white
rounded-3xl
p-6
shadow-md
hover:shadow-xl
transition
duration-300
cursor-pointer
border
"
>
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold text-gray-800">
{item}
</h2>
<p className="text-gray-500 text-sm mt-2">
Klik untuk membuka pengaturan
</p>
</div>
<div className="
w-12
h-12
rounded-2xl
bg-blue-100
flex
items-center
justify-center
text-2xl
x
">
⚙️
</div>
</div>
</div>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,547 @@
import {
Lock,
Info,
LogOut,
Moon,
Sun,
User,
} from "lucide-react";
import {
useEffect,
useState,
} from "react";
import axios from "axios";
export default function SettingsPage() {
const [darkMode, setDarkMode] =
useState(false);
const [showAbout, setShowAbout] =
useState(false);
const [showProfile, setShowProfile] =
useState(false);
const [userData, setUserData] =
useState({
nama: "-",
username: "-",
role: "-",
gmail: "-",
});
useEffect(() => {
const savedTheme =
localStorage.getItem("theme");
if (savedTheme === "dark") {
setDarkMode(true);
document.documentElement.classList.add(
"dark"
);
}
ambilDataUser();
}, []);
// =========================
// AMBIL DATA USER LOGIN
// =========================
const ambilDataUser = async () => {
try {
const userId =
localStorage.getItem("user_id");
if (!userId) return;
const response =
await axios.get(
"http://localhost:5000/api/users"
);
const semuaUser =
response.data.data;
const user =
semuaUser.find(
(u: any) =>
u.user_id == userId
);
if (!user) return;
setUserData({
nama:
user.nama_lengkap || "-",
username:
user.username || "-",
role:
user.role || "-",
gmail:
user.email || "-",
});
} catch (error) {
console.error(
"Gagal ambil data user:",
error
);
}
};
// =========================
// DARK MODE
// =========================
const toggleTheme = () => {
if (darkMode) {
document.documentElement.classList.remove(
"dark"
);
localStorage.setItem(
"theme",
"light"
);
} else {
document.documentElement.classList.add(
"dark"
);
localStorage.setItem(
"theme",
"dark"
);
}
setDarkMode(!darkMode);
};
// =========================
// MENU SETTINGS
// =========================
const settingsMenu = [
{
title: "Ganti Password",
desc: "Ubah password akun",
icon: <Lock size={26} />,
},
{
title: "Tentang Anda",
desc:
"Informasi akun pengguna",
icon: <User size={26} />,
action: () =>
setShowProfile(true),
},
{
title: "Tentang Aplikasi",
desc:
"Informasi aplikasi School Loan",
icon: <Info size={26} />,
action: () =>
setShowAbout(true),
},
];
return (
<div className="p-8 bg-gray-100 dark:bg-gray-900 min-h-screen transition-all duration-300">
{/* HEADER */}
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-5xl font-bold text-gray-800 dark:text-white">
Settings
</h1>
<p className="text-gray-500 dark:text-gray-300 mt-2">
Pengaturan aplikasi BAST
</p>
</div>
{/* DARK MODE */}
<button
onClick={toggleTheme}
className="
flex
items-center
gap-3
bg-blue-600
hover:bg-blue-700
text-white
px-5
py-3
rounded-2xl
shadow-lg
transition-all
"
>
{darkMode ? (
<Sun size={22} />
) : (
<Moon size={22} />
)}
{darkMode
? "Light Mode"
: "Dark Mode"}
</button>
</div>
{/* MENU */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6">
{settingsMenu.map((item, index) => (
<div
key={index}
onClick={item.action}
className="
bg-white
dark:bg-gray-800
rounded-3xl
p-6
shadow-md
hover:shadow-xl
transition-all
duration-300
cursor-pointer
"
>
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold text-gray-800 dark:text-white">
{item.title}
</h2>
<p className="text-gray-500 dark:text-gray-300 text-sm mt-2">
{item.desc}
</p>
</div>
<div className="
w-14
h-14
rounded-2xl
bg-blue-100
dark:bg-blue-900
flex
items-center
justify-center
text-blue-600
dark:text-blue-300
">
{item.icon}
</div>
</div>
</div>
))}
</div>
{/* LOGOUT */}
<div className="mt-10">
<div
onClick={() => {
localStorage.clear();
window.location.href = "/";
}}
className="
bg-red-500
hover:bg-red-600
text-white
rounded-3xl
p-6
shadow-lg
cursor-pointer
transition-all
duration-300
w-full
md:w-[320px]
"
>
<div className="flex items-center gap-4">
<div className="
w-14
h-14
rounded-2xl
bg-white/20
flex
items-center
justify-center
">
<LogOut size={26} />
</div>
<div>
<h2 className="text-2xl font-bold">
Logout
</h2>
<p className="text-red-100 text-sm mt-1">
Keluar dari aplikasi
</p>
</div>
</div>
</div>
</div>
{/* MODAL PROFILE */}
{showProfile && (
<div className="
fixed
inset-0
bg-black/50
flex
items-center
justify-center
z-50
">
<div className="
bg-white
dark:bg-gray-800
w-[90%]
md:w-[500px]
rounded-3xl
p-8
shadow-2xl
relative
">
<button
onClick={() =>
setShowProfile(false)
}
className="
absolute
top-5
right-5
text-gray-500
"
>
✕
</button>
<div className="flex flex-col items-center">
<div
className="
w-32
h-32
rounded-full
bg-blue-600
flex
items-center
justify-center
shadow-xl
"
>
<User
size={60}
className="text-white"
/>
</div>
<h1 className="
text-3xl
font-bold
text-gray-800
dark:text-white
mt-4
">
{userData.nama}
</h1>
<p className="
text-gray-500
dark:text-gray-300
mt-1
">
@{userData.username}
</p>
</div>
<div className="mt-8 space-y-4">
<div className="
bg-gray-100
dark:bg-gray-700
p-4
rounded-2xl
">
<p className="text-sm text-gray-500">
Role
</p>
<h2 className="font-semibold dark:text-white">
{userData.role}
</h2>
</div>
<div className="
bg-gray-100
dark:bg-gray-700
p-4
rounded-2xl
">
<p className="text-sm text-gray-500">
Email
</p>
<h2 className="font-semibold dark:text-white">
{userData.gmail}
</h2>
</div>
</div>
</div>
</div>
)}
{/* MODAL ABOUT */}
{showAbout && (
<div className="
fixed
inset-0
bg-black/50
flex
items-center
justify-center
z-50
">
<div className="
bg-white
dark:bg-gray-800
w-[90%]
md:w-[600px]
rounded-3xl
p-8
shadow-2xl
relative
">
<button
onClick={() =>
setShowAbout(false)
}
className="
absolute
top-5
right-5
text-gray-500
"
>
✕
</button>
<h1 className="
text-3xl
font-bold
text-blue-600
mb-4
">
Tentang Aplikasi
</h1>
<p className="
text-gray-700
dark:text-gray-300
leading-8
">
<span className="font-bold">
School Loan
</span>{" "}
adalah aplikasi pengelolaan barang
dan transaksi BAST berbasis web.
<br /><br />
Dibuat menggunakan:
<br />
• React JS
<br />
• Express JS
<br />
• MySQL
<br />
• Tailwind CSS
<br /><br />
Versi 1.0
</p>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,511 @@
// src/pages/TransaksiBAST.tsx
import {
FileText,
Clock3,
User,
BadgeCheck,
Download,
MapPin,
Layers,
Plus,
X,
Eye,
} from "lucide-react";
import { useEffect, useState } from "react";
import axios from "axios";
import jsPDF from "jspdf";
import autoTable from "jspdf-autotable";
export default function TransaksiBAST() {
const [dataBAST, setDataBAST] = useState<any[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [showModal, setShowModal] = useState(false);
// State untuk penampung teks pencarian barang di input
const [barangSearchInput, setBarangSearchInput] = useState("");
const [formData, setFormData] = useState({
barang_id: "",
user_serah_id: "",
user_terima_id: "",
});
// State untuk dropdown options
const [barangList, setBarangList] = useState<any[]>([]);
const [userList, setUserList] = useState<any[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
// State untuk preview PDF
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const [showPreviewModal, setShowPreviewModal] = useState(false);
const [previewTitle, setPreviewTitle] = useState("");
const fetchBAST = () => {
setLoading(true);
axios
.get("http://localhost:5000/api/bast")
.then((res) => {
if (res.data && res.data.success && Array.isArray(res.data.data)) {
setDataBAST(res.data.data);
} else if (Array.isArray(res.data)) {
setDataBAST(res.data);
} else {
setDataBAST([]);
}
})
.catch((err) => {
console.error("Gagal memuat data transaksi BAST:", err);
setDataBAST([]);
})
.finally(() => {
setLoading(false);
});
};
useEffect(() => {
fetchBAST();
axios.get("http://localhost:5000/api/barang").then((res) => {
setBarangList(res.data?.data || res.data || []);
}).catch(err => console.error(err));
axios.get("http://localhost:5000/api/users").then((res) => {
setUserList(res.data?.data || res.data || []);
}).catch(err => console.error(err));
}, []);
// Fungsi untuk menangani perubahan ketikan pada input pencarian barang
const handleBarangSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
setBarangSearchInput(val);
// Cari barang yang teks string-nya cocok dengan yang diketik user
const match = barangList.find(
(b) => `${b.kode_barang} - ${b.nama_barang}` === val
);
// Jika ketemu yang pas, masukkan barang_id ke dalam formData
if (match) {
setFormData((prev) => ({ ...prev, barang_id: match.barang_id }));
} else {
// Jika user menghapus teks atau belum memilih dari list, kosongkan id
setFormData((prev) => ({ ...prev, barang_id: "" }));
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Validasi tambahan untuk memastikan barang_id benar-benar terpilih dari list
if (!formData.barang_id) {
alert("Silakan pilih barang yang valid dari daftar pilihan yang tersedia.");
return;
}
setIsSubmitting(true);
try {
await axios.post("http://localhost:5000/api/bast", formData);
setShowModal(false);
setBarangSearchInput("");
setFormData({
barang_id: "",
user_serah_id: "",
user_terima_id: "",
});
fetchBAST();
} catch (error) {
console.error("Gagal membuat BAST:", error);
alert("Gagal membuat BAST. Pastikan server berjalan dan periksa console.");
} finally {
setIsSubmitting(false);
}
};
// --- FUNGSI UTAMA GENERATOR DOKUMEN ---
const generatePDFInstance = async (item: any) => {
const doc = new jsPDF();
let qrImage = "";
try {
const qrRes = await axios.get(
`http://localhost:5000/api/qrcode/${item.bast_id}`
);
qrImage = qrRes.data.qr;
} catch (err) {
console.log("QR Code endpoint not found, menggunakan QR default jika tersedia.");
}
if (qrImage) {
doc.addImage(qrImage, "PNG", 160, 15, 30, 30);
}
doc.rect(140, 50, 50, 10);
doc.setFontSize(10);
doc.text(`BAST-#${item.bast_id}`, 143, 57);
doc.setFont("helvetica", "bold");
doc.setFontSize(15);
doc.text("BERITA ACARA", 105, 80, { align: "center" });
doc.text("SERAH TERIMA BARANG/ASSET", 105, 92, { align: "center" });
doc.setFont("helvetica", "normal");
doc.setFontSize(11);
const waktuBuat = item.dibuat_pada ? new Date(item.dibuat_pada).toLocaleDateString("id-ID") : "-";
doc.text(`Pada hari ini, tanggal ${waktuBuat},`, 20, 115);
doc.text(`telah dilakukan serah terima barang secara sah antara pihak:`, 20, 128);
doc.setFont("helvetica", "bold");
doc.text(`[Penyerah] ${item.nama_penyerah || "-"}`, 25, 138);
doc.text(`[Penerima] ${item.nama_penerima || "-"}`, 25, 146);
doc.setFont("helvetica", "normal");
doc.text("Adapun rincian barang/aset CIFO yang diserahterimakan sebagai berikut:", 20, 160);
autoTable(doc, {
startY: 168,
margin: { left: 20, right: 20 },
styles: {
fontSize: 10,
halign: "center",
valign: "middle",
lineWidth: 0.3,
lineColor: [0, 0, 0],
},
headStyles: {
fillColor: [255, 255, 255],
textColor: [0, 0, 0],
fontStyle: "bold",
},
bodyStyles: {
textColor: [0, 0, 0],
},
head: [["No.", "Kode Barang", "Nama Barang", "Kondisi Serah", "Kondisi Terima"]],
body: [[
"1",
item.kode_barang || "-",
item.nama_barang || "-",
item.status_serah || "Menunggu",
item.status_terima || "Menunggu"
]],
});
doc.setFont("helvetica", "normal");
doc.setFontSize(11);
doc.text("Demikian Berita Acara Serah Terima ini dibuat dengan sebenar-benarnya dan", 20, 220);
doc.text("dipergunakan sebagaimana mestinya untuk keperluan inventarisasi sekolah.", 20, 232);
doc.setFont("helvetica", "bold");
doc.text("Yang Menyerahkan,", 20, 255);
doc.text("Yang Menerima,", 125, 255);
doc.setFont("helvetica", "normal");
doc.text(item.nama_penyerah || "-", 20, 285);
doc.text(item.nama_penerima || "-", 125, 285);
return doc;
};
// AKSI 1: PRATINJAU PDF
const handlePreviewPDF = async (item: any) => {
setPreviewTitle(`Pratinjau Dokumen BAST #${item.bast_id}`);
const doc = await generatePDFInstance(item);
const blobData = doc.output("blob");
const blobResult = URL.createObjectURL(blobData);
setPdfUrl(blobResult);
setShowPreviewModal(true);
};
// AKSI 2: SEKALIGUS DOWNLOAD FILE
const downloadPDF = async (item: any) => {
const doc = await generatePDFInstance(item);
doc.save(`BAST_CIFO_${item.bast_id}.pdf`);
};
return (
<div className="p-8 min-h-screen bg-gray-100 dark:bg-gray-900 transition-all duration-300">
{/* HEADER UTAMA */}
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-4xl font-bold dark:text-white">BAST</h1>
<p className="text-gray-500 dark:text-gray-300 mt-2">
Data serah terima barang logistik resmi perusahaan dan sekolah.
</p>
</div>
<button
onClick={() => setShowModal(true)}
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white font-semibold px-5 py-2.5 rounded-xl shadow-md transition-all duration-300"
>
<Plus size={20} />
Buat BAST
</button>
</div>
{/* RENDER KARTU DOKUMEN BAST */}
{loading ? (
<div className="p-12 text-center bg-white dark:bg-gray-800 rounded-3xl text-blue-600 font-medium shadow-md animate-pulse">
Sedang menyinkronkan data dokumen BAST dari server CIFO...
</div>
) : dataBAST && dataBAST.length > 0 ? (
<div className="space-y-5">
{dataBAST.map((item) => (
<div
key={item.bast_id}
className="bg-white dark:bg-gray-800 rounded-3xl shadow-md p-6 border border-gray-200 dark:border-gray-700 hover:shadow-lg transition-all duration-300"
>
<div className="flex flex-col sm:flex-row justify-between items-start gap-4">
<div className="space-y-2.5 flex-1">
<div className="flex items-center gap-2">
<FileText size={20} className="text-blue-600 shrink-0" />
<h2 className="text-xl font-extrabold dark:text-white">
Dokumen BAST #{item.bast_id}
</h2>
</div>
<div>
<span className="text-xs font-mono font-bold px-2 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded">
{item.kode_barang || "KODE-BLANK"}
</span>
<p className="text-xl font-bold text-blue-600 dark:text-blue-400 mt-1">
{item.nama_barang}
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-1.5 pt-1 text-sm text-gray-600 dark:text-gray-300">
<div className="flex items-center gap-2">
<Layers size={16} className="text-gray-400" />
<span>Kategori: <strong className="text-gray-800 dark:text-gray-100">{item.nama_kategori || "-"}</strong></span>
</div>
<div className="flex items-center gap-2">
<MapPin size={16} className="text-gray-400" />
<span>Lokasi: <strong className="text-gray-800 dark:text-gray-100">{item.nama_lokasi || "-"}</strong></span>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-1.5 pt-1 border-t border-gray-100 dark:border-gray-700 text-sm text-gray-600 dark:text-gray-300">
<div className="flex items-center gap-2">
<BadgeCheck size={16} className="text-green-500" />
<span>Penyerah: <strong className="text-gray-800 dark:text-gray-100">{item.nama_penyerah || "-"}</strong></span>
</div>
<div className="flex items-center gap-2">
<User size={16} className="text-blue-500" />
<span>Penerima: <strong className="text-gray-800 dark:text-gray-100">{item.nama_penerima || "-"}</strong></span>
</div>
</div>
<div className="flex items-center gap-2 text-xs text-gray-400 pt-1">
<Clock3 size={14} />
<span>Dibuat Pada: {item.dibuat_pada ? new Date(item.dibuat_pada).toLocaleString("id-ID") : "-"}</span>
</div>
</div>
{/* AREA NOTIFIKASI STATUS & OPERASI CETAK */}
<div className="flex sm:flex-col items-end justify-between sm:justify-start w-full sm:w-auto gap-3 self-stretch sm:self-start border-t sm:border-t-0 pt-4 sm:pt-0 border-gray-100 dark:border-gray-700">
<div className="flex flex-col gap-1 items-end">
<span className="text-[10px] uppercase font-bold text-gray-400 tracking-wider">Status Dokumen</span>
<div className="flex gap-1.5">
<span className={`text-xs font-bold px-3 py-1 rounded-full ${item.status_serah === "Approved" ? "bg-green-100 text-green-700" : "bg-yellow-100 text-yellow-700"}`}>
Serah: {item.status_serah}
</span>
<span className={`text-xs font-bold px-3 py-1 rounded-full ${item.status_terima === "Approved" ? "bg-green-100 text-green-700" : "bg-yellow-100 text-yellow-700"}`}>
Terima: {item.status_terima}
</span>
</div>
</div>
<div className="flex gap-2 w-full sm:w-auto">
{/* TOMBOL PREVIEW */}
<button
onClick={() => handlePreviewPDF(item)}
className="flex items-center gap-1.5 bg-gray-600 hover:bg-gray-700 text-white font-semibold text-xs px-3 py-2 rounded-xl shadow transition duration-200"
>
<Eye size={14} />
Lihat
</button>
{/* TOMBOL DOWNLOAD */}
<button
onClick={() => downloadPDF(item)}
className="flex items-center gap-1.5 bg-blue-600 hover:bg-blue-700 text-white font-semibold text-xs px-3 py-2 rounded-xl shadow hover:shadow-lg transition duration-200"
>
<Download size={14} />
Cetak
</button>
</div>
</div>
</div>
</div>
))}
</div>
) : (
<div className="p-12 text-center bg-white dark:bg-gray-800 rounded-3xl border border-gray-200 dark:border-gray-700 text-gray-400 font-medium">
Belum ada riwayat dokumen transaksi BAST terdaftar.
</div>
)}
{/* --- MODAL DIALOG PREVIEW PDF --- */}
{showPreviewModal && pdfUrl && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<div className="bg-white dark:bg-gray-800 rounded-3xl w-full max-w-4xl h-[85vh] shadow-2xl overflow-hidden flex flex-col animate-in fade-in zoom-in duration-200">
<div className="flex justify-between items-center p-5 border-b border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-850">
<h3 className="text-lg font-bold text-gray-900 dark:text-white flex items-center gap-2">
<Eye size={18} className="text-blue-600" />
{previewTitle}
</h3>
<button
onClick={() => {
setShowPreviewModal(false);
setPdfUrl(null);
}}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-full transition"
>
<X size={20} />
</button>
</div>
<div className="flex-1 bg-gray-700 p-2">
<iframe
src={pdfUrl}
title="PDF Preview Container"
className="w-full h-full rounded-xl border-none"
/>
</div>
<div className="p-4 bg-gray-50 dark:bg-gray-850 border-t border-gray-100 dark:border-gray-700 flex justify-end gap-3">
<button
onClick={() => {
setShowPreviewModal(false);
setPdfUrl(null);
}}
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-gray-200 rounded-xl text-xs font-semibold transition"
>
Tutup Pratinjau
</button>
<a
href={pdfUrl}
download={`${previewTitle.replace(/ /g, "_")}.pdf`}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-xl text-xs font-semibold shadow transition flex items-center gap-1"
>
<Download size={14} /> Download File
</a>
</div>
</div>
</div>
)}
{/* MODAL FORM INPUT DATA BAST */}
{showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="bg-white dark:bg-gray-800 rounded-3xl w-full max-w-2xl shadow-2xl overflow-hidden animate-in fade-in zoom-in duration-200">
<div className="flex justify-between items-center p-6 border-b border-gray-100 dark:border-gray-700">
<h2 className="text-2xl font-bold text-gray-800 dark:text-white">Buat BAST Baru</h2>
<button
onClick={() => {
setShowModal(false);
setBarangSearchInput("");
}}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full transition-colors"
>
<X size={24} />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6">
<div className="grid grid-cols-1 gap-5 mb-6">
{/* PILIH BARANG MENGGUNAKAN SEARCHABLE INPUT (DATALIST) */}
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Pilih Barang (Bisa Diketik & Dicari)</label>
<input
required
type="text"
list="barang-options"
placeholder="Ketik kode atau nama barang..."
value={barangSearchInput}
onChange={handleBarangSearchChange}
className="w-full px-4 py-2.5 rounded-xl border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 outline-none transition-all"
/>
{/* Elemen datalist yang menampung daftar pencarian */}
<datalist id="barang-options">
{barangList.map((b) => (
<option key={b.barang_id} value={`${b.kode_barang} - ${b.nama_barang}`} />
))}
</datalist>
<p className="text-xs text-gray-400">
{formData.barang_id ? "Barang terverifikasi terpilih" : "Ketik dan pilih salah satu barang dari list"}
</p>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">User Penyerah</label>
<select
required
name="user_serah_id"
value={formData.user_serah_id}
onChange={(e) => setFormData({ ...formData, user_serah_id: e.target.value })}
className="w-full px-4 py-2.5 rounded-xl border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 outline-none transition-all cursor-pointer"
>
<option value="" disabled>-- Pilih Penyerah --</option>
{userList.map(u => (
<option key={u.user_id} value={u.user_id}>
{u.nama_lengkap} ({u.role})
</option>
))}
</select>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">User Penerima</label>
<select
required
name="user_terima_id"
value={formData.user_terima_id}
onChange={(e) => setFormData({ ...formData, user_terima_id: e.target.value })}
className="w-full px-4 py-2.5 rounded-xl border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 outline-none transition-all cursor-pointer"
>
<option value="" disabled>-- Pilih Penerima --</option>
{userList.map(u => (
<option key={u.user_id} value={u.user_id}>
{u.nama_lengkap} ({u.role})
</option>
))}
</select>
</div>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-gray-100 dark:border-gray-700">
<button
type="button"
onClick={() => {
setShowModal(false);
setBarangSearchInput("");
}}
className="px-5 py-2.5 rounded-xl text-gray-700 dark:text-gray-300 font-medium hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
Batal
</button>
<button
type="submit"
disabled={isSubmitting}
className="px-5 py-2.5 rounded-xl bg-blue-600 hover:bg-blue-700 text-white font-medium shadow-md transition-all disabled:opacity-70 disabled:cursor-not-allowed flex items-center gap-2"
>
{isSubmitting ? "Menyimpan..." : "Simpan BAST"}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

0
kasir_admin/src/root.tsx Normal file
View File

View File

@ -0,0 +1,13 @@
import Hero from "../components/dashboard/Hero";
import About from "../components/dashboard/About";
import Content from "../components/dashboard/Content";
export default function Dashboard() {
return (
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 p-6 transition-all duration-300">
<Hero />
<About />
<Content />
</div>
);
}

View File

@ -0,0 +1,157 @@
import { useState, useEffect } from 'react';
import axios from 'axios';
import { useNavigate } from 'react-router';
import { Lock, User, ShieldAlert, Eye, EyeOff } from 'lucide-react';
import LoginInput from '../components/login/LoginInput';
const LoginKasir = () => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
// Bersihkan sisa login lama setiap halaman login dibuka
useEffect(() => {
localStorage.clear();
}, []);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
// Tembak API Login ke Backend Express
const response = await axios.post('http://localhost:5000/api/login', {
username: username,
password: password
});
// Deteksi otomatis format data (antisipasi kalau dibungkus objek .data lagi)
const dataRespon = response.data.data || response.data;
const tokenRespon = response.data.token || dataRespon.token;
// Validasi pengaman jika backend lupa mengirimkan token
if (!tokenRespon) {
throw new Error("Token otentikasi tidak dikirim oleh server backend!");
}
// SIMPAN DATA SECARA LENGKAP KE LOCAL STORAGE (Sesuai Data Dictionary UKK)
localStorage.setItem('token', tokenRespon);
localStorage.setItem('user_role', dataRespon.role || dataRespon.user_role);
localStorage.setItem('user_name', dataRespon.nama_lengkap || dataRespon.nama || "User");
// Mengamankan ID User yang login untuk kebutuhan pencocokan data BAST nanti
localStorage.setItem('user_id', dataRespon.user_id || dataRespon.id);
// Ambil nama user untuk notifikasi sambutan
const namaUser = dataRespon.nama_lengkap || dataRespon.nama || "User";
alert('Selamat Datang, ' + namaUser + '! 🎉');
// Ambil role user dari backend
const roleUser = dataRespon.role || dataRespon.user_role;
// LOGIN KHUSUS ADMIN
if (roleUser === 'Admin') {
navigate('/dashboard');
// ATAU kalau beda project:
// window.location.href = "http://localhost:5173/dashboard";
}
// LOGIN KHUSUS USER
else if (roleUser === 'User') {
// Kalau beda folder / beda project
window.location.href = "http://localhost:5174/userDashboard";
// Kalau masih 1 project:
// navigate('/user/dashboard');
}
// Kalau role tidak dikenali
else {
setError('Role user tidak dikenali!');
}
} catch (err: any) {
console.error("Detail Error Login:", err);
// Tampilkan pesan error dari backend, jika gagal total pakai pesan default
setError(err.response?.data?.message || err.message || 'Gagal terhubung ke server');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-[#1A1D21] flex items-center justify-center p-4">
<div className="max-w-md w-full bg-[#24272C] rounded-2xl p-8 shadow-2xl border border-white/5">
{/* Header */}
<div className="text-center mb-8">
<div className="bg-blue-500/10 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4">
<Lock className="text-blue-500" size={32} />
</div>
<h2 className="text-2xl font-bold text-white">Portal BAST</h2>
<p className="text-gray-400 text-sm mt-2">Silahkan masuk ke akun anda</p>
</div>
{/* Notif Error */}
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-500 p-3 rounded-lg mb-6 flex items-center gap-3 text-sm">
<ShieldAlert size={18} />
{error}
</div>
)}
<form onSubmit={handleLogin}>
{/* Input Username */}
<LoginInput
label="Username"
name="username"
type="text"
icon={User}
placeholder="Masukkan username"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
{/* Input Password */}
<LoginInput
label="Password"
name="password"
type={showPassword ? 'text' : 'password'}
icon={Lock}
placeholder="••••••••"
showEye={true}
toggleEye={() => setShowPassword(!showPassword)}
eyeIcon={showPassword ? EyeOff : Eye}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{/* Tombol Submit */}
<button
type="submit"
disabled={loading}
className="w-full mt-6 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-800 disabled:cursor-not-allowed text-white font-semibold py-3 rounded-xl shadow-lg shadow-blue-500/20 transition-all transform active:scale-[0.98]"
>
{loading ? 'Menghubungkan...' : 'Masuk Sekarang'}
</button>
</form>
<p className="text-center text-gray-500 text-xs mt-8">
&copy; 2026 WinterAccess - Login Kasir
</p>
</div>
</div>
);
};
export default LoginKasir;

View File

@ -0,0 +1,98 @@
import { useState, useEffect } from "react";
import axios from "axios";
// COMPONENTS
import UsersHero from "../components/manajemenUsers/UserHero";
import UsersFilter from "../components/manajemenUsers/UserFilter";
import UsersTable from "../components/manajemenUsers/UserTable";
// Definisikan tipe data User sesuai kolom database CIFO Abang
export interface UserData {
user_id: number;
nama_lengkap: string;
username: string;
role: string;
email: string | null;
lembaga: string | null;
}
export default function ManajemenUsers() {
const [users, setUsers] = useState<UserData[]>([]);
const [filteredUsers, setFilteredUsers] = useState<UserData[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [kataKunci, setKataKunci] = useState<string>("");
// 1. FUNGSI READ: Ambil data user dari backend Express
const ambilDataUsers = async () => {
setLoading(true);
try {
const respon = await axios.get("http://localhost:5000/api/users");
if (respon.data.success) {
setUsers(respon.data.data);
setFilteredUsers(respon.data.data); // Inisialisasi data filter
}
} catch (error) {
console.error("Gagal mengambil data user:", error);
alert("Waduh Bang, gagal konek ke API server!");
} finally {
setLoading(false);
}
};
// Jalankan fungsi ambil data saat halaman pertama kali dibuka
useEffect(() => {
ambilDataUsers();
}, []);
// 2. FUNGSI FILTER: Menyaring data berdasarkan inputan di UsersFilter
useEffect(() => {
const hasilFilter = users.filter((user) =>
user.nama_lengkap.toLowerCase().includes(kataKunci.toLowerCase()) ||
user.username.toLowerCase().includes(kataKunci.toLowerCase()) ||
(user.lembaga && user.lembaga.toLowerCase().includes(kataKunci.toLowerCase()))
);
setFilteredUsers(hasilFilter);
}, [kataKunci, users]);
// 3. FUNGSI DELETE: Menghapus user berdasarkan user_id
const handleHapusUser = async (user_id: number) => {
if (window.confirm("Beneran mau hapus user ini, Bang?")) {
try {
const respon = await axios.delete(`http://localhost:5000/api/users/${user_id}`);
if (respon.data.success) {
alert(respon.data.message);
ambilDataUsers(); // Muat ulang tabel biar data yang dihapus hilang
}
} catch (error: any) {
console.error("Gagal hapus user:", error);
// Menampilkan pesan error dari backend jika user terikat transaksi BAST
alert(error.response?.data?.message || "Gagal menghapus user!");
}
}
};
return (
<div className="min-h-screen bg-[#f4f8ff] dark:bg-gray-900 p-6 transition-all duration-300">
{/* HERO: Kirim fungsi muat ulang supaya kalau abang sukses Tambah User, tabel langsung update */}
<UsersHero onUserDitambahkan={ambilDataUsers} />
{/* FILTER: Kirim state kata kunci untuk mendeteksi ketikan pencarian */}
<UsersFilter kataKunci={kataKunci} setKataKunci={setKataKunci} />
{/* TABLE: Kirim data hasil filter, status loading, fungsi hapus, dan fungsi refresh setelah edit */}
{loading ? (
<div className="flex justify-center items-center p-10 bg-white rounded-2xl shadow-sm border border-blue-100">
<p className="text-blue-600 font-semibold animate-pulse">
Sabar Bang, lagi narik data dari DB CIFO... ⏳
</p>
</div>
) : (
<UsersTable
dataUsers={filteredUsers}
onHapusUser={handleHapusUser}
onUserDiedit={ambilDataUsers}
/>
)}
</div>
);
}

View File

@ -0,0 +1,273 @@
// src/routes/MasterData.tsx
import { useState, useEffect } from "react";
import axios from "axios";
import { Plus, Pencil, Loader2, PackageX, Search } from "lucide-react";
import KategoriLokasiManagement from "../components/masterData/KategoriLokasiManagement";
// COMPONENTS
import BarangHero from "../components/masterData/BarangHero";
import EditBarangModal from "../components/masterData/EditBarangModal";
import TambahBarang from "../components/masterData/TambahBarang";
export interface BarangType {
barang_id: number;
kode_barang: string;
nama_barang: string;
stok: number;
status_barang: string;
qr_image: string;
foto_barang: string | null;
nama_kategori?: string;
nama_lokasi?: string;
}
export default function MasterData() {
const [selectedKategori, setSelectedKategori] = useState("Semua");
const [selectedBarang, setSelectedBarang] = useState<BarangType | null>(null);
const [showTambah, setShowTambah] = useState(false);
const [barangList, setBarangList] = useState<BarangType[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [selectedQr, setSelectedQr] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const uniqueCategories = [
"Semua",
...Array.from(new Set(barangList.map((b) => b.nama_kategori).filter((k): k is string => !!k))),
];
const muatDataBarang = async () => {
setLoading(true);
try {
const respon = await axios.get("http://localhost:5000/api/barang");
if (respon.data && respon.data.success && Array.isArray(respon.data.data)) {
setBarangList(respon.data.data);
} else if (Array.isArray(respon.data)) {
setBarangList(respon.data);
} else {
setBarangList([]);
}
} catch (error) {
console.error("Gagal memuat data dari API:", error);
setBarangList([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
muatDataBarang();
}, []);
const filteredBarang = barangList.filter((barang) => {
const matchKategori = selectedKategori === "Semua" || barang.nama_kategori?.toLowerCase() === selectedKategori.toLowerCase();
const matchSearch = barang.nama_barang.toLowerCase().includes(searchQuery.toLowerCase()) ||
barang.kode_barang.toLowerCase().includes(searchQuery.toLowerCase());
return matchKategori && matchSearch;
});
const handleSaveSuccess = () => {
muatDataBarang();
setSelectedBarang(null);
setShowTambah(false);
};
return (
<div className="p-8 bg-gray-100 min-h-screen text-gray-800 space-y-6">
{/* 1. HERO & TOMBOL (Layout Asli: Hero Full Sampai Kanan) */}
<div className="relative w-full">
{/* Banner Hero Panjang Penuh */}
<BarangHero />
{/* Tombol Nempel di Samping Kanan Dalam/Luar Sesuai Desain Asli */}
<div className="absolute right-6 top-1/2 -translate-y-1/2 hidden md:block">
<button
onClick={() => setShowTambah(!showTambah)}
className={`${
showTambah ? "bg-gray-700 hover:bg-gray-800" : "bg-blue-600 hover:bg-blue-700"
} text-white font-bold px-6 py-3.5 rounded-2xl shadow-lg transition duration-200 whitespace-nowrap flex items-center gap-2`}
>
<Plus size={18} className={`transition-transform duration-200 ${showTambah ? 'rotate-45' : ''}`} />
{showTambah ? "Tutup Form" : "Tambah Barang UKK"}
</button>
</div>
</div>
{/* Tombol Versi Mobile (Muncul di bawah hero kalau layar kecil) */}
<div className="block md:hidden w-full text-right">
<button
onClick={() => setShowTambah(!showTambah)}
className="w-full justify-center bg-blue-600 text-white font-bold px-6 py-4 rounded-2xl shadow-md flex items-center gap-2"
>
<Plus size={18} />
{showTambah ? "Tutup Form Input" : "Tambah Barang UKK"}
</button>
</div>
{/* 2. FORM TAMBAH BARANG (Nangkring Full Width Tepat di Bawah Hero) */}
{showTambah && (
<div className="w-full bg-white rounded-3xl p-6 shadow-sm border border-gray-200 animate-in fade-in slide-in-from-top-4 duration-200">
<TambahBarang onRefresh={handleSaveSuccess} />
</div>
)}
{/* 3. FILTER KATEGORI & PENCARIAN BARANG */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 pb-2 pt-2">
{/* Kategori Filter */}
<div className="flex gap-2 overflow-x-auto">
{uniqueCategories.map((kat) => (
<button
key={kat}
onClick={() => setSelectedKategori(kat)}
className={`px-5 py-2.5 rounded-xl font-semibold transition text-sm whitespace-nowrap ${
selectedKategori === kat
? "bg-blue-600 text-white shadow-sm"
: "bg-white text-gray-600 hover:bg-gray-50 border border-gray-200"
}`}
>
{kat}
</button>
))}
</div>
{/* Search Bar */}
<div className="relative w-full md:w-72 shrink-0">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<Search size={18} className="text-gray-400" />
</div>
<input
type="text"
placeholder="Cari nama atau kode barang..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-11 pr-4 py-2.5 bg-white border border-gray-200 rounded-xl text-sm outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all text-gray-800"
/>
</div>
</div>
{/* Management Section for Kategori & Lokasi */}
<KategoriLokasiManagement />
{/* 4. GRID DAFTAR INVENTARIS UTAMA */}
{loading ? (
<div className="p-12 text-center bg-white rounded-3xl border border-gray-200 font-medium text-blue-600 flex flex-col items-center justify-center gap-2">
<Loader2 className="animate-spin text-blue-600" size={32} />
<span>Narik data & QR Code dari server CIFO...</span>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{filteredBarang.length === 0 ? (
<div className="col-span-full text-center py-12 bg-white rounded-3xl border border-gray-200 text-gray-400 font-medium flex flex-col items-center justify-center gap-2">
<PackageX size={40} className="text-gray-300" />
<span>Belum ada aset terdaftar di kategori ini, Bang!</span>
</div>
) : (
filteredBarang.map((barang) => (
<div
key={barang.barang_id}
className="bg-white rounded-3xl p-5 shadow-sm border border-gray-200 flex flex-col justify-between hover:shadow-md transition"
>
<div>
<div className="w-full h-40 bg-gray-50 rounded-2xl overflow-hidden mb-4 border border-gray-100 flex items-center justify-center relative">
{barang.foto_barang ? (
<img
src={barang.foto_barang.startsWith('http') ? barang.foto_barang : `http://localhost:5000/${barang.foto_barang}`}
alt={barang.nama_barang}
className="w-full h-full object-cover"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
const parent = target.parentElement;
if (parent) {
const fallback = document.createElement('div');
fallback.className = 'flex flex-col items-center justify-center text-gray-400';
fallback.innerHTML = '<span class="text-4xl mb-1">📦</span><span class="text-xs font-medium">No Image</span>';
parent.appendChild(fallback);
}
}}
/>
) : (
<div className="flex flex-col items-center justify-center text-gray-400">
<span className="text-4xl mb-1">📦</span>
<span className="text-xs font-medium">No Image</span>
</div>
)}
<span className={`absolute top-3 right-3 text-xs font-bold px-2.5 py-1 rounded-full ${
barang.status_barang === 'Baik' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}>
{barang.status_barang}
</span>
</div>
<span className="text-xs font-mono font-bold text-blue-600 bg-blue-50 px-2 py-0.5 rounded">
{barang.kode_barang}
</span>
<h3 className="text-lg font-bold text-gray-900 mt-1.5 line-clamp-1">{barang.nama_barang}</h3>
<div className="grid grid-cols-2 gap-2 mt-3 text-xs text-gray-500">
<div>Kategori: <strong className="text-gray-700 block">{barang.nama_kategori || '-'}</strong></div>
<div>Lokasi: <strong className="text-gray-700 block">{barang.nama_lokasi || '-'}</strong></div>
</div>
</div>
<div className="mt-5 pt-4 border-t border-gray-100 flex items-center justify-between gap-4">
{barang.qr_image && (
<img
src={barang.qr_image}
alt="QR"
onClick={() => setSelectedQr(barang.qr_image)}
className="w-12 h-12 rounded-lg border p-0.5 bg-white shadow-sm cursor-zoom-in hover:scale-110 transition-all duration-200"
/>
)}
<div className="text-right flex-1">
<span className="text-xs text-gray-400 block">Stok</span>
<span className="text-base font-extrabold text-gray-800">{barang.stok} Unit</span>
</div>
<button
onClick={() => setSelectedBarang(barang)}
className="bg-gray-50 hover:bg-blue-50 hover:text-blue-600 text-gray-500 p-2.5 rounded-xl border border-gray-200 transition flex items-center justify-center"
>
<Pencil size={16} />
</button>
</div>
</div>
))
)}
</div>
)}
{selectedBarang && (
<EditBarangModal
itemSelected={selectedBarang}
onClose={() => setSelectedBarang(null)}
onRefresh={handleSaveSuccess}
/>
)}
{selectedQr && (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in duration-200"
onClick={() => setSelectedQr(null)}
>
<div
className="bg-white dark:bg-gray-800 p-6 rounded-3xl max-w-sm w-full shadow-2xl relative flex flex-col items-center animate-in zoom-in-95 duration-200"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-bold mb-4 text-gray-800 dark:text-white">QR Code Barang</h3>
<img
src={selectedQr}
alt="QR Code Zoom"
className="w-64 h-64 border rounded-2xl p-2 bg-white"
/>
<button
onClick={() => setSelectedQr(null)}
className="mt-6 px-5 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-800 dark:text-white font-semibold rounded-xl text-sm transition"
>
Tutup
</button>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,15 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: 'class',
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})

24
kasir_users/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
kasir_users/README.md Normal file
View File

@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@ -0,0 +1,22 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
},
])

13
kasir_users/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>kasir</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3981
kasir_users/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

37
kasir_users/package.json Normal file
View File

@ -0,0 +1,37 @@
{
"name": "kasir",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.16.0",
"lucide-react": "^1.16.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-router-dom": "^7.14.2"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react": "^6.0.1",
"autoprefixer": "^10.5.0",
"eslint": "^10.2.1",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0",
"postcss": "^8.5.12",
"tailwindcss": "^3.4.19",
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.2",
"vite": "^8.0.10"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

184
kasir_users/src/App.css Normal file
View File

@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}

55
kasir_users/src/App.tsx Normal file
View File

@ -0,0 +1,55 @@
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import Dashboard from "./routes/userDashboard";
import MasterData from "./routes/userMaster-Data";
import Transaksi from "./routes/usertransaksi";
import Laporan from "./routes/userLaporan";
import Settings from "./routes/userSettings";
function App() {
return (
<BrowserRouter>
<Routes>
{/* Redirect awal */}
<Route
path="/"
element={<Navigate to="/userDashboard" />}
/>
{/* DASHBOARD */}
<Route
path="/userDashboard"
element={<Dashboard />}
/>
{/* MASTER DATA */}
<Route
path="/userDashboard/master-data"
element={<MasterData />}
/>
{/* TRANSAKSI */}
<Route
path="/userDashboard/transaksi"
element={<Transaksi />}
/>
{/* LAPORAN */}
<Route
path="/userDashboard/laporan"
element={<Laporan />}
/>
{/* SETTINGS */}
<Route
path="/userDashboard/settings"
element={<Settings />}
/>
</Routes>
</BrowserRouter>
);
}
export default App;

View File

@ -0,0 +1,24 @@
// src/Routes.tsx
import { Routes, Route } from "react-router-dom";
import UserDashboardLayout from "./layout/userDashboardLayout";
import UserDashboard from "./routes/userDashboard";
import UserLaporan from "./routes/userLaporan";
import UserBAST from "./routes/userBAST";
export default function AppRoutes() {
return (
<Routes>
{/* route dengan sidebar */}
<Route element={<UserDashboardLayout />}>
<Route path="/" element={<UserDashboard />} />
<Route path="/laporan" element={<UserLaporan />} />
<Route path="/bast" element={<UserBAST />} />
</Route>
</Routes>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 757 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 750 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 821 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

View File

@ -0,0 +1,13 @@
export default function About() {
return (
<div className="bg-white rounded-2xl shadow p-6 mb-8">
<h2 className="text-2xl font-bold mb-3">
Tentang Sistem
</h2>
<p className="text-gray-600">
Aplikasi kasir dan peminjaman barang sekolah ini dirancang untuk mempermudah pengelolaan transaksi penjualan, pencatatan data barang, serta proses peminjaman perlengkapan sekolah seperti buku, alat tulis, dan perangkat pendukung lainnya. Dengan sistem yang modern dan terintegrasi, pengguna dapat mengelola stok barang, melakukan transaksi, memantau data peminjaman, serta melihat laporan dengan lebih cepat, akurat, dan aman. Dashboard utama memberikan akses mudah ke seluruh fitur penting sehingga operasional sekolah menjadi lebih terorganisir, efisien, dan profesional.
</p>
</div>
);
}

View File

@ -0,0 +1,61 @@
import novel from "../../assets/novel.png";
import elektronik from "../../assets/elektronik.png";
import alat from "../../assets/alat.png";
export default function Content() {
const items = [
{
name: "novel",
image: novel,
function:
"rekreatif (hiburan), didaktif (mendidik), moralitas (penanaman nilai baik-buruk), religius (ajaran agama), dan estetis (keindahan bahasa/cerita).",
},
{
name: "elektronik",
image: elektronik,
function:
"Elektronik digunakan untuk mempermudah pekerjaan manusia, memberikan hiburan, membantu komunikasi, serta meningkatkan efisiensi dan keamanan dalam kehidupan sehari-hari.",
},
{
name: "Alat Tulis",
image: alat ,
function:
"mencatat informasi, mendukung administrasi perkantoran, menunjang kegiatan belajar-mengajar, serta memfasilitasi ekspresi kreatif seperti menggambar dan merancang.",
},
];
return (
<div className="space-y-8 bg-white p-6 rounded-2xl">
<h2 className="text-3xl font-bold text-black mb-6 border-b-4 border-blue-600 pb-2">
Barang Barang
</h2>
{items.map((item, index) => (
<div
key={index}
className="bg-black text-white rounded-2xl shadow-lg p-6 flex items-center gap-8 border-l-4 border-blue-600"
>
{/* KIRI - PENJELASAN */}
<div className="flex-1">
<h3 className="text-2xl font-bold text-blue-400 mb-3">
{item.name}
</h3>
<p className="text-gray-300 leading-relaxed">
{item.function}
</p>
</div>
{/* KANAN - FOTO */}
<div className="w-72">
<img
src={item.image}
alt={item.name}
className="w-full h-56 object-cover rounded-xl shadow border-2 border-blue-500"
/>
</div>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,198 @@
// src/components/dashboard/Hero.tsx
import { useEffect, useState } from "react";
export default function Hero() {
// State untuk menyimpan data profile secara dinamis
const [userData, setUserData] = useState({ nama_lengkap: "User", role: "User" });
// State untuk menyimpan angka statistik dari database
const [stats, setStats] = useState({
permintaanHariIni: 0,
menungguPersetujuan: 0,
totalBarang: 0,
stokHampirHabis: 0,
totalPengguna: 0,
});
useEffect(() => {
// 1. AMBIL DATA USER YANG LOGIN (SINKRONISASI NYA DI SINI YA BANG)
const localName = localStorage.getItem("user_name");
const localRole = localStorage.getItem("user_role");
const sessionUser = sessionStorage.getItem("user");
if (sessionUser) {
// Jika ada data di session storage (Objek utuh)
try {
const parsedUser = JSON.parse(sessionUser);
setUserData({
nama_lengkap: parsedUser.nama_lengkap || "User",
role: parsedUser.role || "User"
});
} catch (e) {
console.error(e);
}
} else if (localName) {
// Jika data session kosong, ambil dari local storage pecahan milik login Abang
setUserData({
nama_lengkap: localName,
role: localRole || "User"
});
}
// 2. Ambil data Barang dari backend untuk Card 3
fetch("http://localhost:5000/api/barang")
.then((res) => res.json())
.then((resData) => {
if (resData.success && Array.isArray(resData.data)) {
const daftarBarang = resData.data;
const hampirHabis = daftarBarang.filter((b: any) => b.stok <= 3).length;
setStats((prev) => ({
...prev,
totalBarang: daftarBarang.length,
stokHampirHabis: hampirHabis,
}));
}
})
.catch((err) => console.error("Gagal mengambil data barang:", err));
// 3. Ambil data BAST dari backend untuk Card 1 dan Card 2
fetch("http://localhost:5000/api/bast")
.then((res) => res.json())
.then((resData) => {
if (resData.success && Array.isArray(resData.data)) {
const daftarBast = resData.data;
// Hitung data BAST yang statusnya masih 'Menunggu' (Card 2)
const menunggu = daftarBast.filter(
(b: any) => b.status_serah === "Menunggu" || b.status_terima === "Menunggu"
).length;
// Hitung data BAST yang dibuat HARI INI (Card 1)
const hariIniWIB = new Date().toLocaleDateString("id-ID");
const totalHariIni = daftarBast.filter((b: any) => {
if (!b.dibuat_pada) return false;
const tanggalBast = new Date(b.dibuat_pada).toLocaleDateString("id-ID");
return tanggalBast === hariIniWIB;
}).length;
setStats((prev) => ({
...prev,
menungguPersetujuan: menunggu,
permintaanHariIni: totalHariIni
}));
}
})
.catch((err) => console.error("Gagal mengambil data BAST:", err));
// 4. Ambil data Users dari backend untuk Card 4
fetch("http://localhost:5000/api/users")
.then((res) => res.json())
.then((resData) => {
if (resData.success && Array.isArray(resData.data)) {
setStats((prev) => ({
...prev,
totalPengguna: resData.data.length
}));
}
})
.catch((err) => console.error("Gagal mengambil data users:", err));
}, []);
// Mengambil huruf pertama untuk avatar icon (kasih pengaman biar ga crash pas loading)
const inisialNama = userData.nama_lengkap ? userData.nama_lengkap.charAt(0).toUpperCase() : "U";
return (
<div className="bg-blue-50 rounded-3xl p-8 shadow-sm border border-blue-100 mb-8">
{/* HEADER */}
<div className="flex justify-between items-start mb-8">
{/* LEFT */}
<div>
<p className="text-gray-500 text-lg">
Selamat datang kembali 👋
</p>
<h1 className="text-5xl font-extrabold text-blue-700 mt-2">
Dashboard
</h1>
</div>
{/* PROFILE */}
<div className="flex items-center gap-3 bg-white rounded-full px-4 py-2 shadow border hover:shadow-lg transition">
{/* AVATAR */}
<div className="w-12 h-12 bg-blue-600 rounded-full flex items-center justify-center text-white font-bold text-lg">
{inisialNama}
</div>
{/* USER INFO */}
<div>
<h3 className="font-semibold text-black">
{userData.nama_lengkap}
</h3>
<p className="text-gray-500 text-sm">
{userData.role}
</p>
</div>
</div>
</div>
{/* STAT CARDS */}
<div className="grid grid-cols-4 gap-6">
{/* CARD 1 */}
<div className="bg-white rounded-2xl p-6 shadow border hover:shadow-lg transition">
<p className="text-gray-500 mb-2">
Permintaan Hari Ini
</p>
<h2 className="text-4xl font-bold text-blue-700">
{stats.permintaanHariIni}
</h2>
<p className="text-green-600 mt-2">
Dokumen BAST baru
</p>
</div>
{/* CARD 2 */}
<div className="bg-white rounded-2xl p-6 shadow border hover:shadow-lg transition">
<p className="text-gray-500 mb-2">
Menunggu Persetujuan
</p>
<h2 className="text-4xl font-bold text-blue-700">
{stats.menungguPersetujuan}
</h2>
<p className="text-yellow-600 mt-2">
Perlu tindakan segera
</p>
</div>
{/* CARD 3 */}
<div className="bg-white rounded-2xl p-6 shadow border hover:shadow-lg transition">
<p className="text-gray-500 mb-2">
Total Barang
</p>
<h2 className="text-4xl font-bold text-blue-700">
{stats.totalBarang}
</h2>
<p className="text-green-600 mt-2">
{stats.stokHampirHabis} stok hampir habis
</p>
</div>
{/* CARD 4 */}
<div className="bg-white rounded-2xl p-6 shadow border hover:shadow-lg transition">
<p className="text-gray-500 mb-2">
Total Pengguna
</p>
<h2 className="text-4xl font-bold text-blue-700">
{stats.totalPengguna}
</h2>
<p className="text-green-600 mt-2">
Siswa & petugas terdaftar
</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,45 @@
import type { LucideIcon } from 'lucide-react';
interface Props {
label: string;
name: string;
type: string;
icon: LucideIcon;
placeholder: string;
showEye?: boolean;
toggleEye?: () => void;
eyeIcon?: LucideIcon;
// --- TAMBAHKAN DUA INI ---
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
export default function LoginInput({
label, name, type, icon: Icon, placeholder, showEye, toggleEye, eyeIcon: EyeIcon,
value, onChange // <--- Terima props di sini
}: Props) {
return (
<div className="mb-4">
<label className="block text-gray-400 text-sm mb-1">{label}:</label>
<div className="relative group">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center text-gray-500 group-focus-within:text-[#4A90E2]">
<Icon size={20} />
</div>
<input
name={name}
type={type}
placeholder={placeholder}
// --- PASANG DI SINI ---
value={value}
onChange={onChange}
className="w-full bg-[#3D444D] border border-gray-600 rounded-lg pl-11 pr-10 py-3 text-white focus:outline-none focus:border-[#4A90E2] transition-all placeholder:text-gray-500"
/>
{showEye && toggleEye && EyeIcon && (
<button type="button" onClick={toggleEye} className="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-500 hover:text-white">
<EyeIcon size={20} />
</button>
)}
</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More