fisrt commit
|
|
@ -0,0 +1,5 @@
|
|||
node_modules
|
||||
# Keep environment variables out of version control
|
||||
.env
|
||||
|
||||
/generated/prisma
|
||||
|
|
@ -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;
|
||||
|
|
@ -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...");
|
||||
});
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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"
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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?
|
||||
|
|
@ -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...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
|
@ -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>
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
After Width: | Height: | Size: 9.3 KiB |
|
|
@ -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 |
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 757 KiB |
|
After Width: | Height: | Size: 750 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 4.3 MiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 821 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 523 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>,
|
||||
)
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
© 2026 WinterAccess - Login Kasir
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginKasir;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: 'class',
|
||||
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
|
||||
plugins: [],
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
|
|
@ -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?
|
||||
|
|
@ -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...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
|
@ -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>
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
After Width: | Height: | Size: 9.3 KiB |
|
|
@ -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 |
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 757 KiB |
|
After Width: | Height: | Size: 750 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 4.3 MiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 821 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 523 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||