first commit
|
|
@ -0,0 +1,24 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>kasir</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
After Width: | Height: | Size: 9.3 KiB |
|
|
@ -0,0 +1,24 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
|
|
@ -0,0 +1,184 @@
|
|||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { RouterProvider } from "react-router-dom";
|
||||
import { router } from "./Routes";
|
||||
|
||||
function App() {
|
||||
const [darkMode, setDarkMode] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const savedTheme = localStorage.getItem("theme");
|
||||
|
||||
if (savedTheme === "dark") {
|
||||
setDarkMode(true);
|
||||
document.documentElement.classList.add("dark");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (darkMode) {
|
||||
document.documentElement.classList.add("dark");
|
||||
localStorage.setItem("theme", "dark");
|
||||
} else {
|
||||
document.documentElement.classList.remove("dark");
|
||||
localStorage.setItem("theme", "light");
|
||||
}
|
||||
}, [darkMode]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white text-black dark:bg-gray-900 dark:text-white transition-all duration-300">
|
||||
<RouterProvider router={router} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
|
@ -0,0 +1,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 />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 757 KiB |
|
After Width: | Height: | Size: 750 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 4.3 MiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 821 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 523 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
|
@ -0,0 +1,13 @@
|
|||
export default function About() {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl shadow p-6 mb-8">
|
||||
<h2 className="text-2xl font-bold mb-3">
|
||||
Tentang Sistem
|
||||
</h2>
|
||||
|
||||
<p className="text-gray-600">
|
||||
Aplikasi kasir dan peminjaman barang sekolah ini dirancang untuk mempermudah pengelolaan transaksi penjualan, pencatatan data barang, serta proses peminjaman perlengkapan sekolah seperti buku, alat tulis, dan perangkat pendukung lainnya. Dengan sistem yang modern dan terintegrasi, pengguna dapat mengelola stok barang, melakukan transaksi, memantau data peminjaman, serta melihat laporan dengan lebih cepat, akurat, dan aman. Dashboard utama memberikan akses mudah ke seluruh fitur penting sehingga operasional sekolah menjadi lebih terorganisir, efisien, dan profesional.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import novel from "../../assets/novel.png";
|
||||
import elektronik from "../../assets/elektronik.png";
|
||||
import alat from "../../assets/alat.png";
|
||||
|
||||
export default function Content() {
|
||||
const items = [
|
||||
{
|
||||
name: "novel",
|
||||
image: novel,
|
||||
function:
|
||||
"rekreatif (hiburan), didaktif (mendidik), moralitas (penanaman nilai baik-buruk), religius (ajaran agama), dan estetis (keindahan bahasa/cerita).",
|
||||
},
|
||||
{
|
||||
name: "elektronik",
|
||||
image: elektronik,
|
||||
function:
|
||||
"Elektronik digunakan untuk mempermudah pekerjaan manusia, memberikan hiburan, membantu komunikasi, serta meningkatkan efisiensi dan keamanan dalam kehidupan sehari-hari.",
|
||||
},
|
||||
{
|
||||
name: "Alat Tulis",
|
||||
image: alat ,
|
||||
function:
|
||||
"mencatat informasi, mendukung administrasi perkantoran, menunjang kegiatan belajar-mengajar, serta memfasilitasi ekspresi kreatif seperti menggambar dan merancang.",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-8 bg-white p-6 rounded-2xl">
|
||||
<h2 className="text-3xl font-bold text-black mb-6 border-b-4 border-blue-600 pb-2">
|
||||
Barang Barang
|
||||
</h2>
|
||||
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-black text-white rounded-2xl shadow-lg p-6 flex items-center gap-8 border-l-4 border-blue-600"
|
||||
>
|
||||
{/* KIRI - PENJELASAN */}
|
||||
<div className="flex-1">
|
||||
<h3 className="text-2xl font-bold text-blue-400 mb-3">
|
||||
{item.name}
|
||||
</h3>
|
||||
|
||||
<p className="text-gray-300 leading-relaxed">
|
||||
{item.function}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* KANAN - FOTO */}
|
||||
<div className="w-72">
|
||||
<img
|
||||
src={item.image}
|
||||
alt={item.name}
|
||||
className="w-full h-56 object-cover rounded-xl shadow border-2 border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
|
||||
interface DashboardStats {
|
||||
permintaanHariIni: number;
|
||||
menungguPersetujuan: number;
|
||||
totalBarang: number;
|
||||
penggunaAktif: number;
|
||||
stokHampirHabis: number;
|
||||
}
|
||||
|
||||
export default function Hero() {
|
||||
// 1. Ambil data profil dari LocalStorage hasil login sebelumnya
|
||||
const userName = localStorage.getItem('user_name') || 'User';
|
||||
const userRole = localStorage.getItem('user_role') || 'Siswa';
|
||||
|
||||
// State untuk menampung angka statistik dari database
|
||||
const [stats, setStats] = useState<DashboardStats>({
|
||||
permintaanHariIni: 0,
|
||||
menungguPersetujuan: 0,
|
||||
totalBarang: 0,
|
||||
penggunaAktif: 0,
|
||||
stokHampirHabis: 0
|
||||
});
|
||||
|
||||
// 2. Fungsi membuat inisial nama (Contoh: "Bu Sari" -> "BS", "Admin" -> "AD")
|
||||
const dapatkanInisial = (nama: string) => {
|
||||
const kata = nama.split(' ');
|
||||
if (kata.length >= 2) {
|
||||
return (kata[0][0] + kata[1][0]).toUpperCase();
|
||||
}
|
||||
return nama.substring(0, 2).toUpperCase();
|
||||
};
|
||||
|
||||
// 3. Efek untuk mengambil data counter statistik dari backend pas halaman dibuka
|
||||
useEffect(() => {
|
||||
const ambilStatistikDashboard = async () => {
|
||||
try {
|
||||
// Silahkan buat rute /api/dashboard-stats di backend jika ingin memisahkan kueri count-nya
|
||||
// Sebagai alternatif awal, kita ambil total barang & total bast dari rute yang sudah ada
|
||||
const resBarang = await axios.get('http://localhost:5000/api/barang');
|
||||
const resBast = await axios.get('http://localhost:5000/api/bast');
|
||||
const resUser = await axios.get('http://localhost:5000/api/users');
|
||||
|
||||
// Hitung total data berdasarkan respon database
|
||||
const totalBarang = resBarang.data.success ? resBarang.data.data.length : 0;
|
||||
const listBast = resBast.data.success ? resBast.data.data : [];
|
||||
const totalUser = resUser.data.success ? resUser.data.data.length : 0;
|
||||
|
||||
// Filter data untuk counter spesifik dokumen BAST
|
||||
const menunggu = listBast.filter((b: any) => b.status_serah === 'Menunggu' || b.status_terima === 'Menunggu').length;
|
||||
|
||||
// Simulasi hitung permintaan hari ini dari tanggal dibuat_pada (UKK basic)
|
||||
const hariIniFormat = new Date().toISOString().split('T')[0];
|
||||
const hariIni = listBast.filter((b: any) => b.dibuat_pada && b.dibuat_pada.startsWith(hariIniFormat)).length;
|
||||
|
||||
// Hitung barang dengan stok kritis (di bawah 5 buah)
|
||||
const kritis = resBarang.data.success
|
||||
? resBarang.data.data.filter((brg: any) => brg.stok <= 3).length
|
||||
: 0;
|
||||
|
||||
setStats({
|
||||
permintaanHariIni: hariIni,
|
||||
menungguPersetujuan: menunggu,
|
||||
totalBarang: totalBarang,
|
||||
penggunaAktif: totalUser,
|
||||
stokHampirHabis: kritis
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Gagal memuat statistik dashboard:", error);
|
||||
}
|
||||
};
|
||||
|
||||
ambilStatistikDashboard();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="bg-blue-50 rounded-3xl p-8 shadow-sm border border-blue-100 mb-8">
|
||||
{/* HEADER */}
|
||||
<div className="flex justify-between items-start mb-8">
|
||||
<div>
|
||||
<p className="text-gray-500 text-lg">
|
||||
Selamat datang kembali 👋
|
||||
</p>
|
||||
|
||||
<h1 className="text-5xl font-extrabold text-blue-700 mt-2">
|
||||
Dashboard
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* PROFILE */}
|
||||
<div className="flex items-center gap-3 bg-white rounded-full px-4 py-2 shadow border">
|
||||
<div className="w-12 h-12 bg-blue-500 rounded-full flex items-center justify-center text-white font-bold">
|
||||
{dapatkanInisial(userName)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold text-black">
|
||||
{userName}
|
||||
</h3>
|
||||
<p className="text-gray-500 text-sm capitalize">
|
||||
{userRole}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* STAT CARDS */}
|
||||
<div className="grid grid-cols-4 gap-6">
|
||||
{/* CARD 1 */}
|
||||
<div className="bg-white rounded-2xl p-6 shadow border">
|
||||
<p className="text-gray-500 mb-2">
|
||||
Permintaan Hari Ini
|
||||
</p>
|
||||
<h2 className="text-4xl font-bold text-blue-700">
|
||||
{stats.permintaanHariIni}
|
||||
</h2>
|
||||
<p className="text-green-600 mt-2 text-sm">
|
||||
Dokumen baru masuk
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* CARD 2 */}
|
||||
<div className="bg-white rounded-2xl p-6 shadow border">
|
||||
<p className="text-gray-500 mb-2">
|
||||
Menunggu Persetujuan
|
||||
</p>
|
||||
<h2 className="text-4xl font-bold text-blue-700">
|
||||
{stats.menungguPersetujuan}
|
||||
</h2>
|
||||
<p className="text-yellow-600 mt-2 text-sm">
|
||||
Perlu tindakan segera
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* CARD 3 */}
|
||||
<div className="bg-white rounded-2xl p-6 shadow border">
|
||||
<p className="text-gray-500 mb-2">
|
||||
Total Ragam Barang
|
||||
</p>
|
||||
<h2 className="text-4xl font-bold text-blue-700">
|
||||
{stats.totalBarang}
|
||||
</h2>
|
||||
<p className="text-red-500 mt-2 text-sm">
|
||||
{stats.stokHampirHabis} stok hampir habis
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* CARD 4 */}
|
||||
<div className="bg-white rounded-2xl p-6 shadow border">
|
||||
<p className="text-gray-500 mb-2">
|
||||
Total Terdaftar
|
||||
</p>
|
||||
<h2 className="text-4xl font-bold text-blue-700">
|
||||
{stats.penggunaAktif}
|
||||
</h2>
|
||||
<p className="text-green-600 mt-2 text-sm">
|
||||
Siswa & petugas aktif
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
name: string;
|
||||
type: string;
|
||||
icon: LucideIcon;
|
||||
placeholder: string;
|
||||
showEye?: boolean;
|
||||
toggleEye?: () => void;
|
||||
eyeIcon?: LucideIcon;
|
||||
// --- TAMBAHKAN DUA INI ---
|
||||
value: string;
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
export default function LoginInput({
|
||||
label, name, type, icon: Icon, placeholder, showEye, toggleEye, eyeIcon: EyeIcon,
|
||||
value, onChange // <--- Terima props di sini
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-400 text-sm mb-1">{label}:</label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center text-gray-500 group-focus-within:text-[#4A90E2]">
|
||||
<Icon size={20} />
|
||||
</div>
|
||||
<input
|
||||
name={name}
|
||||
type={type}
|
||||
placeholder={placeholder}
|
||||
// --- PASANG DI SINI ---
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className="w-full bg-[#3D444D] border border-gray-600 rounded-lg pl-11 pr-10 py-3 text-white focus:outline-none focus:border-[#4A90E2] transition-all placeholder:text-gray-500"
|
||||
/>
|
||||
{showEye && toggleEye && EyeIcon && (
|
||||
<button type="button" onClick={toggleEye} className="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-500 hover:text-white">
|
||||
<EyeIcon size={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
export default function MyButton({ text, type = "button" }: { text: string; type?: "submit" | "button" }) {
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
className="w-full bg-[#4A90E2] hover:bg-[#357ABD] text-white font-semibold py-3 rounded-lg transition-all active:scale-95 shadow-lg mt-4"
|
||||
>
|
||||
{text}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
// src/layout/DashboardLayout.tsx
|
||||
|
||||
import { useState } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import Sidebar from "../components/sidebar/Sidebar";
|
||||
|
||||
export default function DashboardLayout() {
|
||||
const [isOpen, setIsOpen] =
|
||||
useState(true);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-[#f4f8ff]">
|
||||
{/* SIDEBAR */}
|
||||
<Sidebar
|
||||
isOpen={isOpen}
|
||||
setIsOpen={setIsOpen}
|
||||
/>
|
||||
|
||||
{/* MAIN CONTENT */}
|
||||
<main
|
||||
className={`flex-1 transition-all duration-300 ${
|
||||
isOpen
|
||||
? "ml-64"
|
||||
: "ml-20"
|
||||
}`}
|
||||
>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
|
|
@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
export default function Settings() {
|
||||
const settingsMenu = [
|
||||
"Profil Toko",
|
||||
"Manajemen User",
|
||||
"Ganti Password",
|
||||
"Printer Struk",
|
||||
"Pengaturan Pajak",
|
||||
"Kategori Barang",
|
||||
"Backup Data",
|
||||
"Tentang Aplikasi",
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-8 bg-[#f4f7fe] min-h-screen">
|
||||
|
||||
<div className="mb-8">
|
||||
<h1 className="text-5xl font-bold text-gray-800">
|
||||
Settings
|
||||
</h1>
|
||||
|
||||
<p className="text-gray-500 mt-2">
|
||||
Pengaturan aplikasi kasir
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6">
|
||||
|
||||
{settingsMenu.map((item, index) => (
|
||||
|
||||
<div
|
||||
key={index}
|
||||
className="
|
||||
bg-white
|
||||
rounded-3xl
|
||||
p-6
|
||||
shadow-md
|
||||
hover:shadow-xl
|
||||
transition
|
||||
duration-300
|
||||
cursor-pointer
|
||||
border
|
||||
"
|
||||
>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-800">
|
||||
{item}
|
||||
</h2>
|
||||
|
||||
<p className="text-gray-500 text-sm mt-2">
|
||||
Klik untuk membuka pengaturan
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="
|
||||
w-12
|
||||
h-12
|
||||
rounded-2xl
|
||||
bg-blue-100
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
text-2xl
|
||||
x
|
||||
">
|
||||
⚙️
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
))}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,0 +1,13 @@
|
|||
import Hero from "../components/dashboard/Hero";
|
||||
import About from "../components/dashboard/About";
|
||||
import Content from "../components/dashboard/Content";
|
||||
|
||||
export default function Dashboard() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 p-6 transition-all duration-300">
|
||||
<Hero />
|
||||
<About />
|
||||
<Content />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,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">
|
||||
© 2026 WinterAccess - Login Kasir
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginKasir;
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import axios from "axios";
|
||||
|
||||
// COMPONENTS
|
||||
import UsersHero from "../components/manajemenUsers/UserHero";
|
||||
import UsersFilter from "../components/manajemenUsers/UserFilter";
|
||||
import UsersTable from "../components/manajemenUsers/UserTable";
|
||||
|
||||
// Definisikan tipe data User sesuai kolom database CIFO Abang
|
||||
export interface UserData {
|
||||
user_id: number;
|
||||
nama_lengkap: string;
|
||||
username: string;
|
||||
role: string;
|
||||
email: string | null;
|
||||
lembaga: string | null;
|
||||
}
|
||||
|
||||
export default function ManajemenUsers() {
|
||||
const [users, setUsers] = useState<UserData[]>([]);
|
||||
const [filteredUsers, setFilteredUsers] = useState<UserData[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [kataKunci, setKataKunci] = useState<string>("");
|
||||
|
||||
// 1. FUNGSI READ: Ambil data user dari backend Express
|
||||
const ambilDataUsers = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const respon = await axios.get("http://localhost:5000/api/users");
|
||||
if (respon.data.success) {
|
||||
setUsers(respon.data.data);
|
||||
setFilteredUsers(respon.data.data); // Inisialisasi data filter
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Gagal mengambil data user:", error);
|
||||
alert("Waduh Bang, gagal konek ke API server!");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Jalankan fungsi ambil data saat halaman pertama kali dibuka
|
||||
useEffect(() => {
|
||||
ambilDataUsers();
|
||||
}, []);
|
||||
|
||||
// 2. FUNGSI FILTER: Menyaring data berdasarkan inputan di UsersFilter
|
||||
useEffect(() => {
|
||||
const hasilFilter = users.filter((user) =>
|
||||
user.nama_lengkap.toLowerCase().includes(kataKunci.toLowerCase()) ||
|
||||
user.username.toLowerCase().includes(kataKunci.toLowerCase()) ||
|
||||
(user.lembaga && user.lembaga.toLowerCase().includes(kataKunci.toLowerCase()))
|
||||
);
|
||||
setFilteredUsers(hasilFilter);
|
||||
}, [kataKunci, users]);
|
||||
|
||||
// 3. FUNGSI DELETE: Menghapus user berdasarkan user_id
|
||||
const handleHapusUser = async (user_id: number) => {
|
||||
if (window.confirm("Beneran mau hapus user ini, Bang?")) {
|
||||
try {
|
||||
const respon = await axios.delete(`http://localhost:5000/api/users/${user_id}`);
|
||||
if (respon.data.success) {
|
||||
alert(respon.data.message);
|
||||
ambilDataUsers(); // Muat ulang tabel biar data yang dihapus hilang
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Gagal hapus user:", error);
|
||||
// Menampilkan pesan error dari backend jika user terikat transaksi BAST
|
||||
alert(error.response?.data?.message || "Gagal menghapus user!");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#f4f8ff] dark:bg-gray-900 p-6 transition-all duration-300">
|
||||
{/* HERO: Kirim fungsi muat ulang supaya kalau abang sukses Tambah User, tabel langsung update */}
|
||||
<UsersHero onUserDitambahkan={ambilDataUsers} />
|
||||
|
||||
{/* FILTER: Kirim state kata kunci untuk mendeteksi ketikan pencarian */}
|
||||
<UsersFilter kataKunci={kataKunci} setKataKunci={setKataKunci} />
|
||||
|
||||
{/* TABLE: Kirim data hasil filter, status loading, fungsi hapus, dan fungsi refresh setelah edit */}
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center p-10 bg-white rounded-2xl shadow-sm border border-blue-100">
|
||||
<p className="text-blue-600 font-semibold animate-pulse">
|
||||
Sabar Bang, lagi narik data dari DB CIFO... ⏳
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<UsersTable
|
||||
dataUsers={filteredUsers}
|
||||
onHapusUser={handleHapusUser}
|
||||
onUserDiedit={ambilDataUsers}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: 'class',
|
||||
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
|
||||
plugins: [],
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "esnext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||