first commit

This commit is contained in:
akbarguci.pkl 2026-05-18 15:19:56 +07:00
commit b527f42a12
59 changed files with 7893 additions and 0 deletions

24
.gitignore vendored Normal file
View File

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

73
README.md Normal file
View File

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

22
eslint.config.js Normal file
View File

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

13
index.html Normal file
View File

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

4217
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
package.json Normal file
View File

@ -0,0 +1,39 @@
{
"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-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"
}
}

6
postcss.config.js Normal file
View File

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

1
public/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
public/icons.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 4.9 KiB

184
src/App.css Normal file
View File

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

34
src/App.tsx Normal file
View File

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

66
src/Routes.tsx Normal file
View File

@ -0,0 +1,66 @@
// 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";
export const router = createBrowserRouter([
/* LOGIN */
{
path: "/",
element: <LoginKasir />,
},
/* 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 />,
},
],
},
]);

BIN
src/assets/Filsafat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

BIN
src/assets/alat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 757 KiB

BIN
src/assets/buku.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 750 KiB

BIN
src/assets/elektronik.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

BIN
src/assets/komputer.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

BIN
src/assets/lampu.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
src/assets/laptop.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

BIN
src/assets/matematika.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

BIN
src/assets/novel.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

BIN
src/assets/pensil.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

BIN
src/assets/pulang.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

BIN
src/assets/pulpen.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 821 KiB

BIN
src/assets/setrika.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
src/assets/tas.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 523 KiB

BIN
src/assets/tipex.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

BIN
src/assets/welcome.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,27 @@
import type { Dispatch, SetStateAction } from "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">
<span className="text-gray-400 text-lg">🔍</span>
<input
type="text"
value={kataKunci}
onChange={(e) => setKataKunci(e.target.value)}
placeholder="Cari pengguna berdasarkan nama, username, atau lembaga..."
className="bg-transparent outline-none w-full text-gray-700 placeholder-gray-400 text-sm"
/>
</div>
<div className="text-xs text-gray-400 font-medium hidden sm:block">
Ketik kata kunci untuk menyaring baris tabel otomatis
</div>
</div>
);
}

View File

@ -0,0 +1,158 @@
import { useState } from "react";
import axios from "axios";
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>
<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"
>
➕ 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">
<h2 className="text-2xl font-bold text-gray-900 mb-6 flex items-center gap-2 text-blue-700">
👤 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
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"
>
{loading ? "Menyimpan..." : "Simpan Pengguna"}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,211 @@
import { useState } from "react";
import axios from "axios";
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 font-mono text-gray-600 bg-gray-50/50 rounded-lg px-2 py-0.5 inline-block mt-3">
{user.username}
</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">
<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"
>
✏️ Edit
</button>
<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"
>
🗑️ 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">
<h2 className="text-2xl font-bold text-gray-900 mb-6 flex items-center gap-2 text-amber-600">
✏️ 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="Petugas">Petugas</option>
<option value="Siswa">Siswa</option>
<option value="User">User Biasa</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>
<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"
>
{loading ? "Memproses..." : "Perbarui Pengguna"}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,40 @@
// src/components/masterData/BarangFilter.tsx
type Props = {
selectedKategori: string;
setSelectedKategori: (
kategori: string
) => void;
};
export default function BarangFilter({
selectedKategori,
setSelectedKategori,
}: Props) {
const kategoriList = [
"Semua",
"Buku",
"Elektronik",
"Alat Tulis",
];
return (
<div className="flex gap-4 mb-8 flex-wrap">
{kategoriList.map((kategori) => (
<button
key={kategori}
onClick={() =>
setSelectedKategori(kategori)
}
className={`px-5 py-2 rounded-xl font-medium transition-all duration-300 ${
selectedKategori === kategori
? "bg-blue-600 text-white"
: "bg-white dark:bg-gray-800 dark:text-white border border-gray-300 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-700"
}`}
>
{kategori}
</button>
))}
</div>
);
}

View File

@ -0,0 +1,78 @@
type BarangType = {
id: number;
nama: string;
foto: string;
pinjam: number;
kondisi: string;
tersedia: boolean;
kategori: string;
};
type Props = {
barangList: BarangType[];
onEdit: (barang: BarangType) => void;
};
export default function BarangGrid({
barangList,
onEdit,
}: Props) {
return (
<div className="grid grid-cols-4 gap-6">
{barangList.map((barang) => (
<div
key={barang.id}
onClick={() => onEdit(barang)}
className="bg-white dark:bg-gray-800 rounded-3xl shadow-lg overflow-hidden cursor-pointer hover:shadow-2xl hover:-translate-y-1 transition-all duration-300"
>
{/* FOTO FIX SHOPEE STYLE */}
<div className="w-full aspect-square bg-gray-100 dark:bg-gray-700 overflow-hidden">
<img
src={barang.foto}
alt={barang.nama}
className="w-full h-full object-cover hover:scale-105 transition duration-300"
/>
</div>
{/* CONTENT */}
<div className="p-4">
{/* NAMA */}
<h2 className="text-lg font-bold text-black dark:text-white line-clamp-1">
{barang.nama}
</h2>
{/* KATEGORI */}
<p className="text-sm text-blue-600 dark:text-blue-400 mt-1">
{barang.kategori}
</p>
{/* JUMLAH */}
<p className="text-gray-600 dark:text-gray-300 mt-2">
Dipinjam: {barang.pinjam}
</p>
{/* KONDISI */}
<p className="text-gray-600 dark:text-gray-300">
Kondisi: {barang.kondisi}
</p>
{/* STATUS */}
<div className="mt-3">
<span
className={`px-3 py-1 rounded-full text-sm font-medium ${
barang.tersedia
? "bg-green-100 text-green-700"
: "bg-red-100 text-red-600"
}`}
>
{barang.tersedia
? "Tersedia"
: "Dipinjam"}
</span>
</div>
</div>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,13 @@
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">
<h1 className="text-4xl font-extrabold tracking-tight mb-2">
Kelola Barang Sekolah
</h1>
<p className="text-blue-100 text-sm md:text-base">
Pantau sirkulasi stok, kodifikasi QR-Code otomatis, kondisi fisik, dan lokasi penempatan aset inventaris CIFO.
</p>
</div>
);
}

View File

@ -0,0 +1,176 @@
// 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 === "Baik" ? "Baik / Layak" : 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 === "Baik / Layak" ? "Baik" : 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 / Layak">Baik / Layak</option>
<option value="Rusak">Rusak</option>
</select>
</div>
<div>
<label className="block text-sm font-semibold mb-1">Stok</label>
<input
type="number"
value={stokInput}
onChange={(e) => setStokInput(e.target.value)}
className="w-full p-3 rounded-xl border border-gray-300 dark:border-gray-600 dark:bg-gray-700 outline-none"
/>
</div>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="px-5 py-2.5 bg-gray-200 dark:bg-gray-700 rounded-xl font-semibold"
>
Batal
</button>
<button
type="submit"
disabled={loading}
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white px-5 py-2.5 rounded-xl font-semibold shadow disabled:opacity-50"
>
<Save size={18} />
{loading ? "Menyimpan..." : "Simpan Perubahan"}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,545 @@
// src/components/masterData/TambahBarang.tsx
import React, { useState, useEffect } from "react";
import axios from "axios";
import { PlusCircle, Image } from "lucide-react";
interface TambahBarangProps {
onClose?: () => void;
onRefresh?: () => void;
}
export default function TambahBarang({
onClose,
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("");
// FILE FOTO
const [fotoBarang, setFotoBarang] = useState<File | null>(null);
// FOTO LINK ONLINE
const [fotoLink, setFotoLink] = useState("");
// PREVIEW
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
// LOGIKA KODE OTOMATIS
useEffect(() => {
if (kategoriSelected && lokasiSelected) {
const inisialKategori: { [key: string]: string } = {
Elektronik: "ELK",
"Alat Tulis": "ATK",
Kebersihan: "KBR",
"Mebel dan Furnitur": "MUB",
"Perangkat Jaringan": "JRG",
"Peralatan Operasional": "OPR",
"Peralatan Bengkel": "PBG",
"Buku dan Modul": "BKM",
};
const inisialLokasi: { [key: string]: string } = {
"Gudang Pusat": "GUD",
"Lab PPLG": "LAB-PPLG",
"Lab TKJ": "LAB-TKJ",
"X PPLG": "KLS-X-PPLG",
"XI PPLG": "KLS-XI-PPLG",
"XII PPLG": "KLS-XII-PPLG",
"X TKJ": "KLS-X-TKJ",
"XI TKJ": "KLS-XI-TKJ",
"XII TKJ": "KLS-XII-TKJ",
};
const kat = inisialKategori[kategoriSelected] || "BRG";
const lok = inisialLokasi[lokasiSelected] || "GUD";
const nomorUrut = String(
Math.floor(100 + Math.random() * 900)
);
setKodeBarang(`${kat}/${lok}/${nomorUrut}`);
} else {
setKodeBarang("");
}
}, [kategoriSelected, lokasiSelected]);
// UPLOAD FILE FOTO
const handleFileChange = (
e: React.ChangeEvent<HTMLInputElement>
) => {
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0];
setFotoBarang(file);
const localPreview = URL.createObjectURL(file);
setPreviewUrl(localPreview);
// RESET LINK KALAU PAKAI FILE
setFotoLink("");
}
};
// INPUT LINK FOTO
const handleLinkChange = (
e: React.ChangeEvent<HTMLInputElement>
) => {
const value = e.target.value;
setFotoLink(value);
// PREVIEW DARI LINK
setPreviewUrl(value);
// RESET FILE KALAU PAKAI LINK
setFotoBarang(null);
};
const handleSimpanKeDB = async (
e: React.FormEvent
) => {
e.preventDefault();
if (!kodeBarang) {
alert(
"Pilih Kategori dan Lokasi dulu agar kode barang tercipta otomatis!"
);
return;
}
if (!namaBarang.trim()) {
alert("Nama Barang belum diisi!");
return;
}
if (!kondisiFisik) {
alert("Silakan pilih Kondisi Fisik dulu!");
return;
}
if (!stokAwal) {
alert("Stok awal jangan dikosongkan!");
return;
}
// VALIDASI FOTO
if (!fotoBarang && !fotoLink) {
alert(
"Foto barang wajib dipilih atau menggunakan link online!"
);
return;
}
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,
"Lab PPLG": 289,
"Lab TKJ": 296,
"X PPLG": 303,
"XI PPLG": 310,
"XII PPLG": 317,
"X TKJ": 324,
"XI TKJ": 331,
"XII TKJ": 338,
};
const formData = new FormData();
formData.append("kode_barang", kodeBarang);
formData.append("nama_barang", namaBarang.trim());
formData.append(
"kategori_id",
String(kategoriMapping[kategoriSelected] || 1)
);
formData.append(
"lokasi_id",
String(lokasiMapping[lokasiSelected] || 1)
);
formData.append(
"status_barang",
kondisiFisik === "Baik / Layak"
? "Baik"
: "Rusak"
);
formData.append("stok", stokAwal);
// FILE FOTO
if (fotoBarang) {
formData.append("foto_barang", fotoBarang);
}
// LINK FOTO
if (fotoLink) {
formData.append("foto_link", fotoLink);
}
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 ${kodeBarang}`
);
setNamaBarang("");
setKategoriSelected("");
setLokasiSelected("");
setKondisiFisik("");
setStokAwal("");
setFotoBarang(null);
setFotoLink("");
setPreviewUrl(null);
if (onRefresh) onRefresh();
if (onClose) onClose();
}
} catch (err: any) {
console.error(err);
alert(
err.response?.data?.message ||
"Gagal menyimpan barang!"
);
} 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="p-6 bg-white dark:bg-gray-800 rounded-3xl shadow-2xl max-w-2xl w-full mx-auto text-black dark:text-white max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-6 border-b pb-3 border-gray-100 dark:border-gray-700">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
Input Inventaris Baru
</h2>
{onClose && (
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 font-bold text-lg"
>
✕
</button>
)}
</div>
<form
onSubmit={handleSimpanKeDB}
className="space-y-4"
>
{/* FOTO */}
<div>
<label className="block text-sm font-semibold mb-1 text-gray-700 dark:text-gray-200">
Foto Barang
</label>
<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">
<Image size={16} />
Pilih File
<input
type="file"
accept="image/*"
onChange={handleFileChange}
className="hidden"
/>
</label>
<div className="text-xs text-gray-400">
{fotoBarang
? fotoBarang.name
: "Belum ada file terpilih"}
</div>
</div>
{/* INPUT LINK FOTO */}
<div className="mt-3">
<input
type="text"
placeholder="Atau masukkan link foto online..."
value={fotoLink}
onChange={handleLinkChange}
className="w-full p-3 rounded-xl border border-gray-300 dark:border-gray-600 dark:bg-gray-700 outline-none focus:border-blue-500"
/>
</div>
{/* PREVIEW */}
{previewUrl && (
<div className="mt-3 w-32 h-32 rounded-xl overflow-hidden border border-gray-200 shadow-sm">
<img
src={previewUrl}
alt="Preview"
className="w-full h-full object-cover"
/>
</div>
)}
</div>
{/* KATEGORI & LOKASI */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold mb-1 text-gray-700 dark:text-gray-200">
Kategori
</label>
<select
value={kategoriSelected}
onChange={(e) =>
setKategoriSelected(
e.target.value
)
}
className="w-full p-3 rounded-xl border border-gray-300 dark:border-gray-600 dark:bg-gray-700 outline-none focus:border-blue-500 bg-white text-black"
>
<option value="">
-- Pilih Kategori --
</option>
<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 text-gray-700 dark:text-gray-200">
Lokasi Gudang / Ruang
</label>
<select
value={lokasiSelected}
onChange={(e) =>
setLokasiSelected(
e.target.value
)
}
className="w-full p-3 rounded-xl border border-gray-300 dark:border-gray-600 dark:bg-gray-700 outline-none focus:border-blue-500 bg-white text-black"
>
<option value="">
-- Pilih Lokasi --
</option>
<option value="Gudang Pusat">
Gudang Pusat
</option>
<option value="Lab PPLG">
Lab PPLG
</option>
<option value="Lab TKJ">
Lab TKJ
</option>
<option value="X PPLG">
X PPLG
</option>
<option value="XI PPLG">
XI PPLG
</option>
<option value="XII PPLG">
XII PPLG
</option>
<option value="X TKJ">
X TKJ
</option>
<option value="XI TKJ">
XI TKJ
</option>
<option value="XII TKJ">
XII TKJ
</option>
</select>
</div>
</div>
{/* KODE */}
<div>
<label className="block text-sm font-semibold mb-1 text-gray-700 dark:text-gray-200">
Kode Barang
</label>
<input
type="text"
readOnly
disabled
placeholder="Pilih Kategori & Lokasi..."
value={kodeBarang}
className="w-full p-3 rounded-xl border border-gray-200 bg-gray-100 dark:bg-gray-900 font-mono font-bold text-blue-600 outline-none cursor-not-allowed"
/>
</div>
{/* NAMA */}
<div>
<label className="block text-sm font-semibold mb-1 text-gray-700 dark:text-gray-200">
Nama Barang
</label>
<input
type="text"
placeholder="Contoh: Projector Epson"
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 focus:border-blue-500"
/>
</div>
{/* KONDISI & STOK */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold mb-1 text-gray-700 dark:text-gray-200">
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 focus:border-blue-500 bg-white text-black"
>
<option value="">
-- Pilih Kondisi --
</option>
<option value="Baik / Layak">
Baik / Layak
</option>
<option value="Rusak">
Rusak
</option>
</select>
</div>
<div>
<label className="block text-sm font-semibold mb-1 text-gray-700 dark:text-gray-200">
Stok Awal
</label>
<input
type="number"
placeholder="Jumlah stok"
value={stokAwal}
onChange={(e) =>
setStokAwal(
e.target.value
)
}
className="w-full p-3 rounded-xl border border-gray-300 dark:border-gray-600 dark:bg-gray-700 outline-none focus:border-blue-500"
/>
</div>
</div>
{/* BUTTON */}
<div className="flex justify-end gap-3 pt-4 border-t border-gray-100 dark:border-gray-700">
{onClose && (
<button
type="button"
onClick={onClose}
className="px-5 py-3 rounded-xl bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-white font-semibold hover:bg-gray-200 dark:hover:bg-gray-600 transition"
>
Batal
</button>
)}
<button
type="submit"
disabled={loading}
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-xl font-semibold shadow transition disabled:opacity-50"
>
<PlusCircle size={18} />
{loading
? "Menyimpan..."
: "Simpan ke DB"}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,134 @@
// src/components/sidebar/Sidebar.tsx
import { NavLink } from "react-router-dom";
import {
LayoutDashboard,
Users,
ClipboardList,
FileText,
Settings,
Menu,
LogOut,
} from "lucide-react";
type SidebarProps = {
isOpen: boolean;
setIsOpen: (value: boolean) => void;
};
const menuItems = [
{
name: "Dashboard",
path: "/dashboard",
icon: <LayoutDashboard size={20} />,
},
{
name: "Manajemen Users",
path: "/dashboard/manajemen-users",
icon: <Users size={20} />,
},
{
name: "Master Data (Kelola Barang)",
path: "/dashboard/master-data",
icon: <ClipboardList size={20} />,
},
{
name: "Transaksi 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>
);
}

3
src/index.css Normal file
View File

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

View File

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

10
src/main.tsx Normal file
View File

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

220
src/pages/Laporan.tsx Normal file
View File

@ -0,0 +1,220 @@
// src/pages/Laporan.tsx
import {
FileText,
Download,
User,
MapPin,
Layers,
BadgeCheck,
} from "lucide-react";
import { useEffect, useState } from "react";
import axios from "axios";
import jsPDF from "jspdf";
import autoTable from "jspdf-autotable";
export default function Laporan() {
/* =======================================================
STATE DATA LAPORAN (Diberikan default array kosong [])
======================================================= */
const [dataLaporan, setDataLaporan] = useState<any[]>([]);
const [loading, setLoading] = useState<boolean>(true);
/* =======================================================
GET DATA DATABASE (Ambil data dari endpoint /api/bast)
======================================================= */
useEffect(() => {
setLoading(true);
axios
.get("http://localhost:5000/api/bast")
.then((res) => {
// PENGAMAN JALUR DATA: Memastikan data yang masuk ke state selalu bertipe Array
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);
});
}, []);
/* =======================================================
DOWNLOAD PDF LAPORAN KESELURUHAN (Sinkron Kolom Database)
======================================================= */
const downloadPDF = () => {
const doc = new jsPDF("l", "mm", "a4"); // Menggunakan mode Landscape biar muat banyak kolom
/* JUDUL DOKUMEN LAPORAN */
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);
/* GENERATE MASTER TABLE LAPORAN */
autoTable(doc, {
startY: 33,
styles: {
fontSize: 9,
halign: "center",
valign: "middle",
lineWidth: 0.3,
lineColor: [180, 180, 180],
},
headStyles: {
fillColor: [41, 128, 185], // Warna biru premium standar industri
textColor: [255, 255, 255],
fontStyle: "bold",
},
head: [[
"No",
"ID BAST",
"Kode Barang",
"Nama Barang",
"Kategori",
"Lokasi",
"Penyunting/Penyerah",
"Penerima Aset",
"Tanggal Dibuat",
"Status Serah",
"Status Terima"
]],
body: dataLaporan.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"
]),
});
/* SAVE FILE PDF LAPORAN */
doc.save(`Laporan_Tahunan_BAST_${Date.now()}.pdf`);
};
return (
<div className="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-8">
<div>
<h1 className="text-4xl font-bold">Laporan</h1>
<p className="text-gray-500 dark:text-gray-300 mt-2">
Rekapitulasi data laporan mutasi transaksi BAST sekolah & CIFO.
</p>
</div>
{/* BUTTON GENERATE PDF */}
<button
onClick={downloadPDF}
disabled={dataLaporan.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 ${
dataLaporan.length === 0 ? "opacity-50 cursor-not-allowed" : ""
}`}
>
<Download size={20} />
Download Rekap PDF
</button>
</div>
{/* RENDER KONDISIONAL KARTU 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>
) : dataLaporan && dataLaporan.length > 0 ? (
<div className="space-y-5">
{dataLaporan.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">
{/* ID DOKUMEN */}
<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>
{/* NAMA BARANG */}
<p className="text-lg font-bold text-blue-600 dark:text-blue-400">
{item.nama_barang}
</p>
{/* ATRIBUT RELASI DATA */}
<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>
{/* SISI STATUS DOKUMEN DUAL APPROVAL */}
<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">
Tidak ditemukan rekapan laporan transaksi barang, Bang! 📦
</div>
)}
</div>
);
}

81
src/pages/Settings.tsx Normal file
View File

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

364
src/pages/SettingsPage.tsx Normal file
View File

@ -0,0 +1,364 @@
import {
Lock,
Info,
LogOut,
Moon,
Sun,
X,
} from "lucide-react";
import {
useEffect,
useState,
} from "react";
export default function SettingsPage() {
const [darkMode, setDarkMode] =
useState(false);
const [showAbout, setShowAbout] =
useState(false);
useEffect(() => {
const savedTheme =
localStorage.getItem("theme");
if (savedTheme === "dark") {
setDarkMode(true);
document.documentElement.classList.add(
"dark"
);
}
}, []);
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);
};
const settingsMenu = [
{
title: "Ganti Password",
desc: "Ubah password akun",
icon: <Lock size={26} />,
},
{
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 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
">
{/* CLOSE */}
<button
onClick={() =>
setShowAbout(false)
}
className="
absolute
top-5
right-5
text-gray-500
hover:text-red-500
"
>
<X size={24} />
</button>
{/* TITLE */}
<h1 className="
text-3xl
font-bold
text-blue-600
mb-4
">
Tentang Aplikasi
</h1>
{/* CONTENT */}
<p className="
text-gray-700
dark:text-gray-300
leading-8
">
<span className="font-bold">
School Loan
</span>{" "}
adalah aplikasi peminjaman dan
pengelolaan barang sekolah berbasis
web yang digunakan untuk membantu
proses transaksi BAST (Berita Acara
Serah Terima), pengelolaan data
barang, monitoring stok barang,
serta pembuatan laporan secara
cepat, akurat, dan efisien.
<br /><br />
Aplikasi ini dibuat untuk
mempermudah petugas sekolah dalam
mengelola data peminjaman barang
seperti laptop, buku, proyektor,
alat tulis, dan perlengkapan
lainnya secara modern dan
terintegrasi.
<br /><br />
<span className="font-semibold">
Versi:
</span>{" "}
1.0
<br />
<span className="font-semibold">
Developer:
</span>{" "}
Akbar
<br />
<span className="font-semibold">
Framework:
</span>{" "}
React JS, Express JS, MySQL
</p>
</div>
</div>
)}
</div>
);
}

284
src/pages/TransaksiBAST.tsx Normal file
View File

@ -0,0 +1,284 @@
// src/pages/TransaksiBAST.tsx
import {
FileText,
Clock3,
User,
BadgeCheck,
Download,
MapPin,
Layers,
} 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() {
/* =======================================================
STATE DATA DATABASE (Diberikan default array kosong [])
======================================================= */
const [dataBAST, setDataBAST] = useState<any[]>([]);
const [loading, setLoading] = useState<boolean>(true);
/* =======================================================
GET DATA DARI API (Ekstraksi Jalur Data Aman)
======================================================= */
useEffect(() => {
setLoading(true);
axios
.get("http://localhost:5000/api/bast")
.then((res) => {
// PENGAMAN KREDENSIAL: Memastikan data yang masuk ke state murni bertipe Array
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);
});
}, []);
/* =======================================================
DOWNLOAD PDF (Sinkronisasi Properti Sesuai Database)
======================================================= */
const downloadPDF = async (item: any) => {
const doc = new jsPDF();
/* =========================
QR CODE EXTRACTION
========================= */
let qrImage = "";
try {
// Menggunakan fallback jika qr_image sudah ada di dalam object barang
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);
}
/* =========================
KODE DOKUMEN BAST
========================= */
doc.rect(140, 50, 50, 10);
doc.setFontSize(10);
doc.text(`BAST-#${item.bast_id}`, 143, 57);
/* =========================
JUDUL DOKUMEN
========================= */
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" });
/* =========================
ISI SURAT KONTRAK BAST
========================= */
doc.setFont("helvetica", "normal");
doc.setFontSize(11);
// Konversi tanggal sql ke format readable lokal jika ada
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);
/* =========================
TABEL RINCIAN ASET UKK
========================= */
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"
]],
});
/* =========================
DOKUMEN KLAUSUL PENUTUP
========================= */
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);
/* =========================
TANDA TANGAN (TTD)
========================= */
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);
/* =========================
SAVE EKSPOR FILE PDF
========================= */
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">
Transaksi BAST
</h1>
<p className="text-gray-500 dark:text-gray-300 mt-2">
Data serah terima barang logistik resmi perusahaan & sekolah.
</p>
</div>
</div>
{/* RENDER LAYOUT KONDISIONAL */}
{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">
{/* ID BAST */}
<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>
{/* INFO UTAMA BARANG */}
<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>
{/* KATEGORI & LOKASI ATRIBUT */}
<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>
{/* PETUGAS OTORISASI (PENYERAH & PENERIMA) */}
<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>
{/* TRACKING REALTIME TIME STAMP */}
<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>
{/* SISI KANAN CONTROL STATUS & CETAK PDF */}
<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>
<button
onClick={() => downloadPDF(item)}
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white font-semibold text-sm px-4 py-2.5 rounded-xl shadow-md hover:shadow-lg transition duration-200"
>
<Download size={16} />
Cetak PDF
</button>
</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, Bang! 📦
</div>
)}
</div>
);
}

0
src/root.tsx Normal file
View File

13
src/routes/Dashboard.tsx Normal file
View File

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

131
src/routes/LoginKasir.tsx Normal file
View File

@ -0,0 +1,131 @@
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 + '! 🎉');
navigate('/dashboard');
} catch (err: any) {
console.error("Detail Error Login:", err);
// Tampilkan pesan error dari backend, jika gagal total pakai pesan default
setError(err.response?.data?.message || err.message || 'Gagal terhubung ke server');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-[#1A1D21] flex items-center justify-center p-4">
<div className="max-w-md w-full bg-[#24272C] rounded-2xl p-8 shadow-2xl border border-white/5">
{/* Header */}
<div className="text-center mb-8">
<div className="bg-blue-500/10 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4">
<Lock className="text-blue-500" size={32} />
</div>
<h2 className="text-2xl font-bold text-white">Portal BAST</h2>
<p className="text-gray-400 text-sm mt-2">Silahkan masuk ke akun anda</p>
</div>
{/* Notif Error */}
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-500 p-3 rounded-lg mb-6 flex items-center gap-3 text-sm">
<ShieldAlert size={18} />
{error}
</div>
)}
<form onSubmit={handleLogin}>
{/* Input Username */}
<LoginInput
label="Username"
name="username"
type="text"
icon={User}
placeholder="Masukkan username"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
{/* Input Password */}
<LoginInput
label="Password"
name="password"
type={showPassword ? 'text' : 'password'}
icon={Lock}
placeholder="••••••••"
showEye={true}
toggleEye={() => setShowPassword(!showPassword)}
eyeIcon={showPassword ? EyeOff : Eye}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{/* Tombol Submit */}
<button
type="submit"
disabled={loading}
className="w-full mt-6 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-800 disabled:cursor-not-allowed text-white font-semibold py-3 rounded-xl shadow-lg shadow-blue-500/20 transition-all transform active:scale-[0.98]"
>
{loading ? 'Menghubungkan...' : 'Masuk Sekarang'}
</button>
</form>
<p className="text-center text-gray-500 text-xs mt-8">
&copy; 2026 WinterAccess - Login Kasir
</p>
</div>
</div>
);
};
export default LoginKasir;

View File

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

202
src/routes/MasterData.tsx Normal file
View File

@ -0,0 +1,202 @@
// src/routes/MasterData.tsx
import { useState, useEffect } from "react";
import axios from "axios";
// COMPONENTS
import BarangHero from "../components/masterData/BarangHero";
import EditBarangModal from "../components/masterData/EditBarangModal";
import TambahBarang from "../components/masterData/TambahBarang";
// INTERFACE UTAMA DATA BARANG CIFO
export interface BarangType {
barang_id: number;
kode_barang: string;
nama_barang: string;
stok: number;
status_barang: string; // 'Baik' / 'Rusak'
qr_image: string; // Base64 string dari backend Express
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);
// 1. FUNGSI READ: Mengambil data barang real-time dari DB backend Express
const muatDataBarang = async () => {
setLoading(true);
try {
const respon = await axios.get("http://localhost:5000/api/barang");
// Pengaman jika struktur response backend dibungkus { success: true, data: [...] }
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();
}, []);
// 2. FUNGSI FILTER KATEGORI SECARA DINAMIS
const filteredBarang = barangList.filter((barang) => {
if (selectedKategori === "Semua") return true;
return barang.nama_kategori?.toLowerCase() === selectedKategori.toLowerCase();
});
// 3. TRIGGER SETELAH BERHASIL SAVE / EDIT / TAMBAH
const handleSaveSuccess = () => {
muatDataBarang(); // Refresh list barang dari DB
setSelectedBarang(null); // Tutup Modal Edit
setShowTambah(false); // Tutup Modal Tambah
};
return (
<div className="p-8 bg-gray-100 min-h-screen text-gray-800">
{/* HEADER HERO CARD */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-6">
<div className="flex-1 w-full">
<BarangHero />
</div>
<button
onClick={() => setShowTambah(true)}
className="bg-blue-600 hover:bg-blue-700 text-white font-bold px-6 py-4 rounded-2xl shadow-lg transition duration-200 whitespace-nowrap"
>
➕ Tambah Barang UKK
</button>
</div>
{/* FILTER KATEGORI BARANG */}
<div className="flex gap-2 mb-6 overflow-x-auto pb-2">
{["Semua", "Elektronik", "Buku dan Modul", "Alat Tulis"].map((kat) => (
<button
key={kat}
onClick={() => setSelectedKategori(kat)}
className={`px-5 py-2.5 rounded-xl font-semibold transition text-sm ${
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>
{/* GRID DAFTAR INVENTARIS UTAMA */}
{loading ? (
<div className="p-12 text-center bg-white rounded-3xl border border-gray-200 font-medium text-blue-600 animate-pulse">
Narik data & QR Code dari server CIFO... ⏳
</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">
Belum ada aset terdaftar di kategori ini, Bang! 📦
</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>
{/* BOX FRAME IMAGE DENGAN PROTESKSI ANTI IMAGE PECAH */}
<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'; // Sembunyikan gambar yang rusak
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>
{/* BOTTOM ACTION & BARCODE PREVIEW CARD */}
<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" className="w-12 h-12 rounded-lg border p-0.5 bg-white shadow-sm" />
)}
<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"
>
✏️
</button>
</div>
</div>
))
)}
</div>
)}
{/* RENDER MODAL EDIT BARANG (Props disinkronkan dengan EditBarangModal.tsx baru) */}
{selectedBarang && (
<EditBarangModal
itemSelected={selectedBarang}
onClose={() => setSelectedBarang(null)}
onRefresh={handleSaveSuccess}
/>
)}
{/* RENDER MODAL TAMBAH BARANG (Props disinkronkan dengan TambahBarang.tsx baru) */}
{showTambah && (
<TambahBarang
onClose={() => setShowTambah(false)}
onRefresh={handleSaveSuccess}
/>
)}
</div>
);
}

15
tailwind.config.js Normal file
View File

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

25
tsconfig.app.json Normal file
View File

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

7
tsconfig.json Normal file
View File

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

24
tsconfig.node.json Normal file
View File

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

7
vite.config.ts Normal file
View File

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